kimaki 0.26.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent-model.e2e.test.js +25 -122
- package/dist/anthropic-account-identity.js +1 -0
- package/dist/anthropic-auth-plugin.js +1 -0
- package/dist/anthropic-auth-state.js +1 -0
- package/dist/bin.js +73 -19
- package/dist/cli-commands/maintenance.js +46 -7
- package/dist/cli-commands/send.js +24 -0
- package/dist/cli-commands/session.js +183 -8
- package/dist/cli-commands/task.js +13 -1
- package/dist/cli-parsing.test.js +59 -3
- package/dist/cli-runner.js +28 -4
- package/dist/cli-runner.test.js +27 -0
- package/dist/cli-send-thread.e2e.test.js +1 -1
- package/dist/cli.js +32 -0
- package/dist/commands/abort.js +5 -1
- package/dist/commands/agent.js +6 -8
- package/dist/commands/ask-question.js +21 -32
- package/dist/commands/ask-question.test.js +43 -1
- package/dist/commands/login.js +6 -0
- package/dist/commands/merge-worktree.js +36 -10
- package/dist/commands/model-variant.js +7 -1
- package/dist/commands/model.js +14 -8
- package/dist/commands/multioauth.js +1 -0
- package/dist/commands/new-worktree.js +170 -102
- package/dist/commands/user-command.js +6 -8
- package/dist/commands/worktrees.js +5 -13
- package/dist/config.js +16 -1
- package/dist/database.js +124 -3
- package/dist/db.js +22 -0
- package/dist/db.test.js +167 -1
- package/dist/discord-bot.js +34 -32
- package/dist/discord-command-registration.js +9 -1
- package/dist/discord-utils.js +13 -5
- package/dist/discord-utils.test.js +17 -1
- package/dist/errors.js +5 -0
- package/dist/exec-async.js +17 -5
- package/dist/external-directory-permissions.e2e.test.js +2 -2
- package/dist/file-edit-log.js +197 -0
- package/dist/file-edit-log.test.js +258 -0
- package/dist/format-tables.js +52 -0
- package/dist/format-tables.test.js +93 -0
- package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
- package/dist/gateway-proxy.e2e.test.js +5 -5
- package/dist/git-worktree-core.js +182 -17
- package/dist/ipc-tools-plugin.js +63 -3
- package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
- package/dist/kimaki-opencode-plugin.js +7 -3
- package/dist/kimaki-workspace-adaptor.js +33 -10
- package/dist/logger.js +10 -1
- package/dist/markdown.js +3 -1
- package/dist/markdown.test.js +11 -3
- package/dist/message-formatting.js +18 -7
- package/dist/message-formatting.test.js +56 -9
- package/dist/oauth-rotation-shared.js +10 -0
- package/dist/openai-auth-plugin.js +1 -0
- package/dist/openai-auth-state.js +1 -0
- package/dist/opencode-command.test.js +60 -0
- package/dist/opencode.js +53 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
- package/dist/question-text-before-dropdown.e2e.test.js +52 -0
- package/dist/queue-advanced-abort.e2e.test.js +1 -1
- package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
- package/dist/queue-advanced-e2e-setup.js +78 -5
- package/dist/queue-advanced-footer.e2e.test.js +40 -246
- package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
- package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
- package/dist/queue-advanced-question.e2e.test.js +31 -26
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
- package/dist/queue-delete-message.e2e.test.js +1 -1
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
- package/dist/queue-interrupt-drain.e2e.test.js +112 -2
- package/dist/queue-question-select-drain.e2e.test.js +30 -128
- package/dist/runtime-lifecycle.e2e.test.js +10 -37
- package/dist/schema.js +34 -0
- package/dist/session-handler/event-stream-state.js +267 -50
- package/dist/session-handler/event-stream-state.test.js +415 -8
- package/dist/session-handler/model-utils.js +159 -5
- package/dist/session-handler/model-utils.test.js +345 -0
- package/dist/session-handler/thread-session-runtime.js +252 -182
- package/dist/session-sleep.test.js +224 -0
- package/dist/session-title-rename.test.js +7 -39
- package/dist/sleep.e2e.test.js +491 -0
- package/dist/startup-time.e2e.test.js +2 -1
- package/dist/store.js +3 -0
- package/dist/system-message.js +59 -6
- package/dist/system-message.test.js +76 -6
- package/dist/task-runner.js +94 -4
- package/dist/task-schedule.js +56 -0
- package/dist/task-schedule.test.js +71 -1
- package/dist/thread-message-queue.e2e.test.js +10 -169
- package/dist/undo-redo.e2e.test.js +2 -2
- package/dist/voice-attachment.js +23 -1
- package/dist/voice-message.e2e.test.js +10 -10
- package/dist/voice.test.js +55 -29
- package/dist/worktree-clone-isolation.e2e.test.js +188 -0
- package/dist/worktree-lifecycle.e2e.test.js +29 -10
- package/dist/worktrees.js +226 -443
- package/dist/worktrees.test.js +435 -58
- package/dist/xai-auth-plugin.js +1 -0
- package/dist/xai-auth-state.js +1 -0
- package/package.json +8 -6
- package/skills/errore/SKILL.md +18 -0
- package/skills/goke/SKILL.md +150 -10
- package/skills/holocron/SKILL.md +37 -0
- package/skills/npm-package/SKILL.md +55 -35
- package/skills/profano/SKILL.md +17 -2
- package/skills/spiceflow/SKILL.md +39 -3
- package/skills/strada/SKILL.md +39 -0
- package/skills/zele/SKILL.md +19 -0
- package/src/agent-model.e2e.test.ts +25 -151
- package/src/anthropic-account-identity.ts +1 -0
- package/src/anthropic-auth-plugin.ts +1 -0
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bin.ts +78 -26
- package/src/cli-commands/maintenance.ts +59 -13
- package/src/cli-commands/send.ts +25 -0
- package/src/cli-commands/session.ts +203 -9
- package/src/cli-commands/task.ts +13 -1
- package/src/cli-parsing.test.ts +69 -3
- package/src/cli-runner.test.ts +49 -0
- package/src/cli-runner.ts +28 -4
- package/src/cli-send-thread.e2e.test.ts +1 -1
- package/src/cli.ts +53 -0
- package/src/commands/abort.ts +6 -1
- package/src/commands/agent.ts +6 -6
- package/src/commands/ask-question.test.ts +48 -1
- package/src/commands/ask-question.ts +21 -32
- package/src/commands/login.ts +6 -0
- package/src/commands/merge-worktree.ts +43 -12
- package/src/commands/model-variant.ts +8 -1
- package/src/commands/model.ts +18 -8
- package/src/commands/multioauth.ts +1 -0
- package/src/commands/new-worktree.ts +138 -55
- package/src/commands/user-command.ts +6 -6
- package/src/commands/worktrees.ts +5 -12
- package/src/config.ts +18 -1
- package/src/database.ts +195 -3
- package/src/db.test.ts +188 -0
- package/src/db.ts +26 -0
- package/src/discord-bot.ts +35 -29
- package/src/discord-command-registration.ts +15 -1
- package/src/discord-utils.test.ts +19 -0
- package/src/discord-utils.ts +21 -6
- package/src/errors.ts +5 -0
- package/src/exec-async.ts +20 -11
- package/src/external-directory-permissions.e2e.test.ts +2 -2
- package/src/file-edit-log.test.ts +287 -0
- package/src/file-edit-log.ts +283 -0
- package/src/format-tables.test.ts +101 -0
- package/src/format-tables.ts +75 -0
- package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
- package/src/gateway-proxy.e2e.test.ts +5 -5
- package/src/git-worktree-core.ts +247 -20
- package/src/ipc-tools-plugin.ts +75 -5
- package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
- package/src/kimaki-opencode-plugin.ts +7 -3
- package/src/kimaki-workspace-adaptor.ts +40 -11
- package/src/logger.ts +13 -4
- package/src/markdown.test.ts +13 -3
- package/src/markdown.ts +4 -1
- package/src/message-formatting.test.ts +86 -12
- package/src/message-formatting.ts +19 -7
- package/src/oauth-rotation-shared.ts +10 -0
- package/src/openai-auth-plugin.ts +1 -0
- package/src/openai-auth-state.ts +1 -0
- package/src/opencode-command.test.ts +81 -0
- package/src/opencode.ts +71 -11
- package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
- package/src/question-text-before-dropdown.e2e.test.ts +67 -0
- package/src/queue-advanced-abort.e2e.test.ts +1 -1
- package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
- package/src/queue-advanced-e2e-setup.ts +83 -4
- package/src/queue-advanced-footer.e2e.test.ts +47 -294
- package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
- package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
- package/src/queue-advanced-question.e2e.test.ts +35 -29
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
- package/src/queue-delete-message.e2e.test.ts +1 -1
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
- package/src/queue-interrupt-drain.e2e.test.ts +123 -2
- package/src/queue-question-select-drain.e2e.test.ts +32 -157
- package/src/runtime-lifecycle.e2e.test.ts +10 -41
- package/src/schema.sql +14 -1
- package/src/schema.ts +36 -0
- package/src/session-handler/event-stream-state.test.ts +437 -7
- package/src/session-handler/event-stream-state.ts +389 -53
- package/src/session-handler/model-utils.test.ts +415 -0
- package/src/session-handler/model-utils.ts +249 -5
- package/src/session-handler/thread-session-runtime.ts +300 -215
- package/src/session-sleep.test.ts +294 -0
- package/src/session-title-rename.test.ts +9 -48
- package/src/sleep.e2e.test.ts +562 -0
- package/src/startup-time.e2e.test.ts +5 -1
- package/src/store.ts +21 -0
- package/src/system-message.test.ts +86 -6
- package/src/system-message.ts +66 -7
- package/src/task-runner.ts +136 -2
- package/src/task-schedule.test.ts +76 -0
- package/src/task-schedule.ts +95 -0
- package/src/thread-message-queue.e2e.test.ts +10 -202
- package/src/undo-redo.e2e.test.ts +2 -2
- package/src/voice-attachment.ts +29 -1
- package/src/voice-message.e2e.test.ts +10 -10
- package/src/voice.test.ts +63 -29
- package/src/worktree-clone-isolation.e2e.test.ts +213 -0
- package/src/worktree-lifecycle.e2e.test.ts +29 -9
- package/src/worktrees.test.ts +505 -64
- package/src/worktrees.ts +257 -603
- package/src/xai-auth-plugin.ts +1 -0
- package/src/xai-auth-state.ts +1 -0
- package/dist/message-finish-field.e2e.test.js +0 -169
- package/dist/subagent-rate-limit-plugin.js +0 -184
- package/src/message-finish-field.e2e.test.ts +0 -196
- package/src/subagent-rate-limit-plugin.ts +0 -228
|
@@ -40,6 +40,8 @@ import {
|
|
|
40
40
|
sendThreadMessage,
|
|
41
41
|
SILENT_MESSAGE_FLAGS,
|
|
42
42
|
NOTIFY_MESSAGE_FLAGS,
|
|
43
|
+
raceDiscordRename,
|
|
44
|
+
DISCORD_THREAD_RENAME_TIMEOUT_MS,
|
|
43
45
|
} from '../discord-utils.js'
|
|
44
46
|
import type { DiscordFileAttachment } from '../message-formatting.js'
|
|
45
47
|
import { formatPart, formatTaskToolTitle } from '../message-formatting.js'
|
|
@@ -62,6 +64,7 @@ import {
|
|
|
62
64
|
startScheduledTaskRunSession,
|
|
63
65
|
appendSessionEventsSinceLastTimestamp,
|
|
64
66
|
getSessionEventSnapshot,
|
|
67
|
+
cancelSessionSleepForThread,
|
|
65
68
|
} from '../database.js'
|
|
66
69
|
import * as orm from 'drizzle-orm'
|
|
67
70
|
import * as schema from '../schema.js'
|
|
@@ -75,6 +78,7 @@ import {
|
|
|
75
78
|
showAskUserQuestionDropdowns,
|
|
76
79
|
pendingQuestionContexts,
|
|
77
80
|
cancelPendingQuestion,
|
|
81
|
+
findPendingQuestionContextForRequest,
|
|
78
82
|
} from '../commands/ask-question.js'
|
|
79
83
|
import {
|
|
80
84
|
showActionButtons,
|
|
@@ -90,6 +94,11 @@ import {
|
|
|
90
94
|
getCurrentModelInfo,
|
|
91
95
|
ensureSessionPreferencesSnapshot,
|
|
92
96
|
} from '../commands/model.js'
|
|
97
|
+
import {
|
|
98
|
+
displayedModelLabel,
|
|
99
|
+
getProviderModelName,
|
|
100
|
+
validateModelId,
|
|
101
|
+
} from './model-utils.js'
|
|
93
102
|
import {
|
|
94
103
|
getOpencodePromptContext,
|
|
95
104
|
getOpencodeSystemMessage,
|
|
@@ -99,6 +108,7 @@ import {
|
|
|
99
108
|
type WorktreeInfo,
|
|
100
109
|
} from '../system-message.js'
|
|
101
110
|
import { getDataDir } from '../config.js'
|
|
111
|
+
import { store } from '../store.js'
|
|
102
112
|
import {
|
|
103
113
|
trackEvent,
|
|
104
114
|
type AnalyticsIngressMode,
|
|
@@ -115,6 +125,7 @@ import {
|
|
|
115
125
|
import {
|
|
116
126
|
doesLatestUserTurnHaveNaturalCompletion,
|
|
117
127
|
didQuestionQueueHandoffSinceLatestQuestionAsked,
|
|
128
|
+
deriveLatestUnansweredQuestion,
|
|
118
129
|
getAssistantMessageIdsForLatestUserTurn,
|
|
119
130
|
getCurrentTurnStartTime,
|
|
120
131
|
isSessionBusy,
|
|
@@ -122,6 +133,8 @@ import {
|
|
|
122
133
|
getIdleTokenUsageDelta,
|
|
123
134
|
getDerivedSubtaskIndex,
|
|
124
135
|
getDerivedSubtaskAgentType,
|
|
136
|
+
getTokenUsageSessionIdsForIdle,
|
|
137
|
+
isDerivedChildSession,
|
|
125
138
|
getLatestAssistantMessageIdForLatestUserTurn,
|
|
126
139
|
hasAssistantMessageCompletedBefore,
|
|
127
140
|
isAssistantMessageInLatestUserTurn,
|
|
@@ -429,6 +442,14 @@ const PRESERVED_THREAD_PREFIXES: string[] = [
|
|
|
429
442
|
'Fork: ',
|
|
430
443
|
]
|
|
431
444
|
|
|
445
|
+
function stripPreservedThreadPrefix(name: string) {
|
|
446
|
+
const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((prefix) => {
|
|
447
|
+
return name.startsWith(prefix)
|
|
448
|
+
})
|
|
449
|
+
if (!matchedPrefix) return name
|
|
450
|
+
return name.slice(matchedPrefix.length).trim()
|
|
451
|
+
}
|
|
452
|
+
|
|
432
453
|
function getThreadNameCandidateFromSessionTitle({
|
|
433
454
|
sessionTitle,
|
|
434
455
|
currentName,
|
|
@@ -440,14 +461,18 @@ function getThreadNameCandidateFromSessionTitle({
|
|
|
440
461
|
if (!trimmed) {
|
|
441
462
|
return null
|
|
442
463
|
}
|
|
443
|
-
|
|
464
|
+
const withoutCopiedPrefix = stripPreservedThreadPrefix(trimmed)
|
|
465
|
+
if (!withoutCopiedPrefix) {
|
|
466
|
+
return null
|
|
467
|
+
}
|
|
468
|
+
if (/^new session\s*-/i.test(withoutCopiedPrefix)) {
|
|
444
469
|
return null
|
|
445
470
|
}
|
|
446
471
|
const matchedPrefix =
|
|
447
472
|
PRESERVED_THREAD_PREFIXES.find((p) => {
|
|
448
473
|
return currentName.startsWith(p)
|
|
449
474
|
}) ?? ''
|
|
450
|
-
return `${matchedPrefix}${
|
|
475
|
+
return `${matchedPrefix}${withoutCopiedPrefix}`.slice(0, DISCORD_THREAD_NAME_MAX)
|
|
451
476
|
}
|
|
452
477
|
|
|
453
478
|
export function deriveThreadNameFromSessionTitle({
|
|
@@ -470,45 +495,6 @@ export function deriveThreadNameFromSessionTitle({
|
|
|
470
495
|
return candidate
|
|
471
496
|
}
|
|
472
497
|
|
|
473
|
-
export function deriveThreadRenameFromSessionUpdate({
|
|
474
|
-
sessionTitle,
|
|
475
|
-
currentName,
|
|
476
|
-
lastSyncedName,
|
|
477
|
-
}: {
|
|
478
|
-
sessionTitle: string | undefined | null
|
|
479
|
-
currentName: string
|
|
480
|
-
lastSyncedName: string | null
|
|
481
|
-
}) {
|
|
482
|
-
if (lastSyncedName !== null && currentName !== lastSyncedName) {
|
|
483
|
-
return {
|
|
484
|
-
desiredName: null,
|
|
485
|
-
nextSyncedName: lastSyncedName,
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
|
|
489
|
-
const candidate = getThreadNameCandidateFromSessionTitle({
|
|
490
|
-
sessionTitle,
|
|
491
|
-
currentName,
|
|
492
|
-
})
|
|
493
|
-
if (candidate === null) {
|
|
494
|
-
return {
|
|
495
|
-
desiredName: null,
|
|
496
|
-
nextSyncedName: lastSyncedName,
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
if (candidate === currentName) {
|
|
500
|
-
return {
|
|
501
|
-
desiredName: null,
|
|
502
|
-
nextSyncedName: currentName,
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
|
|
506
|
-
return {
|
|
507
|
-
desiredName: candidate,
|
|
508
|
-
nextSyncedName: candidate,
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
|
|
512
498
|
// ── Ingress input type ───────────────────────────────────────────
|
|
513
499
|
|
|
514
500
|
export type EnqueueResult = {
|
|
@@ -588,6 +574,12 @@ export type IngressInput = {
|
|
|
588
574
|
* (e.g. user-to-user replies in a thread).
|
|
589
575
|
*/
|
|
590
576
|
noReply?: boolean
|
|
577
|
+
/**
|
|
578
|
+
* True only for the wake prompt posted by the kimaki_sleep task runner.
|
|
579
|
+
* Every other ingress cancels a pending sleep; this one must not, because it
|
|
580
|
+
* is delivering that sleep rather than superseding it.
|
|
581
|
+
*/
|
|
582
|
+
isSleepWake?: boolean
|
|
591
583
|
/**
|
|
592
584
|
* Product-analytics turn source. Defaults to discord. Set retry/cli/scheduled
|
|
593
585
|
* at the ingress site so DAU queries can exclude non-user activity.
|
|
@@ -694,27 +686,22 @@ export class ThreadSessionRuntime {
|
|
|
694
686
|
// message and showing multiple back-to-back POSTs is wasteful.
|
|
695
687
|
private typingKeepaliveTimeout: ReturnType<typeof setTimeout> | null = null
|
|
696
688
|
private readonly typingRepulseDebounce: ReturnType<typeof createDebouncedTimeout>
|
|
689
|
+
private readonly deferredQuestionShow: ReturnType<typeof createDebouncedTimeout>
|
|
697
690
|
|
|
698
691
|
private static TYPING_REPULSE_DEBOUNCE_MS = 500
|
|
692
|
+
private static DEFERRED_QUESTION_SHOW_MS = 1000
|
|
699
693
|
|
|
700
694
|
// Notification throttles for retry/context notices.
|
|
701
695
|
private lastDisplayedContextPercentage = 0
|
|
702
696
|
private lastRateLimitDisplayTime = 0
|
|
703
697
|
|
|
704
|
-
// Last OpenCode
|
|
705
|
-
//
|
|
706
|
-
// we only call thread.setName() once per distinct title. Discord rate-limits
|
|
707
|
-
// channel/thread renames to ~2 per 10 minutes per thread, so we must avoid
|
|
708
|
-
// retrying. Not persisted — worst case on restart we re-apply the same title
|
|
709
|
-
// once (which is a no-op via deriveThreadNameFromSessionTitle).
|
|
698
|
+
// Last OpenCode session title we applied to Discord. Dedupes session.updated
|
|
699
|
+
// so we only call setName once per distinct title. Not persisted.
|
|
710
700
|
private appliedOpencodeTitle: string | undefined
|
|
711
701
|
|
|
712
|
-
// Last Discord thread name known to match the OpenCode title. Persisted so a
|
|
713
|
-
// user rename is still respected after Kimaki restarts.
|
|
714
|
-
private lastSyncedThreadName: string | null | undefined
|
|
715
|
-
|
|
716
702
|
// Part output buffering (write-side cache, not domain state)
|
|
717
703
|
private partBuffer = new Map<string, Map<string, Part>>()
|
|
704
|
+
private shownQuestionRequestIds = new Set<string>()
|
|
718
705
|
|
|
719
706
|
// Derivable cache (perf optimization for provider.list API call)
|
|
720
707
|
private modelContextLimit: number | undefined
|
|
@@ -792,6 +779,17 @@ export class ThreadSessionRuntime {
|
|
|
792
779
|
this.restartTypingKeepalive({ sendNow: true })
|
|
793
780
|
},
|
|
794
781
|
})
|
|
782
|
+
this.deferredQuestionShow = createDebouncedTimeout({
|
|
783
|
+
delayMs: ThreadSessionRuntime.DEFERRED_QUESTION_SHOW_MS,
|
|
784
|
+
callback: () => {
|
|
785
|
+
if (this.disposed) {
|
|
786
|
+
return
|
|
787
|
+
}
|
|
788
|
+
void this.dispatchAction(async () => {
|
|
789
|
+
await this.tryShowPendingQuestion({ ignoreUnfinishedText: true })
|
|
790
|
+
})
|
|
791
|
+
},
|
|
792
|
+
})
|
|
795
793
|
}
|
|
796
794
|
|
|
797
795
|
private consumeWorktreePromptChange(
|
|
@@ -1026,21 +1024,25 @@ export class ThreadSessionRuntime {
|
|
|
1026
1024
|
if (!mainSessionId || candidateSessionId === mainSessionId) {
|
|
1027
1025
|
return undefined
|
|
1028
1026
|
}
|
|
1029
|
-
|
|
1027
|
+
if (!isDerivedChildSession({
|
|
1030
1028
|
events: this.eventBuffer,
|
|
1031
1029
|
mainSessionId,
|
|
1032
1030
|
candidateSessionId,
|
|
1033
|
-
})
|
|
1034
|
-
if (!subtaskIndex) {
|
|
1031
|
+
})) {
|
|
1035
1032
|
return undefined
|
|
1036
1033
|
}
|
|
1037
1034
|
|
|
1035
|
+
const subtaskIndex = getDerivedSubtaskIndex({
|
|
1036
|
+
events: this.eventBuffer,
|
|
1037
|
+
mainSessionId,
|
|
1038
|
+
candidateSessionId,
|
|
1039
|
+
})
|
|
1038
1040
|
const agentType = getDerivedSubtaskAgentType({
|
|
1039
1041
|
events: this.eventBuffer,
|
|
1040
1042
|
mainSessionId,
|
|
1041
1043
|
candidateSessionId,
|
|
1042
1044
|
})
|
|
1043
|
-
const label = `${agentType || 'task'}-${subtaskIndex}`
|
|
1045
|
+
const label = `${agentType || 'task'}-${subtaskIndex || 1}`
|
|
1044
1046
|
const assistantMessageId = this.getLatestAssistantMessageIdForCurrentTurn({
|
|
1045
1047
|
sessionId: candidateSessionId,
|
|
1046
1048
|
})
|
|
@@ -1053,6 +1055,7 @@ export class ThreadSessionRuntime {
|
|
|
1053
1055
|
this.disposed = true
|
|
1054
1056
|
unregisterEventListener(this.threadId)
|
|
1055
1057
|
void this.persistEventBufferDebounced.dispose()
|
|
1058
|
+
this.deferredQuestionShow.clear()
|
|
1056
1059
|
this.stopTyping()
|
|
1057
1060
|
|
|
1058
1061
|
// Release large internal buffers so GC can reclaim memory immediately
|
|
@@ -1060,6 +1063,7 @@ export class ThreadSessionRuntime {
|
|
|
1060
1063
|
this.eventBuffer = []
|
|
1061
1064
|
this.nextEventIndex = 0
|
|
1062
1065
|
this.partBuffer.clear()
|
|
1066
|
+
this.shownQuestionRequestIds.clear()
|
|
1063
1067
|
this.preprocessChain = Promise.resolve()
|
|
1064
1068
|
|
|
1065
1069
|
// Don't clear actionQueue here — queued closures own resolve/reject for
|
|
@@ -2066,41 +2070,6 @@ export class ThreadSessionRuntime {
|
|
|
2066
2070
|
return
|
|
2067
2071
|
}
|
|
2068
2072
|
|
|
2069
|
-
if (
|
|
2070
|
-
part.type === 'tool' &&
|
|
2071
|
-
part.tool === 'task' &&
|
|
2072
|
-
!this.state?.sentPartIds.has(part.id)
|
|
2073
|
-
) {
|
|
2074
|
-
const taskDisplay = formatTaskToolTitle(part)
|
|
2075
|
-
if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
|
|
2076
|
-
await this.flushBufferedParts({
|
|
2077
|
-
messageID: part.messageID,
|
|
2078
|
-
force: true,
|
|
2079
|
-
skipPartId: part.id,
|
|
2080
|
-
})
|
|
2081
|
-
threadState.updateThread(this.threadId, (t) => {
|
|
2082
|
-
const newIds = new Set(t.sentPartIds)
|
|
2083
|
-
newIds.add(part.id)
|
|
2084
|
-
return { ...t, sentPartIds: newIds }
|
|
2085
|
-
})
|
|
2086
|
-
const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
|
|
2087
|
-
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2088
|
-
if (sendResult instanceof Error) {
|
|
2089
|
-
threadState.updateThread(this.threadId, (t) => {
|
|
2090
|
-
const newIds = new Set(t.sentPartIds)
|
|
2091
|
-
newIds.delete(part.id)
|
|
2092
|
-
return { ...t, sentPartIds: newIds }
|
|
2093
|
-
})
|
|
2094
|
-
discordLogger.error(
|
|
2095
|
-
`ERROR: Failed to send task part ${part.id}:`,
|
|
2096
|
-
sendResult,
|
|
2097
|
-
)
|
|
2098
|
-
return
|
|
2099
|
-
}
|
|
2100
|
-
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
|
|
2101
|
-
}
|
|
2102
|
-
}
|
|
2103
|
-
|
|
2104
2073
|
if (part.type === 'tool' && part.state.status === 'running') {
|
|
2105
2074
|
await this.flushBufferedParts({
|
|
2106
2075
|
messageID: part.messageID,
|
|
@@ -2108,6 +2077,32 @@ export class ThreadSessionRuntime {
|
|
|
2108
2077
|
skipPartId: part.id,
|
|
2109
2078
|
})
|
|
2110
2079
|
await this.sendPartMessage({ part })
|
|
2080
|
+
|
|
2081
|
+
if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
|
|
2082
|
+
const taskDisplay = formatTaskToolTitle(part)
|
|
2083
|
+
if (taskDisplay && (await this.getVerbosity()) !== 'text_only') {
|
|
2084
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
2085
|
+
const newIds = new Set(t.sentPartIds)
|
|
2086
|
+
newIds.add(part.id)
|
|
2087
|
+
return { ...t, sentPartIds: newIds }
|
|
2088
|
+
})
|
|
2089
|
+
const sendResult = await sendThreadMessage(this.thread, taskDisplay + '\n\n')
|
|
2090
|
+
.catch((e) => new DiscordOperationError({ operation: 'sendMessage', cause: e }))
|
|
2091
|
+
if (sendResult instanceof Error) {
|
|
2092
|
+
threadState.updateThread(this.threadId, (t) => {
|
|
2093
|
+
const newIds = new Set(t.sentPartIds)
|
|
2094
|
+
newIds.delete(part.id)
|
|
2095
|
+
return { ...t, sentPartIds: newIds }
|
|
2096
|
+
})
|
|
2097
|
+
discordLogger.error(
|
|
2098
|
+
`ERROR: Failed to send task part ${part.id}:`,
|
|
2099
|
+
sendResult,
|
|
2100
|
+
)
|
|
2101
|
+
return
|
|
2102
|
+
}
|
|
2103
|
+
await setPartMessage({ partId: part.id, messageId: sendResult.id, threadId: this.thread.id })
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2111
2106
|
return
|
|
2112
2107
|
}
|
|
2113
2108
|
|
|
@@ -2238,6 +2233,7 @@ export class ThreadSessionRuntime {
|
|
|
2238
2233
|
|
|
2239
2234
|
if (part.type === 'text' && part.time?.end) {
|
|
2240
2235
|
await this.sendPartMessage({ part })
|
|
2236
|
+
await this.tryShowPendingQuestion()
|
|
2241
2237
|
return
|
|
2242
2238
|
}
|
|
2243
2239
|
|
|
@@ -2301,21 +2297,16 @@ export class ThreadSessionRuntime {
|
|
|
2301
2297
|
this.requestTypingRepulse()
|
|
2302
2298
|
}
|
|
2303
2299
|
|
|
2304
|
-
private trackIdleTokenUsage(
|
|
2305
|
-
|
|
2306
|
-
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
}
|
|
2312
|
-
}
|
|
2313
|
-
if (idleEventIndex === undefined) {
|
|
2314
|
-
return
|
|
2315
|
-
}
|
|
2300
|
+
private trackIdleTokenUsage({
|
|
2301
|
+
sessionId,
|
|
2302
|
+
idleEventIndex,
|
|
2303
|
+
}: {
|
|
2304
|
+
sessionId: string
|
|
2305
|
+
idleEventIndex: number
|
|
2306
|
+
}): void {
|
|
2316
2307
|
const usage = getIdleTokenUsageDelta({
|
|
2317
2308
|
events: this.eventBuffer,
|
|
2318
|
-
sessionId
|
|
2309
|
+
sessionId,
|
|
2319
2310
|
idleEventIndex,
|
|
2320
2311
|
})
|
|
2321
2312
|
if (!usage) {
|
|
@@ -2331,7 +2322,7 @@ export class ThreadSessionRuntime {
|
|
|
2331
2322
|
tokens_total: usage.total,
|
|
2332
2323
|
cost: usage.cost,
|
|
2333
2324
|
assistant_message_count: usage.assistantMessageCount,
|
|
2334
|
-
is_subagent: Boolean(this.getSubtaskInfoForSession(
|
|
2325
|
+
is_subagent: Boolean(this.getSubtaskInfoForSession(sessionId)),
|
|
2335
2326
|
}
|
|
2336
2327
|
if (usage.model) {
|
|
2337
2328
|
properties.model = usage.model
|
|
@@ -2342,8 +2333,37 @@ export class ThreadSessionRuntime {
|
|
|
2342
2333
|
trackEvent('tokens_used', properties)
|
|
2343
2334
|
}
|
|
2344
2335
|
|
|
2336
|
+
private trackIdleTokenUsageForSessionTree(idleSessionId: string): void {
|
|
2337
|
+
let idleEventIndex: number | undefined
|
|
2338
|
+
for (let i = this.eventBuffer.length - 1; i >= 0; i--) {
|
|
2339
|
+
const event = this.eventBuffer[i]?.event
|
|
2340
|
+
if (event?.type === 'session.idle' && event.properties.sessionID === idleSessionId) {
|
|
2341
|
+
idleEventIndex = i
|
|
2342
|
+
break
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
if (idleEventIndex === undefined) {
|
|
2346
|
+
return
|
|
2347
|
+
}
|
|
2348
|
+
const mainSessionId = this.state?.sessionId
|
|
2349
|
+
const sessionIds = mainSessionId
|
|
2350
|
+
? getTokenUsageSessionIdsForIdle({
|
|
2351
|
+
events: this.eventBuffer,
|
|
2352
|
+
mainSessionId,
|
|
2353
|
+
idleSessionId,
|
|
2354
|
+
upToIndex: idleEventIndex,
|
|
2355
|
+
})
|
|
2356
|
+
: [idleSessionId]
|
|
2357
|
+
for (const sessionId of sessionIds) {
|
|
2358
|
+
this.trackIdleTokenUsage({
|
|
2359
|
+
sessionId,
|
|
2360
|
+
idleEventIndex,
|
|
2361
|
+
})
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2345
2365
|
private async handleSessionIdle(idleSessionId: string): Promise<void> {
|
|
2346
|
-
this.
|
|
2366
|
+
this.trackIdleTokenUsageForSessionTree(idleSessionId)
|
|
2347
2367
|
|
|
2348
2368
|
const sessionId = this.state?.sessionId
|
|
2349
2369
|
|
|
@@ -2629,41 +2649,89 @@ export class ThreadSessionRuntime {
|
|
|
2629
2649
|
this.onInteractiveUiStateChanged()
|
|
2630
2650
|
}
|
|
2631
2651
|
|
|
2632
|
-
private
|
|
2633
|
-
|
|
2634
|
-
|
|
2652
|
+
private hasUnfinishedTextPart(messageID: string): boolean {
|
|
2653
|
+
return this.getBufferedParts(messageID).some((part) => {
|
|
2654
|
+
return part.type === 'text' && !part.time?.end
|
|
2655
|
+
})
|
|
2656
|
+
}
|
|
2657
|
+
|
|
2658
|
+
// OpenCode emits question.asked when the tool starts, often before the
|
|
2659
|
+
// preceding text part gets time.end. Showing the dropdown on that event
|
|
2660
|
+
// holds the action queue while Discord posts, so the later text-end cannot
|
|
2661
|
+
// send and dumps after the queued » user: indicator. Wait for text-end.
|
|
2662
|
+
private async tryShowPendingQuestion({
|
|
2663
|
+
ignoreUnfinishedText = false,
|
|
2664
|
+
} = {}): Promise<boolean> {
|
|
2635
2665
|
const sessionId = this.state?.sessionId
|
|
2636
|
-
if (
|
|
2637
|
-
|
|
2638
|
-
`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
|
|
2639
|
-
)
|
|
2640
|
-
return
|
|
2666
|
+
if (!sessionId) {
|
|
2667
|
+
return false
|
|
2641
2668
|
}
|
|
2642
2669
|
|
|
2643
|
-
|
|
2644
|
-
|
|
2645
|
-
|
|
2670
|
+
const request = deriveLatestUnansweredQuestion({
|
|
2671
|
+
events: this.eventBuffer,
|
|
2672
|
+
sessionId,
|
|
2673
|
+
})
|
|
2674
|
+
if (!request) {
|
|
2675
|
+
this.deferredQuestionShow.clear()
|
|
2676
|
+
return false
|
|
2677
|
+
}
|
|
2678
|
+
if (
|
|
2679
|
+
this.shownQuestionRequestIds.has(request.id)
|
|
2680
|
+
|| findPendingQuestionContextForRequest({
|
|
2681
|
+
threadId: this.thread.id,
|
|
2682
|
+
requestId: request.id,
|
|
2683
|
+
})
|
|
2684
|
+
) {
|
|
2685
|
+
this.deferredQuestionShow.clear()
|
|
2686
|
+
return true
|
|
2687
|
+
}
|
|
2646
2688
|
|
|
2689
|
+
const messageId = request.tool?.messageID
|
|
2690
|
+
if (!ignoreUnfinishedText && messageId && this.hasUnfinishedTextPart(messageId)) {
|
|
2691
|
+
return false
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2694
|
+
this.shownQuestionRequestIds.add(request.id)
|
|
2647
2695
|
await this.showInteractiveUi({
|
|
2696
|
+
flushMessageId: messageId,
|
|
2648
2697
|
show: async () => {
|
|
2649
|
-
if (!sessionId) {
|
|
2650
|
-
return
|
|
2651
|
-
}
|
|
2652
2698
|
await showAskUserQuestionDropdowns({
|
|
2653
2699
|
thread: this.thread,
|
|
2654
2700
|
sessionId,
|
|
2655
2701
|
directory: this.sdkDirectory,
|
|
2656
|
-
requestId:
|
|
2657
|
-
input: { questions:
|
|
2702
|
+
requestId: request.id,
|
|
2703
|
+
input: { questions: request.questions },
|
|
2658
2704
|
silent: this.getQueueLength() > 0,
|
|
2659
2705
|
})
|
|
2660
2706
|
},
|
|
2661
2707
|
})
|
|
2662
|
-
|
|
2708
|
+
this.deferredQuestionShow.clear()
|
|
2663
2709
|
this.maybeHandoffQueuedItemForPendingQuestion({
|
|
2664
2710
|
sessionId,
|
|
2665
2711
|
reason: 'question-shown',
|
|
2666
2712
|
})
|
|
2713
|
+
return true
|
|
2714
|
+
}
|
|
2715
|
+
|
|
2716
|
+
private async handleQuestionAsked(
|
|
2717
|
+
questionRequest: QuestionRequest,
|
|
2718
|
+
): Promise<void> {
|
|
2719
|
+
const sessionId = this.state?.sessionId
|
|
2720
|
+
if (questionRequest.sessionID !== sessionId) {
|
|
2721
|
+
logger.log(
|
|
2722
|
+
`[QUESTION IGNORED] Question for different session (expected: ${sessionId}, got: ${questionRequest.sessionID})`,
|
|
2723
|
+
)
|
|
2724
|
+
return
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
logger.log(
|
|
2728
|
+
`Question requested: id=${questionRequest.id}, questions=${questionRequest.questions.length}`,
|
|
2729
|
+
)
|
|
2730
|
+
|
|
2731
|
+
const shown = await this.tryShowPendingQuestion()
|
|
2732
|
+
if (!shown) {
|
|
2733
|
+
this.deferredQuestionShow.trigger()
|
|
2734
|
+
}
|
|
2667
2735
|
}
|
|
2668
2736
|
|
|
2669
2737
|
private handleQuestionReplied(properties: { sessionID: string }): void {
|
|
@@ -2671,6 +2739,7 @@ export class ThreadSessionRuntime {
|
|
|
2671
2739
|
if (properties.sessionID !== sessionId) {
|
|
2672
2740
|
return
|
|
2673
2741
|
}
|
|
2742
|
+
this.deferredQuestionShow.clear()
|
|
2674
2743
|
this.onInteractiveUiStateChanged()
|
|
2675
2744
|
|
|
2676
2745
|
// When a question is answered and the local queue has items, the model may
|
|
@@ -2830,61 +2899,33 @@ export class ThreadSessionRuntime {
|
|
|
2830
2899
|
if (info.id !== this.state?.sessionId) {
|
|
2831
2900
|
return
|
|
2832
2901
|
}
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
new Error('Failed to read persisted thread rename state', { cause: e }),
|
|
2837
|
-
)
|
|
2838
|
-
if (persistedName instanceof Error) {
|
|
2839
|
-
logger.warn(`[TITLE] ${persistedName.message} for thread ${this.threadId}`)
|
|
2840
|
-
return
|
|
2841
|
-
}
|
|
2842
|
-
this.lastSyncedThreadName = persistedName
|
|
2902
|
+
const normalizedTitle = info.title.trim()
|
|
2903
|
+
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
2904
|
+
return
|
|
2843
2905
|
}
|
|
2844
|
-
|
|
2845
|
-
const renameDecision = deriveThreadRenameFromSessionUpdate({
|
|
2906
|
+
const desiredName = deriveThreadNameFromSessionTitle({
|
|
2846
2907
|
sessionTitle: info.title,
|
|
2847
2908
|
currentName: this.thread.name,
|
|
2848
|
-
lastSyncedName: this.lastSyncedThreadName,
|
|
2849
2909
|
})
|
|
2850
|
-
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
) {
|
|
2855
|
-
await this.persistLastSyncedThreadName(renameDecision.nextSyncedName)
|
|
2856
|
-
}
|
|
2857
|
-
return
|
|
2858
|
-
}
|
|
2859
|
-
const { desiredName } = renameDecision
|
|
2860
|
-
const normalizedTitle = info.title.trim()
|
|
2861
|
-
if (this.appliedOpencodeTitle === normalizedTitle) {
|
|
2910
|
+
// Mark before setName so concurrent session.updated events don't stack
|
|
2911
|
+
// renames. Keep the mark on failure — retry is almost always a rate limit.
|
|
2912
|
+
this.appliedOpencodeTitle = normalizedTitle
|
|
2913
|
+
if (!desiredName) {
|
|
2862
2914
|
return
|
|
2863
2915
|
}
|
|
2864
|
-
// Mark before the call so concurrent session.updated events don't stack
|
|
2865
|
-
// rename attempts. On failure we keep the mark — a retry won't help
|
|
2866
|
-
// because the failure is almost always a rate limit.
|
|
2867
|
-
this.appliedOpencodeTitle = normalizedTitle
|
|
2868
2916
|
|
|
2869
|
-
const
|
|
2870
|
-
|
|
2871
|
-
const renameResult = await Promise.race([
|
|
2872
|
-
this.thread.setName(desiredName)
|
|
2917
|
+
const renameResult = await raceDiscordRename({
|
|
2918
|
+
rename: this.thread.setName(desiredName)
|
|
2873
2919
|
.catch((e) =>
|
|
2874
2920
|
new Error('Failed to rename thread from OpenCode title', {
|
|
2875
2921
|
cause: e,
|
|
2876
2922
|
}),
|
|
2877
2923
|
),
|
|
2878
|
-
|
|
2879
|
-
timeoutSignal.addEventListener('abort', () => {
|
|
2880
|
-
resolve('timeout')
|
|
2881
|
-
})
|
|
2882
|
-
}),
|
|
2883
|
-
])
|
|
2924
|
+
})
|
|
2884
2925
|
|
|
2885
2926
|
if (renameResult === 'timeout') {
|
|
2886
2927
|
logger.warn(
|
|
2887
|
-
`[TITLE] setName timed out after ${
|
|
2928
|
+
`[TITLE] setName timed out after ${DISCORD_THREAD_RENAME_TIMEOUT_MS}ms for thread ${this.threadId} (likely rate-limited)`,
|
|
2888
2929
|
)
|
|
2889
2930
|
return
|
|
2890
2931
|
}
|
|
@@ -2894,38 +2935,11 @@ export class ThreadSessionRuntime {
|
|
|
2894
2935
|
)
|
|
2895
2936
|
return
|
|
2896
2937
|
}
|
|
2897
|
-
await this.persistLastSyncedThreadName(desiredName)
|
|
2898
2938
|
logger.log(
|
|
2899
2939
|
`[TITLE] Renamed thread ${this.threadId} to "${desiredName}" from OpenCode session title`,
|
|
2900
2940
|
)
|
|
2901
2941
|
}
|
|
2902
2942
|
|
|
2903
|
-
private async loadLastSyncedThreadName() {
|
|
2904
|
-
const db = await getDb()
|
|
2905
|
-
const row = await db.query.thread_sessions.findFirst({
|
|
2906
|
-
where: { thread_id: this.threadId },
|
|
2907
|
-
columns: { last_synced_name: true },
|
|
2908
|
-
})
|
|
2909
|
-
return row?.last_synced_name ?? null
|
|
2910
|
-
}
|
|
2911
|
-
|
|
2912
|
-
private async persistLastSyncedThreadName(name: string): Promise<void> {
|
|
2913
|
-
this.lastSyncedThreadName = name
|
|
2914
|
-
const db = await getDb()
|
|
2915
|
-
const result = await db.update(schema.thread_sessions)
|
|
2916
|
-
.set({ last_synced_name: name })
|
|
2917
|
-
.where(orm.eq(schema.thread_sessions.thread_id, this.threadId))
|
|
2918
|
-
.catch(
|
|
2919
|
-
(e) =>
|
|
2920
|
-
new Error('Failed to persist thread rename state', {
|
|
2921
|
-
cause: e,
|
|
2922
|
-
}),
|
|
2923
|
-
)
|
|
2924
|
-
if (result instanceof Error) {
|
|
2925
|
-
logger.warn(`[TITLE] ${result.message} for thread ${this.threadId}`)
|
|
2926
|
-
}
|
|
2927
|
-
}
|
|
2928
|
-
|
|
2929
2943
|
private async handleTuiToast(properties: {
|
|
2930
2944
|
title?: string
|
|
2931
2945
|
message: string
|
|
@@ -2965,6 +2979,7 @@ export class ThreadSessionRuntime {
|
|
|
2965
2979
|
* fields that the local-queue path provides.
|
|
2966
2980
|
*/
|
|
2967
2981
|
private async submitViaOpencodeQueue(input: IngressInput): Promise<EnqueueResult> {
|
|
2982
|
+
await this.supersedePendingSleep(input)
|
|
2968
2983
|
let skippedBySessionGuard = false
|
|
2969
2984
|
|
|
2970
2985
|
await this.dispatchAction(async () => {
|
|
@@ -3039,6 +3054,18 @@ export class ThreadSessionRuntime {
|
|
|
3039
3054
|
await clearSessionModel(session.id)
|
|
3040
3055
|
}
|
|
3041
3056
|
|
|
3057
|
+
if (input.model) {
|
|
3058
|
+
const validatedModel = await validateModelId({
|
|
3059
|
+
model: input.model,
|
|
3060
|
+
getClient,
|
|
3061
|
+
directory: this.sdkDirectory,
|
|
3062
|
+
})
|
|
3063
|
+
if (validatedModel instanceof Error) {
|
|
3064
|
+
await cleanupOnError(`Failed to resolve model: ${validatedModel.message}`)
|
|
3065
|
+
return
|
|
3066
|
+
}
|
|
3067
|
+
}
|
|
3068
|
+
|
|
3042
3069
|
await ensureSessionPreferencesSnapshot({
|
|
3043
3070
|
sessionId: session.id,
|
|
3044
3071
|
channelId,
|
|
@@ -3067,11 +3094,11 @@ export class ThreadSessionRuntime {
|
|
|
3067
3094
|
const [modelResult, preferredVariant] = await Promise.all([
|
|
3068
3095
|
(async () => {
|
|
3069
3096
|
if (input.model) {
|
|
3070
|
-
|
|
3071
|
-
|
|
3072
|
-
|
|
3073
|
-
|
|
3074
|
-
}
|
|
3097
|
+
return validateModelId({
|
|
3098
|
+
model: input.model,
|
|
3099
|
+
getClient,
|
|
3100
|
+
directory: this.sdkDirectory,
|
|
3101
|
+
})
|
|
3075
3102
|
}
|
|
3076
3103
|
const modelInfo = await getCurrentModelInfo({
|
|
3077
3104
|
sessionId: session.id,
|
|
@@ -3186,6 +3213,7 @@ export class ThreadSessionRuntime {
|
|
|
3186
3213
|
userId: input.userId,
|
|
3187
3214
|
sourceMessageId: input.sourceMessageId,
|
|
3188
3215
|
sourceThreadId: input.sourceThreadId,
|
|
3216
|
+
threadName: this.thread.name || undefined,
|
|
3189
3217
|
repliedMessage: input.repliedMessage,
|
|
3190
3218
|
worktree,
|
|
3191
3219
|
currentAgent: resolvedAgent,
|
|
@@ -3269,7 +3297,28 @@ export class ThreadSessionRuntime {
|
|
|
3269
3297
|
* Enqueue in kimaki's local per-thread queue.
|
|
3270
3298
|
* Used for explicit queue workflows (/queue, queueMessage=true).
|
|
3271
3299
|
*/
|
|
3300
|
+
/**
|
|
3301
|
+
* A new turn supersedes a pending sleep.
|
|
3302
|
+
*
|
|
3303
|
+
* Called from the two terminal routers rather than from the top of
|
|
3304
|
+
* enqueueIncoming: arrival order is only fixed once a message reaches the
|
|
3305
|
+
* preprocessChain link, so awaiting anything before that lets two rapid
|
|
3306
|
+
* messages swap places. By here the order is already committed.
|
|
3307
|
+
*
|
|
3308
|
+
* Awaited rather than fire-and-forget so it cannot race the task runner and
|
|
3309
|
+
* let a stale wake land after the user took the conversation back.
|
|
3310
|
+
*/
|
|
3311
|
+
private async supersedePendingSleep(input: IngressInput): Promise<void> {
|
|
3312
|
+
if (input.isSleepWake) return
|
|
3313
|
+
await cancelSessionSleepForThread({ threadId: this.threadId }).catch(
|
|
3314
|
+
(error) => {
|
|
3315
|
+
logger.error('[SLEEP] failed to cancel pending sleep:', error)
|
|
3316
|
+
},
|
|
3317
|
+
)
|
|
3318
|
+
}
|
|
3319
|
+
|
|
3272
3320
|
private async enqueueViaLocalQueue(input: IngressInput): Promise<EnqueueResult> {
|
|
3321
|
+
await this.supersedePendingSleep(input)
|
|
3273
3322
|
const queueId = crypto.randomBytes(8).toString('hex')
|
|
3274
3323
|
const queuedMessage: QueuedMessage = {
|
|
3275
3324
|
queueId,
|
|
@@ -3540,6 +3589,11 @@ export class ThreadSessionRuntime {
|
|
|
3540
3589
|
)
|
|
3541
3590
|
|
|
3542
3591
|
this.stopTyping()
|
|
3592
|
+
this.deferredQuestionShow.clear()
|
|
3593
|
+
|
|
3594
|
+
// The aborted run owns the question request, so the dropdown dies with it.
|
|
3595
|
+
// Questions have no TTL, so this is the only thing that clears them here.
|
|
3596
|
+
void cancelPendingQuestion(this.threadId)
|
|
3543
3597
|
|
|
3544
3598
|
const apiAbortPromise = sessionId
|
|
3545
3599
|
? this.abortSessionViaApi({ abortId, reason, sessionId })
|
|
@@ -3613,14 +3667,6 @@ export class ThreadSessionRuntime {
|
|
|
3613
3667
|
return this.state?.queueItems.length ?? 0
|
|
3614
3668
|
}
|
|
3615
3669
|
|
|
3616
|
-
/** NOTIFY_MESSAGE_FLAGS unless queue has a next item, then SILENT.
|
|
3617
|
-
* Permissions should NOT use this — they always notify. */
|
|
3618
|
-
private getNotifyFlags(): number {
|
|
3619
|
-
return this.getQueueLength() > 0
|
|
3620
|
-
? SILENT_MESSAGE_FLAGS
|
|
3621
|
-
: NOTIFY_MESSAGE_FLAGS
|
|
3622
|
-
}
|
|
3623
|
-
|
|
3624
3670
|
/** Clear all queued messages. Returns the removed items. */
|
|
3625
3671
|
clearQueue(): threadState.QueuedMessage[] {
|
|
3626
3672
|
return threadState.clearQueueItems(this.threadId)
|
|
@@ -3809,6 +3855,24 @@ export class ThreadSessionRuntime {
|
|
|
3809
3855
|
await clearSessionModel(session.id)
|
|
3810
3856
|
}
|
|
3811
3857
|
|
|
3858
|
+
if (input.model) {
|
|
3859
|
+
const validatedModel = await validateModelId({
|
|
3860
|
+
model: input.model,
|
|
3861
|
+
getClient,
|
|
3862
|
+
directory: this.sdkDirectory,
|
|
3863
|
+
})
|
|
3864
|
+
if (validatedModel instanceof Error) {
|
|
3865
|
+
this.stopTyping()
|
|
3866
|
+
await sendThreadMessage(
|
|
3867
|
+
this.thread,
|
|
3868
|
+
`Failed to resolve model: ${validatedModel.message}`,
|
|
3869
|
+
{ flags: NOTIFY_MESSAGE_FLAGS },
|
|
3870
|
+
)
|
|
3871
|
+
await this.tryDrainQueue({ showIndicator: true })
|
|
3872
|
+
return
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3875
|
+
|
|
3812
3876
|
await ensureSessionPreferencesSnapshot({
|
|
3813
3877
|
sessionId: session.id,
|
|
3814
3878
|
channelId,
|
|
@@ -3844,11 +3908,11 @@ export class ThreadSessionRuntime {
|
|
|
3844
3908
|
const [earlyModelResult, preferredVariant] = await Promise.all([
|
|
3845
3909
|
(async () => {
|
|
3846
3910
|
if (input.model) {
|
|
3847
|
-
|
|
3848
|
-
|
|
3849
|
-
|
|
3850
|
-
|
|
3851
|
-
}
|
|
3911
|
+
return validateModelId({
|
|
3912
|
+
model: input.model,
|
|
3913
|
+
getClient,
|
|
3914
|
+
directory: this.sdkDirectory,
|
|
3915
|
+
})
|
|
3852
3916
|
}
|
|
3853
3917
|
const modelInfo = await getCurrentModelInfo({
|
|
3854
3918
|
sessionId: session.id,
|
|
@@ -3975,6 +4039,7 @@ export class ThreadSessionRuntime {
|
|
|
3975
4039
|
userId: input.userId,
|
|
3976
4040
|
sourceMessageId: input.sourceMessageId,
|
|
3977
4041
|
sourceThreadId: input.sourceThreadId,
|
|
4042
|
+
threadName: this.thread.name || undefined,
|
|
3978
4043
|
repliedMessage: input.repliedMessage,
|
|
3979
4044
|
worktree,
|
|
3980
4045
|
currentAgent: earlyAgentPreference,
|
|
@@ -4025,6 +4090,7 @@ export class ThreadSessionRuntime {
|
|
|
4025
4090
|
userId: input.userId,
|
|
4026
4091
|
sourceMessageId: input.sourceMessageId,
|
|
4027
4092
|
sourceThreadId: input.sourceThreadId,
|
|
4093
|
+
threadName: this.thread.name || undefined,
|
|
4028
4094
|
repliedMessage: input.repliedMessage,
|
|
4029
4095
|
})
|
|
4030
4096
|
// OpenCode's session.command API has no `system` field. Persist the
|
|
@@ -4477,7 +4543,6 @@ export class ThreadSessionRuntime {
|
|
|
4477
4543
|
elapsedMs < 1000
|
|
4478
4544
|
? '<1s'
|
|
4479
4545
|
: prettyMilliseconds(elapsedMs, { secondsDecimalDigits: 0 })
|
|
4480
|
-
const modelInfo = runInfo.model ? ` ⋅ ${runInfo.model}` : ''
|
|
4481
4546
|
const agentInfo =
|
|
4482
4547
|
runInfo.agent && runInfo.agent.toLowerCase() !== 'build'
|
|
4483
4548
|
? ` ⋅ **${runInfo.agent}**`
|
|
@@ -4494,7 +4559,7 @@ export class ThreadSessionRuntime {
|
|
|
4494
4559
|
}).catch((e) => new FilesystemOperationError({ operation: 'gitBranch', cause: e })),
|
|
4495
4560
|
(async () => {
|
|
4496
4561
|
if (!client || !sessionId) {
|
|
4497
|
-
return
|
|
4562
|
+
return []
|
|
4498
4563
|
}
|
|
4499
4564
|
let tokensUsed = runInfo.tokensUsed
|
|
4500
4565
|
// Fetch final token count from API
|
|
@@ -4534,9 +4599,12 @@ export class ThreadSessionRuntime {
|
|
|
4534
4599
|
})
|
|
4535
4600
|
: undefined
|
|
4536
4601
|
|
|
4602
|
+
const providers = providersResult && !(providersResult instanceof Error)
|
|
4603
|
+
? providersResult.data?.all ?? []
|
|
4604
|
+
: []
|
|
4537
4605
|
let contextLimit = fallbackLimit
|
|
4538
|
-
if (
|
|
4539
|
-
const provider =
|
|
4606
|
+
if (providers.length > 0) {
|
|
4607
|
+
const provider = providers.find((p) => {
|
|
4540
4608
|
return p.id === runInfo.providerID
|
|
4541
4609
|
})
|
|
4542
4610
|
const model = provider?.models?.[runInfo.model || '']
|
|
@@ -4549,6 +4617,7 @@ export class ThreadSessionRuntime {
|
|
|
4549
4617
|
)
|
|
4550
4618
|
contextInfo = ` ⋅ ${percentage}%`
|
|
4551
4619
|
}
|
|
4620
|
+
return providers
|
|
4552
4621
|
})().catch((e) => new OpenCodeSdkError({ operation: 'resolveModelPreference', cause: e })),
|
|
4553
4622
|
])
|
|
4554
4623
|
const branchName =
|
|
@@ -4559,6 +4628,18 @@ export class ThreadSessionRuntime {
|
|
|
4559
4628
|
contextResult,
|
|
4560
4629
|
)
|
|
4561
4630
|
}
|
|
4631
|
+
const providers = contextResult instanceof Error ? [] : (contextResult ?? [])
|
|
4632
|
+
const modelLabel = runInfo.model
|
|
4633
|
+
? displayedModelLabel({
|
|
4634
|
+
modelID: runInfo.model,
|
|
4635
|
+
name: getProviderModelName({
|
|
4636
|
+
providers,
|
|
4637
|
+
providerID: runInfo.providerID,
|
|
4638
|
+
modelID: runInfo.model,
|
|
4639
|
+
}),
|
|
4640
|
+
})
|
|
4641
|
+
: undefined
|
|
4642
|
+
const modelInfo = modelLabel ? ` ⋅ ${modelLabel}` : ''
|
|
4562
4643
|
|
|
4563
4644
|
const truncate = (s: string, max: number) => {
|
|
4564
4645
|
return s.length > max ? s.slice(0, max - 1) + '\u2026' : s
|
|
@@ -4568,13 +4649,17 @@ export class ThreadSessionRuntime {
|
|
|
4568
4649
|
const projectInfo = truncatedBranch
|
|
4569
4650
|
? `${truncatedFolder} ⋅ ${truncatedBranch} ⋅ `
|
|
4570
4651
|
: `${truncatedFolder} ⋅ `
|
|
4571
|
-
const
|
|
4652
|
+
const hasQueuedMessage = this.getQueueLength() > 0
|
|
4653
|
+
const mention = store.getState().footerMentionsEnabled
|
|
4654
|
+
&& !hasQueuedMessage
|
|
4655
|
+
&& this.state?.sessionUserId
|
|
4656
|
+
? ` <@${this.state.sessionUserId}>`
|
|
4657
|
+
: ''
|
|
4658
|
+
const footerText = `*${projectInfo}${sessionDuration}${contextInfo}${modelInfo}${agentInfo}*${mention}`
|
|
4572
4659
|
this.stopTyping()
|
|
4573
4660
|
|
|
4574
|
-
// Skip notification if there's a queued message next — the user only
|
|
4575
|
-
// needs to be notified when the entire queue finishes.
|
|
4576
4661
|
await sendThreadMessage(this.thread, footerText, {
|
|
4577
|
-
flags:
|
|
4662
|
+
flags: hasQueuedMessage ? SILENT_MESSAGE_FLAGS : NOTIFY_MESSAGE_FLAGS,
|
|
4578
4663
|
})
|
|
4579
4664
|
logger.log(
|
|
4580
4665
|
`DURATION: Session completed in ${sessionDuration}, model ${runInfo.model}, tokens ${runInfo.tokensUsed}`,
|