kimaki 0.4.83 → 0.4.85
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/cli.js +9 -2
- package/dist/commands/screenshare.js +14 -6
- package/dist/commands/screenshare.test.js +20 -0
- package/dist/config.js +16 -1
- package/dist/discord-bot.js +12 -59
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-opencode-sync.js +40 -63
- package/dist/gateway-proxy.e2e.test.js +8 -56
- package/dist/onboarding-tutorial.js +1 -1
- package/dist/queue-advanced-e2e-setup.js +36 -0
- package/dist/queue-question-select-drain.e2e.test.js +117 -0
- package/dist/session-handler/thread-session-runtime.js +50 -1
- package/dist/store.js +1 -0
- package/dist/system-message.js +16 -4
- package/package.json +5 -4
- package/skills/errore/SKILL.md +40 -13
- package/skills/goke/SKILL.md +12 -0
- package/skills/lintcn/SKILL.md +868 -0
- package/skills/spiceflow/SKILL.md +1 -1
- package/src/cli.ts +15 -1
- package/src/commands/screenshare.test.ts +30 -0
- package/src/commands/screenshare.ts +18 -6
- package/src/config.ts +19 -1
- package/src/discord-bot.ts +13 -70
- package/src/discord-command-registration.ts +1 -1
- package/src/external-opencode-sync.ts +40 -73
- package/src/gateway-proxy.e2e.test.ts +8 -67
- package/src/genai.ts +2 -2
- package/src/onboarding-tutorial.ts +1 -1
- package/src/queue-advanced-e2e-setup.ts +37 -0
- package/src/queue-question-select-drain.e2e.test.ts +149 -0
- package/src/session-handler/thread-session-runtime.ts +68 -1
- package/src/store.ts +8 -0
- package/src/system-message.ts +16 -4
package/dist/cli.js
CHANGED
|
@@ -26,7 +26,7 @@ import { createLogger, formatErrorWithStack, initLogFile, LogPrefix } from './lo
|
|
|
26
26
|
import { initSentry, notifyError } from './sentry.js';
|
|
27
27
|
import { archiveThread, uploadFilesToDiscord, stripMentions, } from './discord-utils.js';
|
|
28
28
|
import { spawn, execSync } from 'node:child_process';
|
|
29
|
-
import { setDataDir, getDataDir, getProjectsDir, } from './config.js';
|
|
29
|
+
import { setDataDir, setProjectsDir, getDataDir, getProjectsDir, } from './config.js';
|
|
30
30
|
import { execAsync } from './worktrees.js';
|
|
31
31
|
import { backgroundUpgradeKimaki, upgrade, getCurrentVersion, } from './upgrade.js';
|
|
32
32
|
import { startHranaServer } from './hrana-server.js';
|
|
@@ -1305,6 +1305,7 @@ cli
|
|
|
1305
1305
|
.option('--restart-onboarding', 'Prompt for new credentials even if saved')
|
|
1306
1306
|
.option('--add-channels', 'Select OpenCode projects to create Discord channels before starting')
|
|
1307
1307
|
.option('--data-dir <path>', 'Data directory for config and database (default: ~/.kimaki)')
|
|
1308
|
+
.option('--projects-dir <path>', 'Directory where new projects are created (default: <data-dir>/projects)')
|
|
1308
1309
|
.option('--install-url', 'Print the bot install URL and exit')
|
|
1309
1310
|
.option('--use-worktrees', 'Create git worktrees for all new sessions started from channel messages')
|
|
1310
1311
|
.option('--enable-voice-channels', 'Create voice channels for projects (disabled by default)')
|
|
@@ -1332,6 +1333,10 @@ cli
|
|
|
1332
1333
|
setDataDir(options.dataDir);
|
|
1333
1334
|
cliLogger.log(`Using data directory: ${getDataDir()}`);
|
|
1334
1335
|
}
|
|
1336
|
+
if (options.projectsDir) {
|
|
1337
|
+
setProjectsDir(options.projectsDir);
|
|
1338
|
+
cliLogger.log(`Using projects directory: ${getProjectsDir()}`);
|
|
1339
|
+
}
|
|
1335
1340
|
// Initialize file logging to <dataDir>/kimaki.log
|
|
1336
1341
|
initLogFile(getDataDir());
|
|
1337
1342
|
// Batch all CLI flag store updates into a single setState call.
|
|
@@ -2623,6 +2628,7 @@ cli
|
|
|
2623
2628
|
.option('-t, --tunnel-id [id]', 'Custom tunnel ID (only for services safe to expose publicly; prefer random default)')
|
|
2624
2629
|
.option('-h, --host [host]', 'Local host (default: localhost)')
|
|
2625
2630
|
.option('-s, --server [url]', 'Tunnel server URL')
|
|
2631
|
+
.option('-k, --kill', 'Kill any existing process on the port before starting')
|
|
2626
2632
|
.action(async (options) => {
|
|
2627
2633
|
const { runTunnel, parseCommandFromArgv, CLI_NAME } = await import('traforo/run-tunnel');
|
|
2628
2634
|
if (!options.port) {
|
|
@@ -2644,10 +2650,11 @@ cli
|
|
|
2644
2650
|
baseDomain: 'kimaki.xyz',
|
|
2645
2651
|
serverUrl: options.server,
|
|
2646
2652
|
command: command.length > 0 ? command : undefined,
|
|
2653
|
+
kill: options.kill,
|
|
2647
2654
|
});
|
|
2648
2655
|
});
|
|
2649
2656
|
cli
|
|
2650
|
-
.command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after
|
|
2657
|
+
.command('screenshare', 'Share your screen via VNC tunnel. Auto-stops after 30 minutes. Runs until Ctrl+C. Use tmux to run in background.')
|
|
2651
2658
|
.action(async () => {
|
|
2652
2659
|
const { startScreenshare } = await import('./commands/screenshare.js');
|
|
2653
2660
|
try {
|
|
@@ -15,11 +15,14 @@ import { startWebsockify } from '../websockify.js';
|
|
|
15
15
|
import { createLogger } from '../logger.js';
|
|
16
16
|
import { execAsync } from '../worktrees.js';
|
|
17
17
|
const logger = createLogger('SCREEN');
|
|
18
|
+
const SECURE_REPLY_FLAGS = MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS;
|
|
18
19
|
/** One active screenshare per guild (Discord) or per machine (CLI) */
|
|
19
20
|
const activeSessions = new Map();
|
|
20
21
|
const VNC_PORT = 5900;
|
|
21
|
-
const
|
|
22
|
+
const MAX_SESSION_MINUTES = 30;
|
|
23
|
+
const MAX_SESSION_MS = MAX_SESSION_MINUTES * 60 * 1000;
|
|
22
24
|
const TUNNEL_BASE_DOMAIN = 'kimaki.xyz';
|
|
25
|
+
const SCREENSHARE_TUNNEL_ID_BYTES = 16;
|
|
23
26
|
// Public noVNC client — we point it at our tunnel URL
|
|
24
27
|
export function buildNoVncUrl({ tunnelHost }) {
|
|
25
28
|
const params = new URLSearchParams({
|
|
@@ -32,6 +35,9 @@ export function buildNoVncUrl({ tunnelHost }) {
|
|
|
32
35
|
});
|
|
33
36
|
return `https://novnc.com/noVNC/vnc.html?${params.toString()}`;
|
|
34
37
|
}
|
|
38
|
+
export function createScreenshareTunnelId() {
|
|
39
|
+
return crypto.randomBytes(SCREENSHARE_TUNNEL_ID_BYTES).toString('hex');
|
|
40
|
+
}
|
|
35
41
|
// macOS has two separate services:
|
|
36
42
|
// - "Screen Sharing" = view-only VNC (com.apple.screensharing)
|
|
37
43
|
// - "Remote Management" = full control VNC with mouse/keyboard (ARDAgent)
|
|
@@ -170,7 +176,7 @@ export async function startScreenshare({ sessionKey, startedBy, }) {
|
|
|
170
176
|
throw err;
|
|
171
177
|
}
|
|
172
178
|
// Step 3: create tunnel
|
|
173
|
-
const tunnelId =
|
|
179
|
+
const tunnelId = createScreenshareTunnelId();
|
|
174
180
|
const tunnelClient = new TunnelClient({
|
|
175
181
|
localPort: wsInstance.port,
|
|
176
182
|
tunnelId,
|
|
@@ -197,9 +203,9 @@ export async function startScreenshare({ sessionKey, startedBy, }) {
|
|
|
197
203
|
const tunnelHost = `${tunnelId}-tunnel.${TUNNEL_BASE_DOMAIN}`;
|
|
198
204
|
const tunnelUrl = `https://${tunnelHost}`;
|
|
199
205
|
const noVncUrl = buildNoVncUrl({ tunnelHost });
|
|
200
|
-
// Auto-kill after
|
|
206
|
+
// Auto-kill after a short session so a leaked URL does not stay usable all day.
|
|
201
207
|
const timeoutTimer = setTimeout(() => {
|
|
202
|
-
logger.log(`Screen share auto-stopped after
|
|
208
|
+
logger.log(`Screen share auto-stopped after ${MAX_SESSION_MINUTES} minutes (key: ${sessionKey})`);
|
|
203
209
|
stopScreenshare({ sessionKey });
|
|
204
210
|
}, MAX_SESSION_MS);
|
|
205
211
|
// Don't keep the process alive just for this timer
|
|
@@ -240,14 +246,16 @@ export async function handleScreenshareCommand({ command, }) {
|
|
|
240
246
|
});
|
|
241
247
|
return;
|
|
242
248
|
}
|
|
243
|
-
await command.deferReply({ flags:
|
|
249
|
+
await command.deferReply({ flags: SECURE_REPLY_FLAGS });
|
|
244
250
|
try {
|
|
245
251
|
const session = await startScreenshare({
|
|
246
252
|
sessionKey: guildId,
|
|
247
253
|
startedBy: command.user.tag,
|
|
248
254
|
});
|
|
249
255
|
await command.editReply({
|
|
250
|
-
content: `Screen sharing started
|
|
256
|
+
content: `Screen sharing started. This reply is private and the URL uses a high-entropy tunnel id. ` +
|
|
257
|
+
`It will auto-stop after ${MAX_SESSION_MINUTES} minutes. Use /screenshare-stop to stop sooner.\n` +
|
|
258
|
+
`${session.noVncUrl}`,
|
|
251
259
|
});
|
|
252
260
|
}
|
|
253
261
|
catch (err) {
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { describe, expect, test } from 'vitest';
|
|
2
|
+
import { buildNoVncUrl, createScreenshareTunnelId } from './screenshare.js';
|
|
3
|
+
describe('screenshare security defaults', () => {
|
|
4
|
+
test('generates a 128-bit tunnel id', () => {
|
|
5
|
+
const ids = new Set(Array.from({ length: 32 }, () => {
|
|
6
|
+
return createScreenshareTunnelId();
|
|
7
|
+
}));
|
|
8
|
+
expect(ids.size).toBe(32);
|
|
9
|
+
for (const id of ids) {
|
|
10
|
+
expect(id).toMatch(/^[0-9a-f]{32}$/);
|
|
11
|
+
}
|
|
12
|
+
});
|
|
13
|
+
test('builds a secure noVNC URL', () => {
|
|
14
|
+
const url = new URL(buildNoVncUrl({ tunnelHost: '0123456789abcdef-tunnel.kimaki.xyz' }));
|
|
15
|
+
expect(url.origin).toBe('https://novnc.com');
|
|
16
|
+
expect(url.searchParams.get('host')).toBe('0123456789abcdef-tunnel.kimaki.xyz');
|
|
17
|
+
expect(url.searchParams.get('port')).toBe('443');
|
|
18
|
+
expect(url.searchParams.get('encrypt')).toBe('1');
|
|
19
|
+
});
|
|
20
|
+
});
|
package/dist/config.js
CHANGED
|
@@ -42,11 +42,26 @@ export function setDataDir(dir) {
|
|
|
42
42
|
}
|
|
43
43
|
/**
|
|
44
44
|
* Get the projects directory path (for /create-new-project command).
|
|
45
|
-
* Returns <dataDir>/projects
|
|
45
|
+
* Returns the custom --projects-dir if set, otherwise <dataDir>/projects.
|
|
46
46
|
*/
|
|
47
47
|
export function getProjectsDir() {
|
|
48
|
+
const custom = store.getState().projectsDir;
|
|
49
|
+
if (custom) {
|
|
50
|
+
return custom;
|
|
51
|
+
}
|
|
48
52
|
return path.join(getDataDir(), 'projects');
|
|
49
53
|
}
|
|
54
|
+
/**
|
|
55
|
+
* Set a custom projects directory path (from --projects-dir CLI flag).
|
|
56
|
+
* Creates the directory if it doesn't exist.
|
|
57
|
+
*/
|
|
58
|
+
export function setProjectsDir(dir) {
|
|
59
|
+
const resolvedDir = path.resolve(dir);
|
|
60
|
+
if (!fs.existsSync(resolvedDir)) {
|
|
61
|
+
fs.mkdirSync(resolvedDir, { recursive: true });
|
|
62
|
+
}
|
|
63
|
+
store.setState({ projectsDir: resolvedDir });
|
|
64
|
+
}
|
|
50
65
|
const DEFAULT_LOCK_PORT = 29988;
|
|
51
66
|
/**
|
|
52
67
|
* Derive a lock port from the data directory path.
|
package/dist/discord-bot.js
CHANGED
|
@@ -77,8 +77,6 @@ function describeCloseCode(code) {
|
|
|
77
77
|
return codes[code] || 'unknown';
|
|
78
78
|
}
|
|
79
79
|
const shardReconnectState = new Map();
|
|
80
|
-
const shardReconnectRecoveryTimeouts = new Map();
|
|
81
|
-
const GATEWAY_RELOGIN_GRACE_MS = 10_000;
|
|
82
80
|
function getOrCreateShardState(shardId) {
|
|
83
81
|
let state = shardReconnectState.get(shardId);
|
|
84
82
|
if (!state) {
|
|
@@ -144,46 +142,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
144
142
|
discordClient = await createDiscordClient();
|
|
145
143
|
}
|
|
146
144
|
let currentAppId = appId;
|
|
147
|
-
let runtimeHandlersRegistered = false;
|
|
148
|
-
let gatewayReloginInFlight = false;
|
|
149
|
-
const clearShardRecoveryTimeout = ({ shardId }) => {
|
|
150
|
-
const timeout = shardReconnectRecoveryTimeouts.get(shardId);
|
|
151
|
-
if (!timeout) {
|
|
152
|
-
return;
|
|
153
|
-
}
|
|
154
|
-
clearTimeout(timeout);
|
|
155
|
-
shardReconnectRecoveryTimeouts.delete(shardId);
|
|
156
|
-
};
|
|
157
|
-
const forceGatewayRelogin = ({ shardId }) => {
|
|
158
|
-
if (gatewayReloginInFlight) {
|
|
159
|
-
return;
|
|
160
|
-
}
|
|
161
|
-
gatewayReloginInFlight = true;
|
|
162
|
-
void (async () => {
|
|
163
|
-
discordLogger.warn(`[GATEWAY] Shard ${shardId} stayed reconnecting for ${GATEWAY_RELOGIN_GRACE_MS}ms, forcing client relogin`);
|
|
164
|
-
try {
|
|
165
|
-
discordClient.destroy();
|
|
166
|
-
await discordClient.login(token);
|
|
167
|
-
}
|
|
168
|
-
catch (error) {
|
|
169
|
-
discordLogger.error(`[GATEWAY] Forced relogin failed: ${formatErrorWithStack(error)}`);
|
|
170
|
-
}
|
|
171
|
-
finally {
|
|
172
|
-
gatewayReloginInFlight = false;
|
|
173
|
-
}
|
|
174
|
-
})();
|
|
175
|
-
};
|
|
176
|
-
const scheduleShardRecoveryTimeout = ({ shardId }) => {
|
|
177
|
-
clearShardRecoveryTimeout({ shardId });
|
|
178
|
-
const timeout = setTimeout(() => {
|
|
179
|
-
const state = shardReconnectState.get(shardId);
|
|
180
|
-
if (!state?.attempts) {
|
|
181
|
-
return;
|
|
182
|
-
}
|
|
183
|
-
forceGatewayRelogin({ shardId });
|
|
184
|
-
}, GATEWAY_RELOGIN_GRACE_MS);
|
|
185
|
-
shardReconnectRecoveryTimeouts.set(shardId, timeout);
|
|
186
|
-
};
|
|
187
145
|
const setupHandlers = async (c) => {
|
|
188
146
|
discordLogger.log(`Discord bot logged in as ${c.user.tag}`);
|
|
189
147
|
discordLogger.log(`Connected to ${c.guilds.cache.size} guild(s)`);
|
|
@@ -202,12 +160,9 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
202
160
|
}
|
|
203
161
|
voiceLogger.log('[READY] Bot is ready');
|
|
204
162
|
markDiscordGatewayReady();
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
startExternalOpencodeSessionSync({ discordClient: c });
|
|
209
|
-
runtimeHandlersRegistered = true;
|
|
210
|
-
}
|
|
163
|
+
registerInteractionHandler({ discordClient: c, appId: currentAppId });
|
|
164
|
+
registerVoiceStateHandler({ discordClient: c, appId: currentAppId });
|
|
165
|
+
startExternalOpencodeSessionSync({ discordClient: c });
|
|
211
166
|
// Channel logging is informational only; do it in background so startup stays responsive.
|
|
212
167
|
void (async () => {
|
|
213
168
|
for (const guild of c.guilds.cache.values()) {
|
|
@@ -226,14 +181,16 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
226
181
|
};
|
|
227
182
|
// If client is already ready (was logged in before being passed to us),
|
|
228
183
|
// run setup immediately. Otherwise wait for the ClientReady event.
|
|
229
|
-
discordClient.on(Events.ClientReady, (readyClient) => {
|
|
230
|
-
void setupHandlers(readyClient).catch((error) => {
|
|
231
|
-
discordLogger.error(`[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`);
|
|
232
|
-
});
|
|
233
|
-
});
|
|
234
184
|
if (discordClient.isReady()) {
|
|
235
185
|
await setupHandlers(discordClient);
|
|
236
186
|
}
|
|
187
|
+
else {
|
|
188
|
+
discordClient.once(Events.ClientReady, (readyClient) => {
|
|
189
|
+
void setupHandlers(readyClient).catch((error) => {
|
|
190
|
+
discordLogger.error(`[GATEWAY] ClientReady handler failed: ${formatErrorWithStack(error)}`);
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
}
|
|
237
194
|
discordClient.on(Events.Error, (error) => {
|
|
238
195
|
discordLogger.error('[GATEWAY] Client error:', formatErrorWithStack(error));
|
|
239
196
|
});
|
|
@@ -262,10 +219,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
262
219
|
parts.push(`last error: ${state.lastError.message}`);
|
|
263
220
|
}
|
|
264
221
|
discordLogger.warn(`[GATEWAY] Shard ${shardId} reconnecting: ${parts.join(', ')}`);
|
|
265
|
-
scheduleShardRecoveryTimeout({ shardId });
|
|
266
222
|
});
|
|
267
223
|
discordClient.on(Events.ShardResume, (shardId, replayedEvents) => {
|
|
268
|
-
clearShardRecoveryTimeout({ shardId });
|
|
269
224
|
const state = shardReconnectState.get(shardId);
|
|
270
225
|
if (state?.attempts) {
|
|
271
226
|
discordLogger.log(`[GATEWAY] Shard ${shardId} resumed after ${state.attempts} reconnect attempt(s), ${replayedEvents} replayed events`);
|
|
@@ -279,7 +234,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
279
234
|
// After a gateway proxy redeploy, sessions are lost (in-memory), so RESUME
|
|
280
235
|
// fails with INVALID_SESSION and discord.js falls back to fresh IDENTIFY.
|
|
281
236
|
discordClient.on(Events.ShardReady, (shardId) => {
|
|
282
|
-
clearShardRecoveryTimeout({ shardId });
|
|
283
237
|
const state = shardReconnectState.get(shardId);
|
|
284
238
|
if (state?.attempts) {
|
|
285
239
|
discordLogger.log(`[GATEWAY] Shard ${shardId} ready after ${state.attempts} reconnect attempt(s)`);
|
|
@@ -287,9 +241,6 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
287
241
|
shardReconnectState.delete(shardId);
|
|
288
242
|
});
|
|
289
243
|
discordClient.on(Events.Invalidated, () => {
|
|
290
|
-
for (const shardId of shardReconnectRecoveryTimeouts.keys()) {
|
|
291
|
-
clearShardRecoveryTimeout({ shardId });
|
|
292
|
-
}
|
|
293
244
|
discordLogger.error('[GATEWAY] Session invalidated by Discord');
|
|
294
245
|
});
|
|
295
246
|
discordClient.on(Events.MessageCreate, async (message) => {
|
|
@@ -646,6 +597,8 @@ export async function startDiscordBot({ token, appId, discordClient, useWorktree
|
|
|
646
597
|
prompt: '',
|
|
647
598
|
userId: message.author.id,
|
|
648
599
|
username: message.member?.displayName || message.author.displayName,
|
|
600
|
+
sourceMessageId: message.id,
|
|
601
|
+
sourceThreadId: thread.id,
|
|
649
602
|
appId: currentAppId,
|
|
650
603
|
preprocess: () => {
|
|
651
604
|
return preprocessNewThreadMessage({
|
|
@@ -360,7 +360,7 @@ export async function registerCommands({ token, appId, guildIds, userCommands =
|
|
|
360
360
|
.toJSON(),
|
|
361
361
|
new SlashCommandBuilder()
|
|
362
362
|
.setName('screenshare')
|
|
363
|
-
.setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after
|
|
363
|
+
.setDescription(truncateCommandDescription('Start screen sharing via VNC tunnel (auto-stops after 30 minutes)'))
|
|
364
364
|
.setDMPermission(false)
|
|
365
365
|
.toJSON(),
|
|
366
366
|
new SlashCommandBuilder()
|
|
@@ -19,7 +19,7 @@ function isSyntheticTextPart(part) {
|
|
|
19
19
|
return candidate.synthetic === true;
|
|
20
20
|
}
|
|
21
21
|
function parseDiscordOriginMetadata(text) {
|
|
22
|
-
const match = text.match(
|
|
22
|
+
const match = text.match(/<discord-user\s+([^>]+)\s*\/>/);
|
|
23
23
|
if (!match?.[1]) {
|
|
24
24
|
return null;
|
|
25
25
|
}
|
|
@@ -31,29 +31,28 @@ function parseDiscordOriginMetadata(text) {
|
|
|
31
31
|
acc[key] = value || '';
|
|
32
32
|
return acc;
|
|
33
33
|
}, {});
|
|
34
|
-
const messageId = attrs['message-id'];
|
|
35
34
|
const username = attrs['name'];
|
|
36
|
-
if (!
|
|
35
|
+
if (!username) {
|
|
37
36
|
return null;
|
|
38
37
|
}
|
|
39
38
|
return {
|
|
40
|
-
messageId,
|
|
39
|
+
messageId: attrs['message-id'] || undefined,
|
|
41
40
|
username,
|
|
42
41
|
threadId: attrs['thread-id'] || undefined,
|
|
43
42
|
};
|
|
44
43
|
}
|
|
45
44
|
function getDiscordOriginMetadataFromMessage({ message, }) {
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
return [];
|
|
49
|
-
}
|
|
50
|
-
if (!isSyntheticTextPart(part)) {
|
|
51
|
-
return [];
|
|
52
|
-
}
|
|
53
|
-
return [part.text || ''];
|
|
45
|
+
const textParts = message.parts.filter((p) => {
|
|
46
|
+
return p.type === 'text';
|
|
54
47
|
});
|
|
55
|
-
|
|
56
|
-
|
|
48
|
+
// Synthetic parts first (normal promptAsync path), then non-synthetic
|
|
49
|
+
// (session.command() path where the tag is embedded in arguments text).
|
|
50
|
+
const sorted = [
|
|
51
|
+
...textParts.filter((p) => { return isSyntheticTextPart(p); }),
|
|
52
|
+
...textParts.filter((p) => { return !isSyntheticTextPart(p); }),
|
|
53
|
+
];
|
|
54
|
+
for (const part of sorted) {
|
|
55
|
+
const metadata = parseDiscordOriginMetadata(part.text || '');
|
|
57
56
|
if (metadata) {
|
|
58
57
|
return metadata;
|
|
59
58
|
}
|
|
@@ -81,14 +80,13 @@ function getRenderableUserTextParts({ message, }) {
|
|
|
81
80
|
function getExternalUserMirrorText({ username, prompt, }) {
|
|
82
81
|
return `» **${username}:** ${prompt.slice(0, 1000)}${prompt.length > 1000 ? '...' : ''}`;
|
|
83
82
|
}
|
|
84
|
-
// Pure derivation:
|
|
85
|
-
//
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
// with renderable text content.
|
|
83
|
+
// Pure derivation: is the latest user turn from Discord?
|
|
84
|
+
// Checks the newest user message with renderable text for a <discord-user />
|
|
85
|
+
// synthetic part. If present, the session is currently driven from Discord
|
|
86
|
+
// (kimaki manages it) and external sync should skip it. If absent (CLI/TUI),
|
|
87
|
+
// external sync should mirror it — this naturally handles the "reclaim" case
|
|
88
|
+
// (external → discord → external) without any DB source toggling.
|
|
89
|
+
function isLatestUserTurnFromDiscord({ messages, }) {
|
|
92
90
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
93
91
|
const message = messages[i];
|
|
94
92
|
if (message.info.role !== 'user') {
|
|
@@ -99,18 +97,10 @@ function hasExternalResume({ messages, threadId, syncedPartIds, }) {
|
|
|
99
97
|
continue;
|
|
100
98
|
}
|
|
101
99
|
// Found the latest user message with actual text content.
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
if (origin && (!origin.threadId || origin.threadId === threadId)) {
|
|
105
|
-
// Latest user turn came from Discord — no external resume.
|
|
106
|
-
return false;
|
|
107
|
-
}
|
|
108
|
-
// Latest user turn is external (CLI/TUI). Check if we already
|
|
109
|
-
// mirrored all its parts. If any part is unseen, reclaim.
|
|
110
|
-
return renderableParts.some((p) => {
|
|
111
|
-
return !syncedPartIds.has(p.id);
|
|
112
|
-
});
|
|
100
|
+
// If it has <discord-user /> origin metadata, it came from Discord.
|
|
101
|
+
return getDiscordOriginMetadataFromMessage({ message }) !== null;
|
|
113
102
|
}
|
|
103
|
+
// No user messages with text — treat as external (allow sync).
|
|
114
104
|
return false;
|
|
115
105
|
}
|
|
116
106
|
function shouldMirrorAssistantPart({ part, verbosity, }) {
|
|
@@ -177,16 +167,14 @@ function groupTrackedChannelsByDirectory(trackedChannels) {
|
|
|
177
167
|
}, new Map());
|
|
178
168
|
return [...grouped.values()];
|
|
179
169
|
}
|
|
180
|
-
async function ensureExternalSessionThread({ discordClient, channelId, sessionId, sessionTitle, messages,
|
|
170
|
+
async function ensureExternalSessionThread({ discordClient, channelId, sessionId, sessionTitle, messages, }) {
|
|
181
171
|
const existingThreadId = await getThreadIdBySessionId(sessionId);
|
|
182
172
|
if (existingThreadId) {
|
|
173
|
+
// Caller already verified via isLatestUserTurnFromDiscord that this
|
|
174
|
+
// session should be synced. If the thread was kimaki-owned, flip it
|
|
175
|
+
// to external_poll so typing and future polls work naturally.
|
|
183
176
|
const existingSource = await getThreadSessionSource(existingThreadId);
|
|
184
|
-
if (existingSource
|
|
185
|
-
return null;
|
|
186
|
-
}
|
|
187
|
-
// Reclaim: flip kimaki-owned thread back to external_poll so typing
|
|
188
|
-
// and future polls work naturally without any new stored state.
|
|
189
|
-
if (existingSource === 'kimaki' && reclaimable) {
|
|
177
|
+
if (existingSource === 'kimaki') {
|
|
190
178
|
await upsertThreadSession({
|
|
191
179
|
threadId: existingThreadId,
|
|
192
180
|
sessionId,
|
|
@@ -250,14 +238,16 @@ function collectUnsyncedChunks({ messages, syncedPartIds, verbosity, thread, })
|
|
|
250
238
|
if (unsyncedParts.length === 0) {
|
|
251
239
|
continue;
|
|
252
240
|
}
|
|
253
|
-
// If the user message came from this Discord thread,
|
|
254
|
-
//
|
|
241
|
+
// If the user message came from this Discord thread, skip mirroring
|
|
242
|
+
// — it's already visible. When message-id is available, record a
|
|
243
|
+
// direct mapping for part dedup. When it's missing (sourceMessageId
|
|
244
|
+
// is optional in IngressInput), just mark parts as synced.
|
|
255
245
|
const discordOrigin = getDiscordOriginMetadataFromMessage({ message });
|
|
256
246
|
if (discordOrigin && (!discordOrigin.threadId || discordOrigin.threadId === thread.id)) {
|
|
257
247
|
unsyncedParts.forEach((part) => {
|
|
258
248
|
directMappings.push({
|
|
259
249
|
partId: part.id,
|
|
260
|
-
messageId: discordOrigin.messageId,
|
|
250
|
+
messageId: discordOrigin.messageId || '',
|
|
261
251
|
threadId: thread.id,
|
|
262
252
|
});
|
|
263
253
|
syncedPartIds.add(part.id);
|
|
@@ -312,24 +302,12 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
|
|
|
312
302
|
throw messagesResponse;
|
|
313
303
|
}
|
|
314
304
|
const messages = messagesResponse.data || [];
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
const existingSource = await getThreadSessionSource(existingThreadId);
|
|
322
|
-
if (existingSource === 'kimaki') {
|
|
323
|
-
const existingPartIds = await getPartMessageIds(existingThreadId);
|
|
324
|
-
reclaimable = hasExternalResume({
|
|
325
|
-
messages,
|
|
326
|
-
threadId: existingThreadId,
|
|
327
|
-
syncedPartIds: new Set(existingPartIds),
|
|
328
|
-
});
|
|
329
|
-
if (!reclaimable) {
|
|
330
|
-
return;
|
|
331
|
-
}
|
|
332
|
-
}
|
|
305
|
+
// Pure derivation from opencode events: if the latest user turn has
|
|
306
|
+
// <discord-user /> metadata, kimaki's thread runtime owns this session.
|
|
307
|
+
// Skip external sync entirely. When the user resumes from CLI/TUI the
|
|
308
|
+
// latest user turn will lack the tag, so sync picks it up naturally.
|
|
309
|
+
if (isLatestUserTurnFromDiscord({ messages })) {
|
|
310
|
+
return;
|
|
333
311
|
}
|
|
334
312
|
const thread = await ensureExternalSessionThread({
|
|
335
313
|
discordClient,
|
|
@@ -337,7 +315,6 @@ async function syncSessionToThread({ client, discordClient, directory, channelId
|
|
|
337
315
|
sessionId,
|
|
338
316
|
sessionTitle,
|
|
339
317
|
messages,
|
|
340
|
-
reclaimable,
|
|
341
318
|
});
|
|
342
319
|
if (thread === null) {
|
|
343
320
|
return;
|
|
@@ -534,5 +511,5 @@ export const externalOpencodeSyncInternals = {
|
|
|
534
511
|
sortSessionsByRecency,
|
|
535
512
|
parseDiscordOriginMetadata,
|
|
536
513
|
getDiscordOriginMetadataFromMessage,
|
|
537
|
-
|
|
514
|
+
isLatestUserTurnFromDiscord,
|
|
538
515
|
};
|
|
@@ -98,13 +98,6 @@ function createMatchers() {
|
|
|
98
98
|
};
|
|
99
99
|
return [defaultReply];
|
|
100
100
|
}
|
|
101
|
-
function waitForChildExit(child) {
|
|
102
|
-
return new Promise((resolve) => {
|
|
103
|
-
child.once('exit', () => {
|
|
104
|
-
resolve();
|
|
105
|
-
});
|
|
106
|
-
});
|
|
107
|
-
}
|
|
108
101
|
async function waitForProxyReady({ port, timeoutMs = 30_000, }) {
|
|
109
102
|
const start = Date.now();
|
|
110
103
|
while (Date.now() - start < timeoutMs) {
|
|
@@ -334,7 +327,7 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
334
327
|
`);
|
|
335
328
|
expect(reply).toBeDefined();
|
|
336
329
|
expect(reply.content.trim().length).toBeGreaterThan(0);
|
|
337
|
-
},
|
|
330
|
+
}, 15_000);
|
|
338
331
|
test('follow-up message in thread gets bot reply', async () => {
|
|
339
332
|
const existingMessages = await discord.thread(firstThreadId).getMessages();
|
|
340
333
|
const existingIds = new Set(existingMessages.map((m) => m.id));
|
|
@@ -347,7 +340,7 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
347
340
|
await waitForFooterMessage({
|
|
348
341
|
discord,
|
|
349
342
|
threadId: firstThreadId,
|
|
350
|
-
timeout:
|
|
343
|
+
timeout: 4_000,
|
|
351
344
|
afterMessageIncludes: 'follow up through proxy',
|
|
352
345
|
afterAuthorId: TEST_USER_ID,
|
|
353
346
|
});
|
|
@@ -365,51 +358,10 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
365
358
|
`);
|
|
366
359
|
expect(reply).toBeDefined();
|
|
367
360
|
expect(reply.content.trim().length).toBeGreaterThan(0);
|
|
368
|
-
},
|
|
369
|
-
test
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
await exitPromise;
|
|
373
|
-
const restartedProxy = startGatewayProxy({
|
|
374
|
-
configDir: path.join(directories.dataDir, 'proxy'),
|
|
375
|
-
port: proxyPort,
|
|
376
|
-
twinPort: discord.port,
|
|
377
|
-
botToken: discord.botToken,
|
|
378
|
-
gatewayUrl: discord.gatewayUrl,
|
|
379
|
-
});
|
|
380
|
-
proxyProcess = restartedProxy.process;
|
|
381
|
-
await waitForProxyReady({ port: proxyPort, timeoutMs: 30_000 });
|
|
382
|
-
await new Promise((resolve) => {
|
|
383
|
-
setTimeout(resolve, 6_000);
|
|
384
|
-
});
|
|
385
|
-
await discord.channel(CHANNEL_1_ID).user(TEST_USER_ID).sendMessage({
|
|
386
|
-
content: 'recovered after proxy restart',
|
|
387
|
-
});
|
|
388
|
-
const recoveryThread = await discord.channel(CHANNEL_1_ID).waitForThread({
|
|
389
|
-
timeout: 30_000,
|
|
390
|
-
predicate: (t) => {
|
|
391
|
-
return t.name?.includes('recovered after proxy restart') ?? false;
|
|
392
|
-
},
|
|
393
|
-
});
|
|
394
|
-
const reply = await discord.thread(recoveryThread.id).waitForBotReply({
|
|
395
|
-
timeout: 30_000,
|
|
396
|
-
});
|
|
397
|
-
await waitForFooterMessage({
|
|
398
|
-
discord,
|
|
399
|
-
threadId: recoveryThread.id,
|
|
400
|
-
timeout: 30_000,
|
|
401
|
-
afterMessageIncludes: 'recovered after proxy restart',
|
|
402
|
-
afterAuthorId: TEST_USER_ID,
|
|
403
|
-
});
|
|
404
|
-
expect(await discord.thread(recoveryThread.id).text()).toMatchInlineSnapshot(`
|
|
405
|
-
"--- from: user (proxy-tester)
|
|
406
|
-
recovered after proxy restart
|
|
407
|
-
--- from: assistant (TestBot)
|
|
408
|
-
⬥ gateway-proxy-reply
|
|
409
|
-
*project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
|
|
410
|
-
`);
|
|
411
|
-
expect(reply.content.trim().length).toBeGreaterThan(0);
|
|
412
|
-
}, 60_000);
|
|
361
|
+
}, 15_000);
|
|
362
|
+
// Reconnect test lives in gateway-proxy-reconnect.e2e.test.ts.
|
|
363
|
+
// It was here before but kills the proxy mid-suite, breaking shared
|
|
364
|
+
// state (bot/proxy connection) for all subsequent tests.
|
|
413
365
|
test('shell command via ! prefix in thread', async () => {
|
|
414
366
|
const existingMessages = await discord.thread(firstThreadId).getMessages();
|
|
415
367
|
const existingIds = new Set(existingMessages.map((m) => m.id));
|
|
@@ -463,7 +415,7 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
463
415
|
`);
|
|
464
416
|
expect(reply).toBeDefined();
|
|
465
417
|
expect(reply.content.trim().length).toBeGreaterThan(0);
|
|
466
|
-
},
|
|
418
|
+
}, 15_000);
|
|
467
419
|
test('guild-2 message does not create thread (guild isolation)', async () => {
|
|
468
420
|
await discord.channel(CHANNEL_2_ID).user(TEST_USER_ID).sendMessage({
|
|
469
421
|
content: 'should not create thread in guild 2',
|
|
@@ -526,5 +478,5 @@ describeIf('gateway-proxy e2e', () => {
|
|
|
526
478
|
finally {
|
|
527
479
|
store.setState({ discordBaseUrl: previousBaseUrl });
|
|
528
480
|
}
|
|
529
|
-
},
|
|
481
|
+
}, 15_000);
|
|
530
482
|
});
|
|
@@ -142,7 +142,7 @@ ${backticks}bash
|
|
|
142
142
|
PORT=$((RANDOM % 6000 + 3000))
|
|
143
143
|
tmux kill-session -t game-dev 2>/dev/null
|
|
144
144
|
tmux new-session -d -s game-dev -c "$PWD"
|
|
145
|
-
tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel -p $PORT -- bun run server.ts" Enter
|
|
145
|
+
tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel --kill -p $PORT -- bun run server.ts" Enter
|
|
146
146
|
${backticks}
|
|
147
147
|
|
|
148
148
|
Wait a moment, then get the tunnel URL:
|
|
@@ -299,6 +299,41 @@ export function createDeterministicMatchers() {
|
|
|
299
299
|
],
|
|
300
300
|
},
|
|
301
301
|
};
|
|
302
|
+
// Question tool for select+queue drain test: model asks a question via dropdown,
|
|
303
|
+
// user answers via select menu while a message is queued.
|
|
304
|
+
const questionSelectQueueMatcher = {
|
|
305
|
+
id: 'question-select-queue-marker',
|
|
306
|
+
priority: 107,
|
|
307
|
+
when: {
|
|
308
|
+
lastMessageRole: 'user',
|
|
309
|
+
latestUserTextIncludes: 'QUESTION_SELECT_QUEUE_MARKER',
|
|
310
|
+
},
|
|
311
|
+
then: {
|
|
312
|
+
parts: [
|
|
313
|
+
{ type: 'stream-start', warnings: [] },
|
|
314
|
+
{
|
|
315
|
+
type: 'tool-call',
|
|
316
|
+
toolCallId: 'question-select-queue-call',
|
|
317
|
+
toolName: 'question',
|
|
318
|
+
input: JSON.stringify({
|
|
319
|
+
questions: [{
|
|
320
|
+
question: 'How to proceed?',
|
|
321
|
+
header: 'Select action',
|
|
322
|
+
options: [
|
|
323
|
+
{ label: 'Alpha', description: 'Alpha option' },
|
|
324
|
+
{ label: 'Beta', description: 'Beta option' },
|
|
325
|
+
],
|
|
326
|
+
}],
|
|
327
|
+
}),
|
|
328
|
+
},
|
|
329
|
+
{
|
|
330
|
+
type: 'finish',
|
|
331
|
+
finishReason: 'tool-calls',
|
|
332
|
+
usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
},
|
|
336
|
+
};
|
|
302
337
|
// Model responds with text + tool call, then after tool result the
|
|
303
338
|
// follow-up matcher responds with text. This creates two assistant messages:
|
|
304
339
|
// first with finish="tool-calls" + completed, second with finish="stop".
|
|
@@ -613,6 +648,7 @@ export function createDeterministicMatchers() {
|
|
|
613
648
|
pluginTimeoutSleepMatcher,
|
|
614
649
|
actionButtonClickFollowupMatcher,
|
|
615
650
|
questionToolMatcher,
|
|
651
|
+
questionSelectQueueMatcher,
|
|
616
652
|
permissionTypingMatcher,
|
|
617
653
|
permissionTypingFollowupMatcher,
|
|
618
654
|
multiToolMatcher,
|