kimaki 0.22.0 → 0.23.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/anthropic-auth-plugin.js +78 -33
- package/dist/anthropic-auth-state.js +62 -27
- package/dist/anthropic-auth-state.test.js +55 -2
- package/dist/channel-management.js +29 -6
- package/dist/cli-commands/project.js +6 -1
- package/dist/commands/login.js +1 -1
- package/dist/commands/new-worktree.js +9 -3
- package/dist/commands/queue.js +12 -2
- package/dist/database.js +7 -8
- package/dist/db.js +1 -0
- package/dist/discord-bot.js +9 -0
- package/dist/hrana-server.js +19 -0
- package/dist/oauth-rotation-shared.js +21 -0
- package/dist/opencode.js +73 -4
- package/dist/schema.js +3 -0
- package/dist/session-handler/global-event-listener.js +38 -6
- package/dist/session-handler/thread-runtime-state.js +2 -0
- package/dist/session-handler/thread-session-runtime.js +19 -3
- package/dist/system-message.js +34 -20
- package/dist/system-message.test.js +34 -20
- package/dist/task-runner.js +8 -4
- package/dist/worktree-lifecycle.e2e.test.js +16 -2
- package/package.json +4 -4
- package/skills/holocron/SKILL.md +11 -8
- package/src/anthropic-auth-plugin.ts +82 -35
- package/src/anthropic-auth-state.test.ts +73 -1
- package/src/anthropic-auth-state.ts +85 -25
- package/src/channel-management.ts +35 -6
- package/src/cli-commands/project.ts +6 -1
- package/src/commands/login.ts +1 -1
- package/src/commands/new-worktree.ts +14 -3
- package/src/commands/queue.ts +13 -2
- package/src/database.ts +8 -9
- package/src/db.ts +1 -0
- package/src/discord-bot.ts +12 -0
- package/src/hrana-server.ts +19 -0
- package/src/oauth-rotation-shared.ts +22 -0
- package/src/opencode.ts +90 -6
- package/src/schema.sql +1 -0
- package/src/schema.ts +3 -0
- package/src/session-handler/global-event-listener.ts +38 -6
- package/src/session-handler/thread-runtime-state.ts +4 -1
- package/src/session-handler/thread-session-runtime.ts +25 -3
- package/src/system-message.test.ts +34 -20
- package/src/system-message.ts +34 -20
- package/src/task-runner.ts +8 -4
- package/src/worktree-lifecycle.e2e.test.ts +18 -2
- package/skills/batch/SKILL.md +0 -87
- package/skills/security-review/SKILL.md +0 -208
- package/skills/simplify/SKILL.md +0 -58
package/src/database.ts
CHANGED
|
@@ -193,10 +193,9 @@ export async function recoverStaleRunningScheduledTasks({ staleBefore }: { stale
|
|
|
193
193
|
return countRows(rows)
|
|
194
194
|
}
|
|
195
195
|
|
|
196
|
-
export async function
|
|
196
|
+
export async function deleteScheduledTask(taskId: number) {
|
|
197
197
|
const db = await getDb()
|
|
198
|
-
await db.
|
|
199
|
-
.set({ status: 'completed', last_run_at: completedAt, running_started_at: null, last_error: null })
|
|
198
|
+
await db.delete(schema.scheduled_tasks)
|
|
200
199
|
.where(orm.eq(schema.scheduled_tasks.id, taskId))
|
|
201
200
|
}
|
|
202
201
|
|
|
@@ -762,20 +761,20 @@ export async function getAnyAudioApiKey(): Promise<{ provider: 'openai' | 'gemin
|
|
|
762
761
|
|
|
763
762
|
|
|
764
763
|
|
|
765
|
-
export async function setChannelDirectory({ channelId, directory, channelType, skipIfExists = false }: { channelId: string; directory: string; channelType: DatabaseChannelType; skipIfExists?: boolean }) {
|
|
764
|
+
export async function setChannelDirectory({ channelId, directory, channelType, guildId, skipIfExists = false }: { channelId: string; directory: string; channelType: DatabaseChannelType; guildId?: string; skipIfExists?: boolean }) {
|
|
766
765
|
const db = await getDb()
|
|
767
766
|
if (skipIfExists) {
|
|
768
767
|
await db.insert(schema.channel_directories)
|
|
769
|
-
.values({ channel_id: channelId, directory, channel_type: channelType })
|
|
768
|
+
.values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
|
|
770
769
|
.onConflictDoNothing({ target: schema.channel_directories.channel_id })
|
|
771
770
|
return
|
|
772
771
|
}
|
|
773
772
|
await db.insert(schema.channel_directories)
|
|
774
|
-
.values({ channel_id: channelId, directory, channel_type: channelType })
|
|
775
|
-
.onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType } })
|
|
773
|
+
.values({ channel_id: channelId, directory, channel_type: channelType, guild_id: guildId })
|
|
774
|
+
.onConflictDoUpdate({ target: schema.channel_directories.channel_id, set: { directory, channel_type: channelType, guild_id: guildId } })
|
|
776
775
|
}
|
|
777
776
|
|
|
778
|
-
export async function findChannelsByDirectory({ directory, channelType }: { directory?: string; channelType?: DatabaseChannelType }): Promise<Array<{ channel_id: string; directory: string; channel_type: string }>> {
|
|
777
|
+
export async function findChannelsByDirectory({ directory, channelType }: { directory?: string; channelType?: DatabaseChannelType }): Promise<Array<{ channel_id: string; directory: string; channel_type: string; guild_id: string | null }>> {
|
|
779
778
|
const db = await getDb()
|
|
780
779
|
const where = directory && channelType
|
|
781
780
|
? { directory, channel_type: channelType }
|
|
@@ -784,7 +783,7 @@ export async function findChannelsByDirectory({ directory, channelType }: { dire
|
|
|
784
783
|
: channelType
|
|
785
784
|
? { channel_type: channelType }
|
|
786
785
|
: undefined
|
|
787
|
-
return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true } })
|
|
786
|
+
return db.query.channel_directories.findMany({ where, columns: { channel_id: true, directory: true, channel_type: true, guild_id: true } })
|
|
788
787
|
}
|
|
789
788
|
|
|
790
789
|
export async function getAllTextChannelDirectories() {
|
package/src/db.ts
CHANGED
|
@@ -159,6 +159,7 @@ async function migrateSchema({
|
|
|
159
159
|
"ALTER TABLE thread_sessions ADD COLUMN source TEXT DEFAULT 'kimaki'",
|
|
160
160
|
'ALTER TABLE thread_sessions ADD COLUMN last_synced_name TEXT',
|
|
161
161
|
'ALTER TABLE thread_sessions ADD COLUMN parent_session_id TEXT',
|
|
162
|
+
'ALTER TABLE channel_directories ADD COLUMN guild_id TEXT',
|
|
162
163
|
]
|
|
163
164
|
for (const stmt of alterStatements) {
|
|
164
165
|
await client.execute(stmt).catch(() => undefined)
|
package/src/discord-bot.ts
CHANGED
|
@@ -97,6 +97,7 @@ import { markDiscordGatewayReady, stopHranaServer } from './hrana-server.js'
|
|
|
97
97
|
import { notifyError } from './sentry.js'
|
|
98
98
|
import { flushDebouncedProcessCallbacks } from './debounced-process-flush.js'
|
|
99
99
|
import { startRuntimeIdleSweeper } from './runtime-idle-sweeper.js'
|
|
100
|
+
import { getDefaultKimakiDirectory } from './channel-management.js'
|
|
100
101
|
import { store } from './store.js'
|
|
101
102
|
import {
|
|
102
103
|
startExternalOpencodeSessionSync,
|
|
@@ -1455,6 +1456,17 @@ export async function startDiscordBot({
|
|
|
1455
1456
|
// channel are disposed by their own ThreadDelete events from Discord.
|
|
1456
1457
|
discordClient.on(Events.ChannelDelete, async (channel) => {
|
|
1457
1458
|
try {
|
|
1459
|
+
// Check if this is the default kimaki channel. If so, preserve the
|
|
1460
|
+
// channel_directories row as a tombstone so we don't recreate it.
|
|
1461
|
+
const mapping = await getChannelDirectory(channel.id)
|
|
1462
|
+
const defaultDir = getDefaultKimakiDirectory()
|
|
1463
|
+
if (mapping && mapping.directory === defaultDir) {
|
|
1464
|
+
discordLogger.log(
|
|
1465
|
+
`Preserving channel_directories row for deleted default channel ${channel.id} as tombstone`,
|
|
1466
|
+
)
|
|
1467
|
+
return
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1458
1470
|
const deleted = await deleteChannelDirectoryById(channel.id)
|
|
1459
1471
|
if (deleted) {
|
|
1460
1472
|
discordLogger.log(
|
package/src/hrana-server.ts
CHANGED
|
@@ -24,6 +24,11 @@ import { createLogger, LogPrefix } from './logger.js'
|
|
|
24
24
|
import { ServerStartError, FetchError } from './errors.js'
|
|
25
25
|
import { getLockPort } from './config.js'
|
|
26
26
|
import { store } from './store.js'
|
|
27
|
+
// Circular import: opencode.ts → hrana-server.ts → opencode.ts.
|
|
28
|
+
// Safe because both sides only use lazy runtime function calls, never
|
|
29
|
+
// top-level initialization values. The cycle could be broken by moving
|
|
30
|
+
// the port into store.ts, but the current approach is simpler.
|
|
31
|
+
import { getOpencodeServerPort } from './opencode.js'
|
|
27
32
|
|
|
28
33
|
const hranaLogger = createLogger(LogPrefix.DB)
|
|
29
34
|
|
|
@@ -175,6 +180,20 @@ export async function startHranaServer({
|
|
|
175
180
|
res.end(JSON.stringify({ status: 'ok', pid: process.pid }))
|
|
176
181
|
return
|
|
177
182
|
}
|
|
183
|
+
// OpenCode server port discovery — no auth required (localhost only).
|
|
184
|
+
// CLI subcommands query this to reuse the bot's running OpenCode server
|
|
185
|
+
// instead of spawning a redundant second server process.
|
|
186
|
+
if (pathname === '/kimaki/opencode-port') {
|
|
187
|
+
const port = getOpencodeServerPort()
|
|
188
|
+
if (port === null) {
|
|
189
|
+
res.writeHead(404, { 'content-type': 'application/json' })
|
|
190
|
+
res.end(JSON.stringify({ error: 'no_opencode_server' }))
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
res.writeHead(200, { 'content-type': 'application/json' })
|
|
194
|
+
res.end(JSON.stringify({ port }))
|
|
195
|
+
return
|
|
196
|
+
}
|
|
178
197
|
// Hrana routes: /v2, /v2/pipeline — require auth
|
|
179
198
|
if (pathname === '/v2' || pathname === '/v2/pipeline') {
|
|
180
199
|
if (!isAuthorizedRequest(req)) {
|
|
@@ -279,6 +279,28 @@ export function isTokenRefreshError(message: string) {
|
|
|
279
279
|
)
|
|
280
280
|
}
|
|
281
281
|
|
|
282
|
+
/** Permanent OAuth refresh death (invalid_grant / expired refresh). Remove account, do not rotate. */
|
|
283
|
+
export function isPermanentOAuthRefreshFailure(error: unknown): boolean {
|
|
284
|
+
const message =
|
|
285
|
+
typeof error === 'string'
|
|
286
|
+
? error
|
|
287
|
+
: error instanceof Error
|
|
288
|
+
? error.message
|
|
289
|
+
: ''
|
|
290
|
+
const haystack = message.toLowerCase()
|
|
291
|
+
if (!haystack) return false
|
|
292
|
+
if (haystack.includes('invalid_grant')) return true
|
|
293
|
+
if (haystack.includes('refresh token expired')) return true
|
|
294
|
+
if (haystack.includes('refresh_token') || haystack.includes('refresh token')) {
|
|
295
|
+
return (
|
|
296
|
+
haystack.includes('expired') ||
|
|
297
|
+
haystack.includes('invalid') ||
|
|
298
|
+
haystack.includes('revoked')
|
|
299
|
+
)
|
|
300
|
+
}
|
|
301
|
+
return false
|
|
302
|
+
}
|
|
303
|
+
|
|
282
304
|
// --- Auth file helpers ---
|
|
283
305
|
|
|
284
306
|
export function isOAuthStored(value: unknown): value is OAuthStored {
|
package/src/opencode.ts
CHANGED
|
@@ -30,7 +30,10 @@ import {
|
|
|
30
30
|
type PermissionRuleset,
|
|
31
31
|
} from '@opencode-ai/sdk/v2'
|
|
32
32
|
|
|
33
|
-
import {
|
|
33
|
+
import {
|
|
34
|
+
restartGlobalEventListener,
|
|
35
|
+
waitForGlobalEventListener,
|
|
36
|
+
} from './session-handler/global-event-listener.js'
|
|
34
37
|
import {
|
|
35
38
|
getDataDir,
|
|
36
39
|
getLockPort,
|
|
@@ -281,9 +284,12 @@ function buildStartupTimeoutReason({
|
|
|
281
284
|
// Clients are created per-directory with the x-opencode-directory header.
|
|
282
285
|
|
|
283
286
|
type SingleServer = {
|
|
284
|
-
process: ChildProcess
|
|
287
|
+
process: ChildProcess | null
|
|
285
288
|
port: number
|
|
286
289
|
baseUrl: string
|
|
290
|
+
/** True when this server was discovered from the bot's hrana endpoint,
|
|
291
|
+
* not spawned by this process. We must not kill it on cleanup. */
|
|
292
|
+
discovered?: boolean
|
|
287
293
|
}
|
|
288
294
|
|
|
289
295
|
type ServerLifecycleEvent =
|
|
@@ -321,6 +327,11 @@ function killSingleServerProcessNow({
|
|
|
321
327
|
return
|
|
322
328
|
}
|
|
323
329
|
|
|
330
|
+
// Never kill a server we didn't spawn (discovered from another process)
|
|
331
|
+
if (singleServer.discovered || !singleServer.process) {
|
|
332
|
+
return
|
|
333
|
+
}
|
|
334
|
+
|
|
324
335
|
const serverProcess = singleServer.process
|
|
325
336
|
const pid = serverProcess.pid
|
|
326
337
|
if (!pid || serverProcess.killed) {
|
|
@@ -559,22 +570,82 @@ function ensureOpencodeHomeDirectories({
|
|
|
559
570
|
})
|
|
560
571
|
}
|
|
561
572
|
|
|
573
|
+
/**
|
|
574
|
+
* Try to discover an OpenCode server already running in the bot process.
|
|
575
|
+
* Queries the hrana server on the lock port for the OpenCode server port,
|
|
576
|
+
* then verifies the server is healthy. Returns null if no server found.
|
|
577
|
+
*/
|
|
578
|
+
async function discoverExistingServer(): Promise<SingleServer | null> {
|
|
579
|
+
const lockPort = getLockPort()
|
|
580
|
+
try {
|
|
581
|
+
const portResponse = await requestHealthcheck({
|
|
582
|
+
url: `http://127.0.0.1:${lockPort}/kimaki/opencode-port`,
|
|
583
|
+
timeoutMs: 2000,
|
|
584
|
+
})
|
|
585
|
+
if (portResponse.status !== 200) {
|
|
586
|
+
return null
|
|
587
|
+
}
|
|
588
|
+
const parsed = JSON.parse(portResponse.body)
|
|
589
|
+
const port = parsed?.port
|
|
590
|
+
if (typeof port !== 'number') {
|
|
591
|
+
return null
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
// Verify the OpenCode server is actually healthy
|
|
595
|
+
const healthResponse = await requestHealthcheck({
|
|
596
|
+
url: `http://127.0.0.1:${port}/api/health`,
|
|
597
|
+
timeoutMs: 2000,
|
|
598
|
+
})
|
|
599
|
+
if (healthResponse.status >= 500) {
|
|
600
|
+
return null
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
opencodeLogger.log(
|
|
604
|
+
`Discovered existing OpenCode server on port ${port} via hrana lock port ${lockPort}`,
|
|
605
|
+
)
|
|
606
|
+
return {
|
|
607
|
+
process: null,
|
|
608
|
+
port,
|
|
609
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
610
|
+
discovered: true,
|
|
611
|
+
}
|
|
612
|
+
} catch {
|
|
613
|
+
// Connection refused or other network error — no bot running
|
|
614
|
+
return null
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
|
|
562
618
|
async function ensureSingleServer({
|
|
563
619
|
directory,
|
|
564
620
|
}: {
|
|
565
621
|
directory?: string
|
|
566
622
|
} = {}): Promise<ServerStartError | SingleServer> {
|
|
567
623
|
const startupDirectory = directory || preferredStartupDirectory || undefined
|
|
568
|
-
if (singleServer && !singleServer.process
|
|
624
|
+
if (singleServer && !singleServer.process?.killed) {
|
|
569
625
|
return singleServer
|
|
570
626
|
}
|
|
571
627
|
|
|
572
|
-
// Deduplicate concurrent startup attempts
|
|
628
|
+
// Deduplicate concurrent startup attempts (covers both discovery and spawn)
|
|
573
629
|
if (startingServer) {
|
|
574
630
|
return startingServer
|
|
575
631
|
}
|
|
576
632
|
|
|
577
|
-
|
|
633
|
+
// Wrap discovery + spawn in a single shared promise so concurrent callers
|
|
634
|
+
// don't each run discoverExistingServer() and then each spawn a server.
|
|
635
|
+
startingServer = (async () => {
|
|
636
|
+
// Try to discover an already-running server from the bot process via
|
|
637
|
+
// the hrana server's /kimaki/opencode-port endpoint. This lets CLI
|
|
638
|
+
// subcommands (kimaki session list, archive, wait, etc.) reuse the
|
|
639
|
+
// bot's OpenCode server instead of spawning a redundant one.
|
|
640
|
+
const discovered = await discoverExistingServer()
|
|
641
|
+
if (discovered) {
|
|
642
|
+
singleServer = discovered
|
|
643
|
+
return discovered
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
return startSingleServer({ directory: startupDirectory })
|
|
647
|
+
})()
|
|
648
|
+
|
|
578
649
|
try {
|
|
579
650
|
return await startingServer
|
|
580
651
|
} finally {
|
|
@@ -788,6 +859,7 @@ async function startSingleServer({
|
|
|
788
859
|
OPENCODE_PORT: port.toString(),
|
|
789
860
|
KIMAKI: '1',
|
|
790
861
|
OPENCODE_EXPERIMENTAL_WORKSPACES: 'true',
|
|
862
|
+
OPENCODE_ENABLE_EXA: '1',
|
|
791
863
|
KIMAKI_DATA_DIR: getDataDir(),
|
|
792
864
|
KIMAKI_LOCK_PORT: getLockPort().toString(),
|
|
793
865
|
KIMAKI_PARENT_LOCK_PORT: getLockPort().toString(),
|
|
@@ -1316,13 +1388,23 @@ export async function stopOpencodeServer(): Promise<boolean> {
|
|
|
1316
1388
|
}
|
|
1317
1389
|
|
|
1318
1390
|
const server = singleServer
|
|
1391
|
+
|
|
1392
|
+
// For discovered servers (from another process), just clear local state
|
|
1393
|
+
// without killing the process we don't own.
|
|
1394
|
+
if (server.discovered || !server.process) {
|
|
1395
|
+
singleServer = null
|
|
1396
|
+
clientCache.clear()
|
|
1397
|
+
serverRetryCount = 0
|
|
1398
|
+
return true
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1319
1401
|
opencodeLogger.log(
|
|
1320
1402
|
`Stopping opencode server (pid: ${server.process.pid}, port: ${server.port})`,
|
|
1321
1403
|
)
|
|
1322
1404
|
if (!server.process.killed) {
|
|
1323
1405
|
const killResult = errore.try(
|
|
1324
1406
|
() => {
|
|
1325
|
-
server.process
|
|
1407
|
+
server.process!.kill('SIGTERM')
|
|
1326
1408
|
},
|
|
1327
1409
|
(error) => {
|
|
1328
1410
|
return new Error('Failed to send SIGTERM to opencode server', {
|
|
@@ -1366,5 +1448,7 @@ export async function restartOpencodeServer(): Promise<OpenCodeErrors | true> {
|
|
|
1366
1448
|
|
|
1367
1449
|
const result = await ensureSingleServer()
|
|
1368
1450
|
if (result instanceof Error) return result
|
|
1451
|
+
restartGlobalEventListener()
|
|
1452
|
+
await waitForGlobalEventListener()
|
|
1369
1453
|
return true
|
|
1370
1454
|
}
|
package/src/schema.sql
CHANGED
package/src/schema.ts
CHANGED
|
@@ -69,6 +69,9 @@ export const channel_directories = sqliteCore.sqliteTable('channel_directories',
|
|
|
69
69
|
channel_id: sqliteCore.text('channel_id').primaryKey().notNull(),
|
|
70
70
|
directory: sqliteCore.text('directory').notNull(),
|
|
71
71
|
channel_type: sqliteCore.text('channel_type', { enum: ['text', 'voice'] }).notNull(),
|
|
72
|
+
// Guild that owns this channel. Used to scope the default-channel tombstone
|
|
73
|
+
// check so multi-guild setups don't cross-block each other.
|
|
74
|
+
guild_id: sqliteCore.text('guild_id'),
|
|
72
75
|
created_at: datetime('created_at').default(orm.sql`CURRENT_TIMESTAMP`),
|
|
73
76
|
})
|
|
74
77
|
|
|
@@ -22,8 +22,18 @@ function isAbortError(err: unknown): boolean {
|
|
|
22
22
|
return false
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
function delay(ms: number): Promise<void> {
|
|
26
|
-
return new Promise((resolve) =>
|
|
25
|
+
function delay(ms: number, signal: AbortSignal): Promise<void> {
|
|
26
|
+
return new Promise((resolve) => {
|
|
27
|
+
if (signal.aborted) {
|
|
28
|
+
resolve()
|
|
29
|
+
return
|
|
30
|
+
}
|
|
31
|
+
const timeout = setTimeout(resolve, ms)
|
|
32
|
+
signal.addEventListener('abort', () => {
|
|
33
|
+
clearTimeout(timeout)
|
|
34
|
+
resolve()
|
|
35
|
+
}, { once: true })
|
|
36
|
+
})
|
|
27
37
|
}
|
|
28
38
|
|
|
29
39
|
// ── Types ──────────────────────────────────────────────────────
|
|
@@ -36,6 +46,8 @@ const callbacks = new Map<string, EventCallback>()
|
|
|
36
46
|
let loopRunning = false
|
|
37
47
|
let disposed = false
|
|
38
48
|
let controller: AbortController | null = null
|
|
49
|
+
let connected = false
|
|
50
|
+
const connectionWaiters = new Set<() => void>()
|
|
39
51
|
|
|
40
52
|
// ── Public API ─────────────────────────────────────────────────
|
|
41
53
|
|
|
@@ -70,6 +82,7 @@ export function unregisterEventListener(threadId: string): void {
|
|
|
70
82
|
export function disposeGlobalEventListener(): void {
|
|
71
83
|
disposed = true
|
|
72
84
|
loopRunning = false
|
|
85
|
+
connected = false
|
|
73
86
|
controller?.abort()
|
|
74
87
|
controller = null
|
|
75
88
|
callbacks.clear()
|
|
@@ -81,9 +94,19 @@ export function disposeGlobalEventListener(): void {
|
|
|
81
94
|
*/
|
|
82
95
|
export function restartGlobalEventListener(): void {
|
|
83
96
|
if (disposed) return
|
|
97
|
+
connected = false
|
|
84
98
|
controller?.abort()
|
|
85
99
|
}
|
|
86
100
|
|
|
101
|
+
/** Wait until the event stream is connected before starting event-producing work. */
|
|
102
|
+
export function waitForGlobalEventListener(): Promise<void> {
|
|
103
|
+
if (callbacks.size === 0 || connected) return Promise.resolve()
|
|
104
|
+
ensureListenerRunning()
|
|
105
|
+
return new Promise((resolve) => {
|
|
106
|
+
connectionWaiters.add(resolve)
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
|
|
87
110
|
// ── Internals ──────────────────────────────────────────────────
|
|
88
111
|
|
|
89
112
|
// Lazy subscription to opencode server lifecycle. Deferred to avoid
|
|
@@ -161,7 +184,7 @@ async function runEventLoop(): Promise<void> {
|
|
|
161
184
|
logger.warn(
|
|
162
185
|
`[GLOBAL LISTENER] No OpenCode server available, retrying in ${backoffMs}ms`,
|
|
163
186
|
)
|
|
164
|
-
await delay(backoffMs)
|
|
187
|
+
await delay(backoffMs, signal)
|
|
165
188
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
166
189
|
continue
|
|
167
190
|
}
|
|
@@ -181,13 +204,16 @@ async function runEventLoop(): Promise<void> {
|
|
|
181
204
|
`[GLOBAL LISTENER] Subscribe failed, retrying in ${backoffMs}ms:`,
|
|
182
205
|
subscribeResult.message,
|
|
183
206
|
)
|
|
184
|
-
await delay(backoffMs)
|
|
207
|
+
await delay(backoffMs, signal)
|
|
185
208
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
186
209
|
continue
|
|
187
210
|
}
|
|
188
211
|
|
|
189
212
|
const events = subscribeResult.stream
|
|
190
213
|
|
|
214
|
+
connected = true
|
|
215
|
+
for (const resolve of connectionWaiters) resolve()
|
|
216
|
+
connectionWaiters.clear()
|
|
191
217
|
logger.log('[GLOBAL LISTENER] Connected to global event stream')
|
|
192
218
|
|
|
193
219
|
let receivedAnyEvent = false
|
|
@@ -199,6 +225,8 @@ async function runEventLoop(): Promise<void> {
|
|
|
199
225
|
})()
|
|
200
226
|
.catch((e) => new OpenCodeSdkError({ operation: 'event.iterate', cause: e }))
|
|
201
227
|
|
|
228
|
+
connected = false
|
|
229
|
+
|
|
202
230
|
if (receivedAnyEvent) {
|
|
203
231
|
backoffMs = 500
|
|
204
232
|
}
|
|
@@ -213,13 +241,17 @@ async function runEventLoop(): Promise<void> {
|
|
|
213
241
|
`[GLOBAL LISTENER] Stream broke, reconnecting in ${backoffMs}ms:`,
|
|
214
242
|
iterResult.message,
|
|
215
243
|
)
|
|
216
|
-
await delay(backoffMs)
|
|
244
|
+
await delay(backoffMs, signal)
|
|
217
245
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
218
246
|
} else {
|
|
247
|
+
if (signal.aborted) {
|
|
248
|
+
backoffMs = 500
|
|
249
|
+
continue
|
|
250
|
+
}
|
|
219
251
|
logger.log(
|
|
220
252
|
`[GLOBAL LISTENER] Stream ended normally, reconnecting in ${backoffMs}ms`,
|
|
221
253
|
)
|
|
222
|
-
await delay(backoffMs)
|
|
254
|
+
await delay(backoffMs, signal)
|
|
223
255
|
backoffMs = Math.min(backoffMs * 2, maxBackoffMs)
|
|
224
256
|
}
|
|
225
257
|
}
|
|
@@ -224,8 +224,11 @@ export function dequeueItem(threadId: string): QueuedMessage | undefined {
|
|
|
224
224
|
return next
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export function clearQueueItems(threadId: string):
|
|
227
|
+
export function clearQueueItems(threadId: string): QueuedMessage[] {
|
|
228
|
+
const items =
|
|
229
|
+
store.getState().threads.get(threadId)?.queueItems.slice() ?? []
|
|
228
230
|
updateThread(threadId, (t) => ({ ...t, queueItems: [] }))
|
|
231
|
+
return items
|
|
229
232
|
}
|
|
230
233
|
|
|
231
234
|
export function removeQueueItemAtPosition(
|
|
@@ -34,6 +34,7 @@ import { isAbortError } from '../utils.js'
|
|
|
34
34
|
import {
|
|
35
35
|
registerEventListener,
|
|
36
36
|
unregisterEventListener,
|
|
37
|
+
waitForGlobalEventListener,
|
|
37
38
|
} from './global-event-listener.js'
|
|
38
39
|
import { createLogger, LogPrefix } from '../logger.js'
|
|
39
40
|
import {
|
|
@@ -2309,6 +2310,25 @@ export class ThreadSessionRuntime {
|
|
|
2309
2310
|
repulseTyping: false,
|
|
2310
2311
|
})
|
|
2311
2312
|
|
|
2313
|
+
// Skip footer if model produced no visible output (no text, no tool calls,
|
|
2314
|
+
// just step-start/step-finish lifecycle parts). This happens when the model
|
|
2315
|
+
// decides not to respond.
|
|
2316
|
+
const hasVisibleOutput = assistantMessageIds.some((msgId) => {
|
|
2317
|
+
const parts = this.getBufferedParts(msgId)
|
|
2318
|
+
return parts.some(
|
|
2319
|
+
(part) => part.type !== 'step-start' && part.type !== 'step-finish',
|
|
2320
|
+
)
|
|
2321
|
+
})
|
|
2322
|
+
if (!hasVisibleOutput) {
|
|
2323
|
+
this.stopTyping()
|
|
2324
|
+
this.resetPerRunState()
|
|
2325
|
+
this.clearBufferedPartsForMessages(assistantMessageIds)
|
|
2326
|
+
logger.log(
|
|
2327
|
+
`[ASSISTANT COMPLETED] no visible output, skipping footer for message ${completedMessageId} sessionId=${sessionId}`,
|
|
2328
|
+
)
|
|
2329
|
+
return
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2312
2332
|
this.stopTyping()
|
|
2313
2333
|
|
|
2314
2334
|
const turnStartTime = getCurrentTurnStartTime({
|
|
@@ -3094,6 +3114,7 @@ export class ThreadSessionRuntime {
|
|
|
3094
3114
|
...variantField,
|
|
3095
3115
|
...(input.noReply ? { noReply: true } : {}),
|
|
3096
3116
|
}
|
|
3117
|
+
await waitForGlobalEventListener()
|
|
3097
3118
|
const promptResult = await getClient().session.promptAsync(request)
|
|
3098
3119
|
.catch((e) => new OpenCodeSdkError({ operation: 'session.promptAsync', cause: e }))
|
|
3099
3120
|
if (promptResult instanceof Error || promptResult.error) {
|
|
@@ -3482,9 +3503,9 @@ export class ThreadSessionRuntime {
|
|
|
3482
3503
|
: NOTIFY_MESSAGE_FLAGS
|
|
3483
3504
|
}
|
|
3484
3505
|
|
|
3485
|
-
/** Clear all queued messages. */
|
|
3486
|
-
clearQueue():
|
|
3487
|
-
threadState.clearQueueItems(this.threadId)
|
|
3506
|
+
/** Clear all queued messages. Returns the removed items. */
|
|
3507
|
+
clearQueue(): threadState.QueuedMessage[] {
|
|
3508
|
+
return threadState.clearQueueItems(this.threadId)
|
|
3488
3509
|
}
|
|
3489
3510
|
|
|
3490
3511
|
/** Remove a queued message by its 1-based position. */
|
|
@@ -3972,6 +3993,7 @@ export class ThreadSessionRuntime {
|
|
|
3972
3993
|
return
|
|
3973
3994
|
}
|
|
3974
3995
|
|
|
3996
|
+
await waitForGlobalEventListener()
|
|
3975
3997
|
const promptResponse = await getClient().session.promptAsync({
|
|
3976
3998
|
sessionID: session.id,
|
|
3977
3999
|
directory: this.sdkDirectory,
|
|
@@ -282,18 +282,30 @@ describe('system-message', () => {
|
|
|
282
282
|
|
|
283
283
|
\`--wait\` is incompatible with \`--send-at\` because scheduled tasks run in the future.
|
|
284
284
|
|
|
285
|
-
|
|
285
|
+
Keep scheduled task prompts **short**. The prompt text becomes the first message in the Discord thread, so long prompts clutter the channel. Instead of inlining the full task description in \`--prompt\`, write a markdown file in the project's \`tasks/\` folder and reference it:
|
|
286
286
|
|
|
287
|
-
|
|
287
|
+
\`\`\`bash
|
|
288
|
+
kimaki send --channel chan_123 --prompt 'Read tasks/weekly-test-suite.md and follow instructions' --send-at '0 9 * * 1' --agent <current_agent> --parent-session ses_123 --user '<discord-user-id>'
|
|
289
|
+
\`\`\`
|
|
290
|
+
|
|
291
|
+
The task file should contain all the detail: goal, constraints, expected output, completion criteria. Use this frontmatter format:
|
|
292
|
+
|
|
293
|
+
\`\`\`yaml
|
|
294
|
+
---
|
|
295
|
+
title: Weekly test suite
|
|
296
|
+
description: >
|
|
297
|
+
Managed by kimaki scheduled task. Do not move or delete this file
|
|
298
|
+
without also updating the kimaki task (kimaki task list / kimaki task edit).
|
|
299
|
+
---
|
|
300
|
+
\`\`\`
|
|
301
|
+
|
|
302
|
+
For simple reminders and notifications (\`--notify-only\`), inline the prompt directly since there is no AI session to read files.
|
|
288
303
|
|
|
289
|
-
Notification strategy
|
|
304
|
+
Notification strategy:
|
|
290
305
|
- NEVER use \`@username\` (e.g. \`@Tommy\`) directly in task prompts. The prompt text becomes the first message in the thread, so a raw \`@\` mention triggers an actual Discord ping every time the task fires. Instead, wrap it in inline code like \`\\\`@Tommy\\\`\`, or use Discord user ID mentions like \`<@USER_ID>\` only in the body of the prompt where the agent will process it, not in the opening line.
|
|
291
|
-
-
|
|
292
|
-
-
|
|
293
|
-
-
|
|
294
|
-
- With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
|
|
295
|
-
- If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
|
|
296
|
-
- Example no-op cleanup command: \`kimaki session archive thread_123 (or --session ses_123)\`
|
|
306
|
+
- If a task needs user attention, add "mention the user via Discord user ID when task requires user review" in the task md file.
|
|
307
|
+
- With \`--user\`, the user is added to the thread and receives thread-level notifications.
|
|
308
|
+
- If a scheduled task completes with no actionable result, archive the session: \`kimaki session archive thread_123 (or --session ses_123)\`
|
|
297
309
|
|
|
298
310
|
Manage scheduled tasks with:
|
|
299
311
|
|
|
@@ -303,20 +315,22 @@ describe('system-message', () => {
|
|
|
303
315
|
|
|
304
316
|
\`kimaki session list\` also shows if a session was started by a scheduled \`delay\` or \`cron\` task, including task ID when available.
|
|
305
317
|
|
|
318
|
+
**Never duplicate tasks to run more frequently.** If a task should run twice a day (morning and evening), edit the existing task's cron expression instead of creating a second task. Cron supports comma-separated hours:
|
|
319
|
+
|
|
320
|
+
\`\`\`bash
|
|
321
|
+
# runs at 9:00 UTC and 18:00 UTC every day
|
|
322
|
+
kimaki task edit <id> --send-at '0 9,18 * * *'
|
|
323
|
+
\`\`\`
|
|
324
|
+
|
|
306
325
|
Use case patterns:
|
|
307
|
-
- Reminder flows: create deadline reminders
|
|
308
|
-
- Proactive reminders: when you encounter time-sensitive information
|
|
309
|
-
- Weekly QA:
|
|
310
|
-
-
|
|
311
|
-
- Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
|
|
312
|
-
- Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive thread_123 (or --session ses_123)\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
|
|
313
|
-
- Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
|
|
326
|
+
- Reminder flows: create deadline reminders with one-time \`--send-at\` and \`--notify-only\`; mention only if action is required.
|
|
327
|
+
- Proactive reminders: when you encounter time-sensitive information (API key expiration, certificate renewal, trial ending), schedule a \`--notify-only\` reminder before the deadline. Always tell the user you scheduled the reminder so they know.
|
|
328
|
+
- Weekly QA / recurring maintenance: write the full task spec in \`tasks/\` and schedule a short prompt pointing to it.
|
|
329
|
+
- Thread reminders: when the user says "remind me about this in 2 hours", use \`--send-at\` with \`--thread\` to resurface the current thread:
|
|
314
330
|
|
|
315
331
|
kimaki send --thread thread_123 (or --session ses_123) --prompt 'Reminder: <@USER_ID> you asked to be reminded about this thread.' --send-at '<future_UTC_time>' --notify-only --agent <current_agent>
|
|
316
332
|
|
|
317
|
-
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
|
|
318
|
-
|
|
319
|
-
Scheduled tasks can maintain project memory by reading and updating an md file in the repository (for example \`docs/automation-notes.md\`) on each run.
|
|
333
|
+
Replace \`<future_UTC_time>\` with the computed UTC ISO timestamp.
|
|
320
334
|
|
|
321
335
|
Worktrees are useful for handing off parallel tasks that need to be isolated from each other (each session works on its own branch).
|
|
322
336
|
|
|
@@ -798,7 +812,7 @@ describe('system-message', () => {
|
|
|
798
812
|
- Main repo path (previous folder, DO NOT TOUCH): /repo
|
|
799
813
|
- To find the base branch (the branch this worktree was created from): \`git -C /repo symbolic-ref --short HEAD\`
|
|
800
814
|
- To find the base commit (the commit this worktree diverged from): \`git merge-base <base-branch> HEAD\`
|
|
801
|
-
You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default.
|
|
815
|
+
You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. To merge this worktree into the main branch, run \`kimaki merge-worktree\`. If it reports rebase conflicts, resolve them and rerun until it succeeds.
|
|
802
816
|
</system-reminder>
|
|
803
817
|
"
|
|
804
818
|
`)
|