kimaki 0.4.77 → 0.4.78
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 +27 -0
- package/dist/commands/diff.js +20 -85
- package/dist/commands/screenshare.js +295 -0
- package/dist/critique-utils.js +95 -0
- package/dist/diff-patch-plugin.js +314 -0
- package/dist/discord-bot.js +1 -1
- package/dist/interaction-handler.js +10 -0
- package/dist/message-formatting.js +3 -62
- package/dist/onboarding-tutorial-plugin.js +1 -1
- package/dist/opencode-plugin.js +4 -4
- package/dist/patch-text-parser.js +97 -0
- package/dist/session-handler/thread-session-runtime.js +1 -1
- package/dist/websockify.js +69 -0
- package/package.json +7 -5
- package/skills/event-sourcing-state/SKILL.md +188 -34
- package/skills/playwriter/SKILL.md +1 -1
- package/src/cli.ts +35 -0
- package/src/commands/diff.ts +25 -99
- package/src/commands/screenshare.ts +354 -0
- package/src/critique-utils.ts +139 -0
- package/src/discord-bot.ts +1 -1
- package/src/interaction-handler.ts +15 -0
- package/src/message-formatting.ts +3 -68
- package/src/onboarding-tutorial-plugin.ts +1 -1
- package/src/opencode-plugin.ts +5 -4
- package/src/patch-text-parser.ts +107 -0
- package/src/session-handler/thread-session-runtime.ts +2 -1
- package/src/websockify.ts +101 -0
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// /screenshare command - Start screen sharing via VNC + WebSocket bridge + kimaki tunnel.
|
|
2
|
+
// On macOS: uses built-in Screen Sharing (port 5900).
|
|
3
|
+
// On Linux: spawns x11vnc against the current $DISPLAY.
|
|
4
|
+
// Exposes the VNC stream via an in-process websockify bridge and a traforo tunnel,
|
|
5
|
+
// then sends the user a noVNC URL they can open in a browser.
|
|
6
|
+
//
|
|
7
|
+
// /screenshare-stop command - Stops the active screen share for this guild.
|
|
8
|
+
|
|
9
|
+
import { MessageFlags } from 'discord.js'
|
|
10
|
+
import crypto from 'node:crypto'
|
|
11
|
+
import { spawn, type ChildProcess } from 'node:child_process'
|
|
12
|
+
import net from 'node:net'
|
|
13
|
+
import { TunnelClient } from 'traforo/client'
|
|
14
|
+
import type { CommandContext } from './types.js'
|
|
15
|
+
import { SILENT_MESSAGE_FLAGS } from '../discord-utils.js'
|
|
16
|
+
import { startWebsockify } from '../websockify.js'
|
|
17
|
+
import { createLogger } from '../logger.js'
|
|
18
|
+
import { execAsync } from '../worktrees.js'
|
|
19
|
+
import type { WebSocketServer } from 'ws'
|
|
20
|
+
|
|
21
|
+
const logger = createLogger('SCREEN')
|
|
22
|
+
|
|
23
|
+
export type ScreenshareSession = {
|
|
24
|
+
tunnelClient: TunnelClient
|
|
25
|
+
wss: WebSocketServer
|
|
26
|
+
/** x11vnc child process, only on Linux */
|
|
27
|
+
vncProcess: ChildProcess | undefined
|
|
28
|
+
url: string
|
|
29
|
+
noVncUrl: string
|
|
30
|
+
startedBy: string
|
|
31
|
+
startedAt: number
|
|
32
|
+
/** Auto-kill timer */
|
|
33
|
+
timeoutTimer: ReturnType<typeof setTimeout>
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** One active screenshare per guild (Discord) or per machine (CLI) */
|
|
37
|
+
const activeSessions = new Map<string, ScreenshareSession>()
|
|
38
|
+
|
|
39
|
+
const VNC_PORT = 5900
|
|
40
|
+
const MAX_SESSION_MS = 60 * 60 * 1000 // 1 hour
|
|
41
|
+
const TUNNEL_BASE_DOMAIN = 'kimaki.xyz'
|
|
42
|
+
|
|
43
|
+
// Public noVNC client — we point it at our tunnel URL
|
|
44
|
+
export function buildNoVncUrl({ tunnelHost }: { tunnelHost: string }): string {
|
|
45
|
+
const params = new URLSearchParams({
|
|
46
|
+
autoconnect: 'true',
|
|
47
|
+
host: tunnelHost,
|
|
48
|
+
port: '443',
|
|
49
|
+
encrypt: '1',
|
|
50
|
+
resize: 'scale',
|
|
51
|
+
view_only: 'false',
|
|
52
|
+
})
|
|
53
|
+
return `https://novnc.com/noVNC/vnc.html?${params.toString()}`
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// macOS has two separate services:
|
|
57
|
+
// - "Screen Sharing" = view-only VNC (com.apple.screensharing)
|
|
58
|
+
// - "Remote Management" = full control VNC with mouse/keyboard (ARDAgent)
|
|
59
|
+
// We need Remote Management for interactive control, not just Screen Sharing.
|
|
60
|
+
export async function ensureMacRemoteManagement(): Promise<void> {
|
|
61
|
+
// Check if port 5900 is listening via netstat (no sudo needed).
|
|
62
|
+
// lsof and launchctl list both require sudo for system daemons.
|
|
63
|
+
try {
|
|
64
|
+
const { stdout } = await execAsync(
|
|
65
|
+
'netstat -an | grep "\\.5900 " | grep LISTEN',
|
|
66
|
+
{ timeout: 5000 },
|
|
67
|
+
)
|
|
68
|
+
if (stdout.trim()) {
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
} catch {
|
|
72
|
+
// not listening
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
throw new Error(
|
|
76
|
+
'macOS Remote Management is not enabled.\n' +
|
|
77
|
+
'Enable it: **System Settings > General > Sharing > Remote Management**\n' +
|
|
78
|
+
'Make sure "VNC viewers may control screen with password" is enabled.\n' +
|
|
79
|
+
'Or via terminal:\n' +
|
|
80
|
+
'```\nsudo /System/Library/CoreServices/RemoteManagement/ARDAgent.app/Contents/Resources/kickstart \\\n' +
|
|
81
|
+
' -activate -configure -allowAccessFor -allUsers -privs -all \\\n' +
|
|
82
|
+
' -clientopts -setvnclegacy -vnclegacy yes \\\n' +
|
|
83
|
+
' -restart -agent -console\n```',
|
|
84
|
+
)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function spawnX11Vnc(): ChildProcess {
|
|
88
|
+
const display = process.env['DISPLAY'] || ':0'
|
|
89
|
+
const child = spawn('x11vnc', [
|
|
90
|
+
'-display', display,
|
|
91
|
+
'-nopw',
|
|
92
|
+
'-localhost',
|
|
93
|
+
'-rfbport', String(VNC_PORT),
|
|
94
|
+
'-shared',
|
|
95
|
+
'-forever',
|
|
96
|
+
], {
|
|
97
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
child.stdout?.on('data', (data: Buffer) => {
|
|
101
|
+
logger.log(`x11vnc: ${data.toString().trim()}`)
|
|
102
|
+
})
|
|
103
|
+
child.stderr?.on('data', (data: Buffer) => {
|
|
104
|
+
logger.error(`x11vnc: ${data.toString().trim()}`)
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
return child
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function waitForPort({
|
|
111
|
+
port,
|
|
112
|
+
process: proc,
|
|
113
|
+
timeoutMs,
|
|
114
|
+
}: {
|
|
115
|
+
port: number
|
|
116
|
+
process: ChildProcess
|
|
117
|
+
timeoutMs: number
|
|
118
|
+
}): Promise<void> {
|
|
119
|
+
return new Promise((resolve, reject) => {
|
|
120
|
+
const maxAttempts = Math.ceil(timeoutMs / 100)
|
|
121
|
+
let attempts = 0
|
|
122
|
+
const check = () => {
|
|
123
|
+
if (proc.exitCode !== null) {
|
|
124
|
+
reject(new Error(`x11vnc exited with code ${proc.exitCode} before becoming ready`))
|
|
125
|
+
return
|
|
126
|
+
}
|
|
127
|
+
const sock = net.createConnection(port, 'localhost')
|
|
128
|
+
sock.on('connect', () => {
|
|
129
|
+
sock.destroy()
|
|
130
|
+
resolve()
|
|
131
|
+
})
|
|
132
|
+
sock.on('error', () => {
|
|
133
|
+
sock.destroy()
|
|
134
|
+
if (++attempts >= maxAttempts) {
|
|
135
|
+
reject(new Error(`Port ${port} not reachable after ${timeoutMs}ms`))
|
|
136
|
+
} else {
|
|
137
|
+
setTimeout(check, 100)
|
|
138
|
+
}
|
|
139
|
+
})
|
|
140
|
+
}
|
|
141
|
+
check()
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function cleanupSession(session: ScreenshareSession): void {
|
|
146
|
+
clearTimeout(session.timeoutTimer)
|
|
147
|
+
try {
|
|
148
|
+
session.tunnelClient.close()
|
|
149
|
+
} catch {}
|
|
150
|
+
try {
|
|
151
|
+
session.wss.close()
|
|
152
|
+
} catch {}
|
|
153
|
+
if (session.vncProcess) {
|
|
154
|
+
try {
|
|
155
|
+
session.vncProcess.kill()
|
|
156
|
+
} catch {}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Core screenshare start logic, reused by both Discord command and CLI.
|
|
162
|
+
* Returns the session or throws on failure.
|
|
163
|
+
*/
|
|
164
|
+
export async function startScreenshare({
|
|
165
|
+
sessionKey,
|
|
166
|
+
startedBy,
|
|
167
|
+
}: {
|
|
168
|
+
sessionKey: string
|
|
169
|
+
startedBy: string
|
|
170
|
+
}): Promise<ScreenshareSession> {
|
|
171
|
+
const existing = activeSessions.get(sessionKey)
|
|
172
|
+
if (existing) {
|
|
173
|
+
throw new Error(`Screen sharing is already active: ${existing.noVncUrl}`)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const platform = process.platform
|
|
177
|
+
let vncProcess: ChildProcess | undefined
|
|
178
|
+
|
|
179
|
+
// Step 1: ensure VNC server is running
|
|
180
|
+
if (platform === 'darwin') {
|
|
181
|
+
await ensureMacRemoteManagement()
|
|
182
|
+
} else if (platform === 'linux') {
|
|
183
|
+
if (!process.env['DISPLAY']) {
|
|
184
|
+
throw new Error('No $DISPLAY found. Screen sharing requires a running X11 display.')
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
await execAsync('which x11vnc', { timeout: 3000 })
|
|
188
|
+
} catch {
|
|
189
|
+
throw new Error('x11vnc is not installed. Install it with: sudo apt install x11vnc')
|
|
190
|
+
}
|
|
191
|
+
vncProcess = spawnX11Vnc()
|
|
192
|
+
// Wait for x11vnc to actually be ready (port 5900 accepting connections)
|
|
193
|
+
// instead of a blind 1s sleep. Polls every 100ms, fails if process exits first.
|
|
194
|
+
await waitForPort({ port: VNC_PORT, process: vncProcess, timeoutMs: 3000 })
|
|
195
|
+
} else {
|
|
196
|
+
throw new Error(`Screen sharing is not supported on ${platform}. Only macOS and Linux are supported.`)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Step 2: start in-process websockify bridge
|
|
200
|
+
let wsInstance: Awaited<ReturnType<typeof startWebsockify>>
|
|
201
|
+
try {
|
|
202
|
+
wsInstance = await startWebsockify({
|
|
203
|
+
wsPort: 0,
|
|
204
|
+
tcpHost: 'localhost',
|
|
205
|
+
tcpPort: VNC_PORT,
|
|
206
|
+
})
|
|
207
|
+
} catch (err) {
|
|
208
|
+
if (vncProcess) {
|
|
209
|
+
vncProcess.kill()
|
|
210
|
+
}
|
|
211
|
+
throw err
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Step 3: create tunnel
|
|
215
|
+
const tunnelId = crypto.randomBytes(8).toString('hex')
|
|
216
|
+
const tunnelClient = new TunnelClient({
|
|
217
|
+
localPort: wsInstance.port,
|
|
218
|
+
tunnelId,
|
|
219
|
+
baseDomain: TUNNEL_BASE_DOMAIN,
|
|
220
|
+
})
|
|
221
|
+
|
|
222
|
+
try {
|
|
223
|
+
await Promise.race([
|
|
224
|
+
tunnelClient.connect(),
|
|
225
|
+
new Promise<never>((_, reject) => {
|
|
226
|
+
setTimeout(() => {
|
|
227
|
+
reject(new Error('Tunnel connection timed out after 15s'))
|
|
228
|
+
}, 15000)
|
|
229
|
+
}),
|
|
230
|
+
])
|
|
231
|
+
} catch (err) {
|
|
232
|
+
tunnelClient.close()
|
|
233
|
+
wsInstance.close()
|
|
234
|
+
if (vncProcess) {
|
|
235
|
+
vncProcess.kill()
|
|
236
|
+
}
|
|
237
|
+
throw err
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const tunnelHost = `${tunnelId}-tunnel.${TUNNEL_BASE_DOMAIN}`
|
|
241
|
+
const tunnelUrl = `https://${tunnelHost}`
|
|
242
|
+
const noVncUrl = buildNoVncUrl({ tunnelHost })
|
|
243
|
+
|
|
244
|
+
// Auto-kill after 1 hour
|
|
245
|
+
const timeoutTimer = setTimeout(() => {
|
|
246
|
+
logger.log(`Screen share auto-stopped after 1 hour (key: ${sessionKey})`)
|
|
247
|
+
stopScreenshare({ sessionKey })
|
|
248
|
+
}, MAX_SESSION_MS)
|
|
249
|
+
// Don't keep the process alive just for this timer
|
|
250
|
+
timeoutTimer.unref()
|
|
251
|
+
|
|
252
|
+
const session: ScreenshareSession = {
|
|
253
|
+
tunnelClient,
|
|
254
|
+
wss: wsInstance.wss,
|
|
255
|
+
vncProcess,
|
|
256
|
+
url: tunnelUrl,
|
|
257
|
+
noVncUrl,
|
|
258
|
+
startedBy,
|
|
259
|
+
startedAt: Date.now(),
|
|
260
|
+
timeoutTimer,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
activeSessions.set(sessionKey, session)
|
|
264
|
+
logger.log(`Screen share started by ${startedBy}: ${tunnelUrl}`)
|
|
265
|
+
|
|
266
|
+
return session
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Core screenshare stop logic, reused by both Discord command and CLI.
|
|
271
|
+
*/
|
|
272
|
+
export function stopScreenshare({ sessionKey }: { sessionKey: string }): boolean {
|
|
273
|
+
const session = activeSessions.get(sessionKey)
|
|
274
|
+
if (!session) {
|
|
275
|
+
return false
|
|
276
|
+
}
|
|
277
|
+
cleanupSession(session)
|
|
278
|
+
activeSessions.delete(sessionKey)
|
|
279
|
+
logger.log(`Screen share stopped (key: ${sessionKey})`)
|
|
280
|
+
return true
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function handleScreenshareCommand({
|
|
284
|
+
command,
|
|
285
|
+
}: CommandContext): Promise<void> {
|
|
286
|
+
const guildId = command.guildId
|
|
287
|
+
if (!guildId) {
|
|
288
|
+
await command.reply({
|
|
289
|
+
content: 'This command can only be used in a server',
|
|
290
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
291
|
+
})
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
|
|
296
|
+
|
|
297
|
+
try {
|
|
298
|
+
const session = await startScreenshare({
|
|
299
|
+
sessionKey: guildId,
|
|
300
|
+
startedBy: command.user.tag,
|
|
301
|
+
})
|
|
302
|
+
await command.editReply({
|
|
303
|
+
content: `Screen sharing started\n${session.noVncUrl}`,
|
|
304
|
+
})
|
|
305
|
+
} catch (err) {
|
|
306
|
+
logger.error('Failed to start screen share:', err)
|
|
307
|
+
await command.editReply({
|
|
308
|
+
content: `Failed to start screen share: ${err instanceof Error ? err.message : String(err)}`,
|
|
309
|
+
})
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export async function handleScreenshareStopCommand({
|
|
314
|
+
command,
|
|
315
|
+
}: CommandContext): Promise<void> {
|
|
316
|
+
const guildId = command.guildId
|
|
317
|
+
if (!guildId) {
|
|
318
|
+
await command.reply({
|
|
319
|
+
content: 'This command can only be used in a server',
|
|
320
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
321
|
+
})
|
|
322
|
+
return
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const stopped = stopScreenshare({ sessionKey: guildId })
|
|
326
|
+
if (!stopped) {
|
|
327
|
+
await command.reply({
|
|
328
|
+
content: 'No active screen share to stop',
|
|
329
|
+
flags: MessageFlags.Ephemeral | SILENT_MESSAGE_FLAGS,
|
|
330
|
+
})
|
|
331
|
+
return
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
await command.reply({
|
|
335
|
+
content: 'Screen sharing stopped',
|
|
336
|
+
flags: SILENT_MESSAGE_FLAGS,
|
|
337
|
+
})
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/** Cleanup all sessions on bot shutdown */
|
|
341
|
+
export function cleanupAllScreenshares(): void {
|
|
342
|
+
for (const [guildId, session] of activeSessions) {
|
|
343
|
+
cleanupSession(session)
|
|
344
|
+
activeSessions.delete(guildId)
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// Kill all screenshares when the process exits (Ctrl+C, SIGTERM, etc.)
|
|
349
|
+
function onProcessExit(): void {
|
|
350
|
+
cleanupAllScreenshares()
|
|
351
|
+
}
|
|
352
|
+
process.on('SIGINT', onProcessExit)
|
|
353
|
+
process.on('SIGTERM', onProcessExit)
|
|
354
|
+
process.on('exit', onProcessExit)
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Shared utilities for invoking the critique CLI and parsing its JSON output.
|
|
2
|
+
// Used by /diff command and footer diff link uploads.
|
|
3
|
+
|
|
4
|
+
import { execAsync } from './worktrees.js'
|
|
5
|
+
import { createLogger, LogPrefix } from './logger.js'
|
|
6
|
+
|
|
7
|
+
const logger = createLogger(LogPrefix.DIFF)
|
|
8
|
+
|
|
9
|
+
const CRITIQUE_TIMEOUT_MS = 30_000
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Shell-quote a string by wrapping in single quotes and escaping embedded
|
|
13
|
+
* single quotes. Prevents injection when interpolating into shell commands.
|
|
14
|
+
*/
|
|
15
|
+
function shellQuote(s: string): string {
|
|
16
|
+
return `'${s.replace(/'/g, "'\\''")}'`
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export type CritiqueResult = {
|
|
20
|
+
url: string
|
|
21
|
+
id: string
|
|
22
|
+
error?: undefined
|
|
23
|
+
} | {
|
|
24
|
+
url?: undefined
|
|
25
|
+
id?: undefined
|
|
26
|
+
error: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Parse critique --json output. Critique prints progress to stderr and JSON
|
|
31
|
+
* to stdout. The JSON line contains { url, id } on success or { error } on
|
|
32
|
+
* failure. We scan all lines for the first valid JSON object with a url or
|
|
33
|
+
* error field, falling back to searching for a critique.work URL in the raw
|
|
34
|
+
* output.
|
|
35
|
+
*/
|
|
36
|
+
export function parseCritiqueOutput(output: string): CritiqueResult | undefined {
|
|
37
|
+
const lines = output.trim().split('\n')
|
|
38
|
+
for (const line of lines) {
|
|
39
|
+
if (!line.startsWith('{')) {
|
|
40
|
+
continue
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const parsed = JSON.parse(line) as {
|
|
44
|
+
url?: string
|
|
45
|
+
id?: string
|
|
46
|
+
error?: string
|
|
47
|
+
}
|
|
48
|
+
if (parsed.error) {
|
|
49
|
+
return { error: parsed.error }
|
|
50
|
+
}
|
|
51
|
+
if (parsed.url && parsed.id) {
|
|
52
|
+
return { url: parsed.url, id: parsed.id }
|
|
53
|
+
}
|
|
54
|
+
} catch {
|
|
55
|
+
// not valid JSON, try next line
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
// Fallback: try to find a URL in the raw output
|
|
59
|
+
const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/)
|
|
60
|
+
if (urlMatch) {
|
|
61
|
+
const url = urlMatch[0]
|
|
62
|
+
// Extract ID from URL path: /v/{id}
|
|
63
|
+
const idMatch = url.match(/\/v\/([a-f0-9]+)/)
|
|
64
|
+
const id = idMatch?.[1]
|
|
65
|
+
if (id) {
|
|
66
|
+
return { url, id }
|
|
67
|
+
}
|
|
68
|
+
// URL without parseable id — return as error so callers don't build
|
|
69
|
+
// broken OG image URLs from an empty id
|
|
70
|
+
return { error: url }
|
|
71
|
+
}
|
|
72
|
+
return undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Run critique on the current git working tree diff and return the result.
|
|
77
|
+
* Used by the /diff slash command.
|
|
78
|
+
*/
|
|
79
|
+
export async function uploadGitDiffViaCritique({
|
|
80
|
+
title,
|
|
81
|
+
cwd,
|
|
82
|
+
}: {
|
|
83
|
+
title: string
|
|
84
|
+
cwd: string
|
|
85
|
+
}): Promise<CritiqueResult | undefined> {
|
|
86
|
+
try {
|
|
87
|
+
const { stdout, stderr } = await execAsync(
|
|
88
|
+
`critique --web ${shellQuote(title)} --json`,
|
|
89
|
+
{ cwd, timeout: CRITIQUE_TIMEOUT_MS },
|
|
90
|
+
)
|
|
91
|
+
return parseCritiqueOutput(stdout || stderr)
|
|
92
|
+
} catch (error) {
|
|
93
|
+
// exec error includes stdout/stderr — try to parse JSON from it
|
|
94
|
+
const execError = error as {
|
|
95
|
+
stdout?: string
|
|
96
|
+
stderr?: string
|
|
97
|
+
message?: string
|
|
98
|
+
}
|
|
99
|
+
const output = execError.stdout || execError.stderr || ''
|
|
100
|
+
const parsed = parseCritiqueOutput(output)
|
|
101
|
+
if (parsed) {
|
|
102
|
+
return parsed
|
|
103
|
+
}
|
|
104
|
+
const message = execError.message || 'Unknown error'
|
|
105
|
+
if (message.includes('command not found') || message.includes('ENOENT')) {
|
|
106
|
+
return { error: 'critique not available' }
|
|
107
|
+
}
|
|
108
|
+
return { error: `Failed to generate diff: ${message.slice(0, 200)}` }
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Upload a .patch file to critique.work via critique --stdin.
|
|
114
|
+
* Returns the critique URL on success, undefined on failure.
|
|
115
|
+
* Default timeout is 10s since this runs in the background (footer edit).
|
|
116
|
+
*/
|
|
117
|
+
export async function uploadPatchViaCritique({
|
|
118
|
+
patchPath,
|
|
119
|
+
title,
|
|
120
|
+
cwd,
|
|
121
|
+
timeoutMs = 10_000,
|
|
122
|
+
}: {
|
|
123
|
+
patchPath: string
|
|
124
|
+
title: string
|
|
125
|
+
cwd: string
|
|
126
|
+
timeoutMs?: number
|
|
127
|
+
}): Promise<string | undefined> {
|
|
128
|
+
try {
|
|
129
|
+
const { stdout } = await execAsync(
|
|
130
|
+
`critique --stdin --web ${shellQuote(title)} --json < ${shellQuote(patchPath)}`,
|
|
131
|
+
{ cwd, timeout: timeoutMs },
|
|
132
|
+
)
|
|
133
|
+
const result = parseCritiqueOutput(stdout)
|
|
134
|
+
return result?.url
|
|
135
|
+
} catch (error) {
|
|
136
|
+
logger.error('critique upload failed:', error)
|
|
137
|
+
return undefined
|
|
138
|
+
}
|
|
139
|
+
}
|
package/src/discord-bot.ts
CHANGED
|
@@ -813,7 +813,7 @@ export async function startDiscordBot({
|
|
|
813
813
|
},
|
|
814
814
|
})
|
|
815
815
|
} else {
|
|
816
|
-
discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
816
|
+
// discordLogger.log(`Channel type ${channel.type} is not supported`)
|
|
817
817
|
}
|
|
818
818
|
} catch (error) {
|
|
819
819
|
voiceLogger.error('Discord handler error:', error)
|
|
@@ -90,6 +90,10 @@ import { handleContextUsageCommand } from './commands/context-usage.js'
|
|
|
90
90
|
import { handleSessionIdCommand } from './commands/session-id.js'
|
|
91
91
|
import { handleUpgradeAndRestartCommand } from './commands/upgrade.js'
|
|
92
92
|
import { handleMcpCommand, handleMcpSelectMenu } from './commands/mcp.js'
|
|
93
|
+
import {
|
|
94
|
+
handleScreenshareCommand,
|
|
95
|
+
handleScreenshareStopCommand,
|
|
96
|
+
} from './commands/screenshare.js'
|
|
93
97
|
import { handleModelVariantSelectMenu } from './commands/model.js'
|
|
94
98
|
import {
|
|
95
99
|
handleModelVariantCommand,
|
|
@@ -328,6 +332,17 @@ export function registerInteractionHandler({
|
|
|
328
332
|
case 'mcp':
|
|
329
333
|
await handleMcpCommand({ command: interaction, appId })
|
|
330
334
|
return
|
|
335
|
+
|
|
336
|
+
case 'screenshare':
|
|
337
|
+
await handleScreenshareCommand({ command: interaction, appId })
|
|
338
|
+
return
|
|
339
|
+
|
|
340
|
+
case 'screenshare-stop':
|
|
341
|
+
await handleScreenshareStopCommand({
|
|
342
|
+
command: interaction,
|
|
343
|
+
appId,
|
|
344
|
+
})
|
|
345
|
+
return
|
|
331
346
|
}
|
|
332
347
|
|
|
333
348
|
// Handle quick agent commands (ending with -agent suffix, but not the base /agent command)
|
|
@@ -13,6 +13,7 @@ import * as errore from 'errore'
|
|
|
13
13
|
import { createLogger, LogPrefix } from './logger.js'
|
|
14
14
|
import { FetchError } from './errors.js'
|
|
15
15
|
import { processImage } from './image-utils.js'
|
|
16
|
+
import { parsePatchFileCounts } from './patch-text-parser.js'
|
|
16
17
|
|
|
17
18
|
// Generic message type compatible with both v1 and v2 SDK
|
|
18
19
|
type GenericSessionMessage = {
|
|
@@ -61,73 +62,7 @@ function escapeInlineMarkdown(text: string): string {
|
|
|
61
62
|
return text.replace(/([*_~|`\\])/g, '\\$1')
|
|
62
63
|
}
|
|
63
64
|
|
|
64
|
-
|
|
65
|
-
* Parses a patchText string (apply_patch format) and counts additions/deletions per file.
|
|
66
|
-
* Patch format uses `*** Add File:`, `*** Update File:`, `*** Delete File:` headers,
|
|
67
|
-
* with diff lines prefixed by `+` (addition) or `-` (deletion) inside `@@` hunks.
|
|
68
|
-
*/
|
|
69
|
-
function parsePatchCounts(
|
|
70
|
-
patchText: string,
|
|
71
|
-
): Map<string, { additions: number; deletions: number }> {
|
|
72
|
-
const counts = new Map<string, { additions: number; deletions: number }>()
|
|
73
|
-
const lines = patchText.split('\n')
|
|
74
|
-
let currentFile = ''
|
|
75
|
-
let currentType = ''
|
|
76
|
-
let inHunk = false
|
|
77
|
-
|
|
78
|
-
for (const line of lines) {
|
|
79
|
-
const addMatch = line.match(/^\*\*\* Add File:\s*(.+)/)
|
|
80
|
-
const updateMatch = line.match(/^\*\*\* Update File:\s*(.+)/)
|
|
81
|
-
const deleteMatch = line.match(/^\*\*\* Delete File:\s*(.+)/)
|
|
82
|
-
|
|
83
|
-
if (addMatch || updateMatch || deleteMatch) {
|
|
84
|
-
const match = addMatch || updateMatch || deleteMatch
|
|
85
|
-
currentFile = (match?.[1] ?? '').trim()
|
|
86
|
-
currentType = addMatch ? 'add' : updateMatch ? 'update' : 'delete'
|
|
87
|
-
counts.set(currentFile, { additions: 0, deletions: 0 })
|
|
88
|
-
inHunk = false
|
|
89
|
-
continue
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
if (line.startsWith('@@')) {
|
|
93
|
-
inHunk = true
|
|
94
|
-
continue
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
if (line.startsWith('*** ')) {
|
|
98
|
-
inHunk = false
|
|
99
|
-
continue
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
if (!currentFile) {
|
|
103
|
-
continue
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const entry = counts.get(currentFile)
|
|
107
|
-
if (!entry) {
|
|
108
|
-
continue
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (currentType === 'add') {
|
|
112
|
-
// all content lines in Add File are additions
|
|
113
|
-
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
114
|
-
entry.additions++
|
|
115
|
-
}
|
|
116
|
-
} else if (currentType === 'delete') {
|
|
117
|
-
// all content lines in Delete File are deletions
|
|
118
|
-
if (line.length > 0 && !line.startsWith('*** ')) {
|
|
119
|
-
entry.deletions++
|
|
120
|
-
}
|
|
121
|
-
} else if (inHunk) {
|
|
122
|
-
if (line.startsWith('+')) {
|
|
123
|
-
entry.additions++
|
|
124
|
-
} else if (line.startsWith('-')) {
|
|
125
|
-
entry.deletions++
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
return counts
|
|
130
|
-
}
|
|
65
|
+
// parsePatchCounts → imported from patch-text-parser.ts as parsePatchFileCounts
|
|
131
66
|
|
|
132
67
|
/**
|
|
133
68
|
* Normalize whitespace: convert newlines to spaces and collapse consecutive spaces.
|
|
@@ -299,7 +234,7 @@ export function getToolSummaryText(part: Part): string {
|
|
|
299
234
|
if (!patchText) {
|
|
300
235
|
return ''
|
|
301
236
|
}
|
|
302
|
-
const patchCounts =
|
|
237
|
+
const patchCounts = parsePatchFileCounts(patchText)
|
|
303
238
|
return [...patchCounts.entries()]
|
|
304
239
|
.map(([filePath, { additions, deletions }]) => {
|
|
305
240
|
const fileName = filePath.split('/').pop() || ''
|
package/src/opencode-plugin.ts
CHANGED
|
@@ -344,7 +344,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
|
|
|
344
344
|
if (memoryContent) {
|
|
345
345
|
const condensed = condenseMemoryMd(memoryContent)
|
|
346
346
|
output.parts.push({
|
|
347
|
-
id: crypto.randomUUID()
|
|
347
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
348
348
|
sessionID,
|
|
349
349
|
messageID,
|
|
350
350
|
type: 'text' as const,
|
|
@@ -386,7 +386,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
|
|
|
386
386
|
})
|
|
387
387
|
|
|
388
388
|
output.parts.push({
|
|
389
|
-
id: crypto.randomUUID()
|
|
389
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
390
390
|
sessionID,
|
|
391
391
|
messageID,
|
|
392
392
|
type: 'text' as const,
|
|
@@ -398,7 +398,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
|
|
|
398
398
|
// When the user comes back after a long break, remind the model
|
|
399
399
|
// to save any important context from the previous conversation.
|
|
400
400
|
output.parts.push({
|
|
401
|
-
id: crypto.randomUUID()
|
|
401
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
402
402
|
sessionID,
|
|
403
403
|
messageID,
|
|
404
404
|
type: 'text' as const,
|
|
@@ -425,7 +425,7 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
|
|
|
425
425
|
|
|
426
426
|
sessionGitStates.set(sessionID, gitState)
|
|
427
427
|
output.parts.push({
|
|
428
|
-
id: crypto.randomUUID()
|
|
428
|
+
id: `prt_${crypto.randomUUID()}`,
|
|
429
429
|
sessionID,
|
|
430
430
|
messageID,
|
|
431
431
|
type: 'text' as const,
|
|
@@ -481,3 +481,4 @@ const kimakiPlugin: Plugin = async ({ directory }) => {
|
|
|
481
481
|
export { kimakiPlugin }
|
|
482
482
|
export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js'
|
|
483
483
|
export { onboardingTutorialPlugin } from './onboarding-tutorial-plugin.js'
|
|
484
|
+
|