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
|
@@ -2,25 +2,26 @@
|
|
|
2
2
|
// Uses OpenCode SDK v2 to create worktrees with kimaki- prefix
|
|
3
3
|
// Creates thread immediately, then worktree in background so user can type
|
|
4
4
|
import { ChannelType, REST, } from 'discord.js';
|
|
5
|
+
import crypto from 'node:crypto';
|
|
5
6
|
import fs from 'node:fs';
|
|
6
7
|
import { OpenCodeSdkError } from '../errors.js';
|
|
7
|
-
import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession,
|
|
8
|
+
import { createPendingWorkspace, setWorkspaceReady, setWorkspaceError, getChannelDirectory, getThreadSession, setThreadSession, } from '../database.js';
|
|
8
9
|
import { SILENT_MESSAGE_FLAGS, reactToThread, resolveProjectDirectoryFromAutocomplete, resolveTextChannel, sendThreadMessage, } from '../discord-utils.js';
|
|
9
10
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
11
|
import { notifyError } from '../sentry.js';
|
|
11
|
-
import { execAsync, listBranchesByLastCommit, resolveBestBaseRef, validateBranchRef, } from '../worktrees.js';
|
|
12
|
+
import { execAsync, getManagedWorktreeDirectory, listBranchesByLastCommit, resolveBestBaseRef, validateBranchRef, } from '../worktrees.js';
|
|
13
|
+
import { KIMAKI_WORKTREE_ADAPTER_TYPE, removeWorktreeFromOwnRepository, resolveGitCommit, validateWorktreeIdentity, } from '../git-worktree-core.js';
|
|
12
14
|
import { getOrCreateRuntime } from '../session-handler/thread-session-runtime.js';
|
|
13
15
|
import { buildSessionPermissions, initializeOpencodeForDirectory, } from '../opencode.js';
|
|
14
16
|
import { WORKTREE_PREFIX } from './merge-worktree.js';
|
|
15
17
|
import * as errore from 'errore';
|
|
16
18
|
import { copyCurrentSessionModel } from './model.js';
|
|
17
19
|
const logger = createLogger(LogPrefix.WORKTREE);
|
|
18
|
-
const DEFAULT_WORKTREE_BASE_REF = 'HEAD';
|
|
19
20
|
async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch, }) {
|
|
20
21
|
if (!rawBaseBranch) {
|
|
21
22
|
// Default to the current local HEAD so worktrees can branch from
|
|
22
23
|
// unpublished commits in the main checkout.
|
|
23
|
-
return
|
|
24
|
+
return resolveGitCommit({ directory: projectDirectory, ref: 'HEAD' });
|
|
24
25
|
}
|
|
25
26
|
const validated = await validateBranchRef({
|
|
26
27
|
directory: projectDirectory,
|
|
@@ -37,7 +38,7 @@ async function resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch
|
|
|
37
38
|
if (bestRef !== validated) {
|
|
38
39
|
logger.log(`Base branch resolved: ${validated} → ${bestRef} (remote is ahead)`);
|
|
39
40
|
}
|
|
40
|
-
return bestRef;
|
|
41
|
+
return resolveGitCommit({ directory: projectDirectory, ref: bestRef });
|
|
41
42
|
}
|
|
42
43
|
/** Status message shown while a worktree is being created. */
|
|
43
44
|
export function worktreeCreatingMessage(worktreeName) {
|
|
@@ -153,28 +154,83 @@ async function getProjectDirectoryFromChannel(channel) {
|
|
|
153
154
|
/**
|
|
154
155
|
* Try creating a worktree via the OpenCode workspace SDK.
|
|
155
156
|
* Returns the workspace directory on success, or an Error if the workspace
|
|
156
|
-
* feature is not available or the creation fails.
|
|
157
|
-
* direct git path on Error.
|
|
157
|
+
* feature is not available or the creation fails.
|
|
158
158
|
*/
|
|
159
|
-
async function tryWorkspaceCreate({
|
|
159
|
+
export async function tryWorkspaceCreate({ worktreeName, projectDirectory, baseCommit, }) {
|
|
160
160
|
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
161
161
|
if (getClient instanceof Error)
|
|
162
162
|
return getClient;
|
|
163
163
|
const client = getClient();
|
|
164
|
+
const workspaceId = `wrk_${crypto.randomUUID()}`;
|
|
165
|
+
const managedDirectory = getManagedWorktreeDirectory({
|
|
166
|
+
directory: projectDirectory,
|
|
167
|
+
name: worktreeName,
|
|
168
|
+
});
|
|
169
|
+
const cleanupFailedWorkspace = async (worktreeDirectory) => {
|
|
170
|
+
const removeResponse = await client.experimental.workspace.remove({
|
|
171
|
+
id: workspaceId,
|
|
172
|
+
directory: projectDirectory,
|
|
173
|
+
}).catch((e) => new OpenCodeSdkError({ operation: 'workspace.remove', cause: e }));
|
|
174
|
+
const sdkCleanupError = (() => {
|
|
175
|
+
if (removeResponse instanceof Error)
|
|
176
|
+
return removeResponse;
|
|
177
|
+
if (removeResponse.error) {
|
|
178
|
+
return new Error(`Workspace cleanup failed: ${JSON.stringify(removeResponse.error)}`);
|
|
179
|
+
}
|
|
180
|
+
return undefined;
|
|
181
|
+
})();
|
|
182
|
+
const gitCleanupError = fs.existsSync(worktreeDirectory)
|
|
183
|
+
? await removeWorktreeFromOwnRepository({
|
|
184
|
+
worktreeDirectory,
|
|
185
|
+
branchName: worktreeName,
|
|
186
|
+
})
|
|
187
|
+
: undefined;
|
|
188
|
+
return sdkCleanupError ?? gitCleanupError;
|
|
189
|
+
};
|
|
164
190
|
const response = await client.experimental.workspace.create({
|
|
191
|
+
id: workspaceId,
|
|
165
192
|
directory: projectDirectory,
|
|
166
|
-
type:
|
|
193
|
+
type: KIMAKI_WORKTREE_ADAPTER_TYPE,
|
|
167
194
|
branch: worktreeName,
|
|
168
|
-
extra:
|
|
195
|
+
extra: {
|
|
196
|
+
projectDirectory,
|
|
197
|
+
baseCommit,
|
|
198
|
+
},
|
|
169
199
|
}).catch((e) => new OpenCodeSdkError({ operation: 'workspace.create', cause: e }));
|
|
170
|
-
if (response instanceof Error)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
200
|
+
if (response instanceof Error || response.error) {
|
|
201
|
+
const creationError = response instanceof Error
|
|
202
|
+
? response
|
|
203
|
+
: new Error(`Workspace creation failed: ${JSON.stringify(response.error)}`);
|
|
204
|
+
const cleanupError = await cleanupFailedWorkspace(managedDirectory);
|
|
205
|
+
if (cleanupError instanceof Error) {
|
|
206
|
+
return new Error(`${creationError.message}; cleanup failed: ${cleanupError.message}`, {
|
|
207
|
+
cause: creationError,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return creationError;
|
|
174
211
|
}
|
|
175
212
|
const workspace = response.data;
|
|
176
213
|
if (!workspace?.directory || !workspace.id) {
|
|
177
|
-
|
|
214
|
+
const creationError = new Error('Workspace SDK returned no directory or ID');
|
|
215
|
+
const cleanupError = await cleanupFailedWorkspace(managedDirectory);
|
|
216
|
+
if (cleanupError instanceof Error) {
|
|
217
|
+
return new Error(`${creationError.message}; cleanup failed: ${cleanupError.message}`, {
|
|
218
|
+
cause: creationError,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
return creationError;
|
|
222
|
+
}
|
|
223
|
+
const identityResult = await validateWorktreeIdentity({
|
|
224
|
+
projectDirectory,
|
|
225
|
+
worktreeDirectory: workspace.directory,
|
|
226
|
+
baseCommit,
|
|
227
|
+
});
|
|
228
|
+
if (identityResult instanceof Error) {
|
|
229
|
+
const cleanupError = await cleanupFailedWorkspace(workspace.directory);
|
|
230
|
+
if (cleanupError instanceof Error) {
|
|
231
|
+
return new Error(`${identityResult.message}; cleanup failed: ${cleanupError.message}`, { cause: identityResult });
|
|
232
|
+
}
|
|
233
|
+
return identityResult;
|
|
178
234
|
}
|
|
179
235
|
return { directory: workspace.directory, workspaceId: workspace.id };
|
|
180
236
|
}
|
|
@@ -188,13 +244,13 @@ async function tryWorkspaceCreate({ threadId, worktreeName, projectDirectory, ba
|
|
|
188
244
|
* starterMessage is optional — if omitted, status edits are skipped (creation
|
|
189
245
|
* still proceeds). This keeps worktree creation independent of Discord message
|
|
190
246
|
* delivery, so a transient send failure never silently skips the worktree.
|
|
247
|
+
* beforeReady can bind a session before incoming messages may use the checkout.
|
|
191
248
|
*
|
|
192
249
|
* Returns the worktree directory on success, or an Error on failure.
|
|
193
250
|
* Never throws — all internal errors are caught and returned as Error values.
|
|
194
251
|
*/
|
|
195
|
-
export async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory,
|
|
252
|
+
export async function createWorktreeInBackground({ thread, starterMessage, worktreeName, projectDirectory, baseCommit, rest, beforeReady, }) {
|
|
196
253
|
return (async () => {
|
|
197
|
-
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory}${baseBranch ? ` from ${baseBranch}` : ''}`);
|
|
198
254
|
// Serialize status message edits so onProgress can't overwrite the
|
|
199
255
|
// final success/error edit even if Discord's API is slow.
|
|
200
256
|
let editChain = Promise.resolve();
|
|
@@ -213,11 +269,23 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
213
269
|
workspaceName: worktreeName,
|
|
214
270
|
projectDirectory,
|
|
215
271
|
});
|
|
272
|
+
const resolvedBaseCommit = baseCommit
|
|
273
|
+
? baseCommit
|
|
274
|
+
: await resolveGitCommit({ directory: projectDirectory, ref: 'HEAD' });
|
|
275
|
+
if (resolvedBaseCommit instanceof Error) {
|
|
276
|
+
await setWorkspaceError({
|
|
277
|
+
threadId: thread.id,
|
|
278
|
+
errorMessage: resolvedBaseCommit.message,
|
|
279
|
+
});
|
|
280
|
+
editStatus(`🌳 **Worktree: ${worktreeName}**\n❌ ${resolvedBaseCommit.message}`);
|
|
281
|
+
await editChain;
|
|
282
|
+
return resolvedBaseCommit;
|
|
283
|
+
}
|
|
284
|
+
logger.log(`Creating worktree "${worktreeName}" for project ${projectDirectory} from ${resolvedBaseCommit}`);
|
|
216
285
|
const workspaceResult = await tryWorkspaceCreate({
|
|
217
|
-
threadId: thread.id,
|
|
218
286
|
worktreeName,
|
|
219
287
|
projectDirectory,
|
|
220
|
-
|
|
288
|
+
baseCommit: resolvedBaseCommit,
|
|
221
289
|
});
|
|
222
290
|
if (workspaceResult instanceof Error) {
|
|
223
291
|
const errorMsg = workspaceResult.message;
|
|
@@ -227,6 +295,14 @@ export async function createWorktreeInBackground({ thread, starterMessage, workt
|
|
|
227
295
|
await editChain;
|
|
228
296
|
return workspaceResult;
|
|
229
297
|
}
|
|
298
|
+
const preparationResult = beforeReady
|
|
299
|
+
? await beforeReady(workspaceResult).catch((e) => new WorktreeError('Failed to prepare worktree session', { cause: e }))
|
|
300
|
+
: undefined;
|
|
301
|
+
if (preparationResult instanceof Error) {
|
|
302
|
+
logger.error('[WORKTREE] Session preparation failed:', preparationResult);
|
|
303
|
+
void notifyError(preparationResult, 'Worktree session preparation failed');
|
|
304
|
+
await sendThreadMessage(thread, `Worktree is ready, but failed to prepare its session: ${preparationResult.message}`);
|
|
305
|
+
}
|
|
230
306
|
await setWorkspaceReady({
|
|
231
307
|
threadId: thread.id,
|
|
232
308
|
workspaceId: workspaceResult.workspaceId,
|
|
@@ -308,11 +384,11 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
|
308
384
|
return;
|
|
309
385
|
}
|
|
310
386
|
// Parallelize: base branch validation and existing worktree check are independent
|
|
311
|
-
const [
|
|
387
|
+
const [baseCommit, existingWorktree] = await Promise.all([
|
|
312
388
|
resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch }),
|
|
313
389
|
findExistingWorktreePath({ projectDirectory, worktreeName }),
|
|
314
390
|
]);
|
|
315
|
-
if (
|
|
391
|
+
if (baseCommit instanceof Error) {
|
|
316
392
|
await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``);
|
|
317
393
|
return;
|
|
318
394
|
}
|
|
@@ -354,7 +430,7 @@ export async function handleNewWorktreeCommand({ command, appId, }) {
|
|
|
354
430
|
starterMessage,
|
|
355
431
|
worktreeName,
|
|
356
432
|
projectDirectory,
|
|
357
|
-
|
|
433
|
+
baseCommit,
|
|
358
434
|
rest: command.client.rest,
|
|
359
435
|
}).catch((e) => {
|
|
360
436
|
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
@@ -391,12 +467,12 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
|
|
|
391
467
|
// Parallelize: base branch validation, existing worktree check, and parent channel
|
|
392
468
|
// resolve are all independent. resolveTextChannel fetches the parent from Discord
|
|
393
469
|
// cache/API which can overlap with the git operations.
|
|
394
|
-
const [
|
|
470
|
+
const [baseCommit, existingWorktreePath, textChannel] = await Promise.all([
|
|
395
471
|
resolveRequestedWorktreeBaseRef({ projectDirectory, rawBaseBranch }),
|
|
396
472
|
findExistingWorktreePath({ projectDirectory, worktreeName }),
|
|
397
473
|
resolveTextChannel(thread),
|
|
398
474
|
]);
|
|
399
|
-
if (
|
|
475
|
+
if (baseCommit instanceof Error) {
|
|
400
476
|
await command.editReply(`Invalid base branch: \`${rawBaseBranch}\``);
|
|
401
477
|
return;
|
|
402
478
|
}
|
|
@@ -440,86 +516,78 @@ async function handleWorktreeInThread({ command, thread, appId, }) {
|
|
|
440
516
|
starterMessage: statusMessage,
|
|
441
517
|
worktreeName,
|
|
442
518
|
projectDirectory,
|
|
443
|
-
|
|
519
|
+
baseCommit,
|
|
444
520
|
rest: command.client.rest,
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
await
|
|
452
|
-
return;
|
|
453
|
-
}
|
|
454
|
-
const workspace = await getThreadWorktreeOrWorkspace(worktreeThread.id);
|
|
455
|
-
if (!workspace?.workspace_id) {
|
|
456
|
-
await sendThreadMessage(worktreeThread, '✗ Worktree is ready, but OpenCode returned no workspace ID for context reuse.');
|
|
457
|
-
return;
|
|
458
|
-
}
|
|
459
|
-
const getClient = await initializeOpencodeForDirectory(result, {
|
|
460
|
-
originalRepoDirectory: projectDirectory,
|
|
461
|
-
channelId: parent.id,
|
|
462
|
-
});
|
|
463
|
-
if (getClient instanceof Error) {
|
|
464
|
-
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to initialize OpenCode for context reuse: ${getClient.message}`);
|
|
465
|
-
return;
|
|
466
|
-
}
|
|
467
|
-
const forkResponse = await getClient().session.fork({
|
|
468
|
-
sessionID: sourceSessionId,
|
|
469
|
-
directory: result,
|
|
470
|
-
workspace: workspace.workspace_id,
|
|
471
|
-
}).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }));
|
|
472
|
-
if (forkResponse instanceof Error) {
|
|
473
|
-
logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
|
|
474
|
-
void notifyError(forkResponse, 'Failed to fork session into worktree');
|
|
475
|
-
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${forkResponse.message}`);
|
|
476
|
-
return;
|
|
477
|
-
}
|
|
478
|
-
const forkedSession = forkResponse.data;
|
|
479
|
-
if (!forkedSession) {
|
|
480
|
-
const error = new Error('OpenCode did not return a forked session');
|
|
481
|
-
logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', error);
|
|
482
|
-
void notifyError(error, 'Failed to fork session into worktree');
|
|
483
|
-
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
|
|
484
|
-
return;
|
|
485
|
-
}
|
|
486
|
-
await copyCurrentSessionModel({
|
|
487
|
-
sourceSessionId,
|
|
488
|
-
targetSessionId: forkedSession.id,
|
|
489
|
-
channelId: parent.id,
|
|
490
|
-
appId,
|
|
491
|
-
getClient,
|
|
492
|
-
directory: projectDirectory,
|
|
493
|
-
});
|
|
494
|
-
const permissionResponse = await getClient().session.update({
|
|
495
|
-
sessionID: forkedSession.id,
|
|
496
|
-
directory: result,
|
|
497
|
-
permission: buildSessionPermissions({
|
|
498
|
-
directory: result,
|
|
521
|
+
beforeReady: async ({ directory, workspaceId }) => {
|
|
522
|
+
const sourceSessionId = await getThreadSession(thread.id);
|
|
523
|
+
if (!sourceSessionId) {
|
|
524
|
+
await sendThreadMessage(worktreeThread, 'Worktree is ready. Send a message here to start a fresh session in this checkout.');
|
|
525
|
+
return;
|
|
526
|
+
}
|
|
527
|
+
const getClient = await initializeOpencodeForDirectory(directory, {
|
|
499
528
|
originalRepoDirectory: projectDirectory,
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
529
|
+
channelId: parent.id,
|
|
530
|
+
});
|
|
531
|
+
if (getClient instanceof Error) {
|
|
532
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to initialize OpenCode for context reuse: ${getClient.message}`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
const forkResponse = await getClient().session.fork({
|
|
536
|
+
sessionID: sourceSessionId,
|
|
537
|
+
directory,
|
|
538
|
+
workspace: workspaceId,
|
|
539
|
+
}).catch((e) => new OpenCodeSdkError({ operation: 'session.fork', cause: e }));
|
|
540
|
+
if (forkResponse instanceof Error) {
|
|
541
|
+
logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', forkResponse);
|
|
542
|
+
void notifyError(forkResponse, 'Failed to fork session into worktree');
|
|
543
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${forkResponse.message}`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const forkedSession = forkResponse.data;
|
|
547
|
+
if (!forkedSession) {
|
|
548
|
+
const error = new Error('OpenCode did not return a forked session');
|
|
549
|
+
logger.error('[NEW-WORKTREE] Failed to fork session into worktree:', error);
|
|
550
|
+
void notifyError(error, 'Failed to fork session into worktree');
|
|
551
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to reuse session context there: ${error.message}`);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await copyCurrentSessionModel({
|
|
555
|
+
sourceSessionId,
|
|
556
|
+
targetSessionId: forkedSession.id,
|
|
557
|
+
channelId: parent.id,
|
|
558
|
+
appId,
|
|
559
|
+
getClient,
|
|
560
|
+
directory: projectDirectory,
|
|
561
|
+
});
|
|
562
|
+
const permissionResponse = await getClient().session.update({
|
|
563
|
+
sessionID: forkedSession.id,
|
|
564
|
+
directory,
|
|
565
|
+
permission: buildSessionPermissions({
|
|
566
|
+
directory,
|
|
567
|
+
originalRepoDirectory: projectDirectory,
|
|
568
|
+
}),
|
|
569
|
+
}).catch((e) => new OpenCodeSdkError({ operation: 'session.update', cause: e }));
|
|
570
|
+
if (permissionResponse instanceof Error || permissionResponse.error) {
|
|
571
|
+
const error = permissionResponse instanceof Error
|
|
572
|
+
? permissionResponse
|
|
573
|
+
: new Error('OpenCode rejected forked session permission update');
|
|
574
|
+
logger.error('[NEW-WORKTREE] Failed to update forked session permissions:', error);
|
|
575
|
+
void notifyError(error, 'Failed to update forked session permissions');
|
|
576
|
+
await sendThreadMessage(worktreeThread, `✗ Worktree is ready, but failed to update forked session permissions: ${error.message}`);
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
await setThreadSession(worktreeThread.id, forkedSession.id);
|
|
580
|
+
getOrCreateRuntime({
|
|
581
|
+
threadId: worktreeThread.id,
|
|
582
|
+
thread: worktreeThread,
|
|
583
|
+
projectDirectory,
|
|
584
|
+
sdkDirectory: directory,
|
|
585
|
+
channelId: parent.id,
|
|
586
|
+
appId,
|
|
587
|
+
});
|
|
588
|
+
await sendThreadMessage(worktreeThread, `Reusing context from <#${thread.id}> in worktree session \`${forkedSession.id}\`.`);
|
|
589
|
+
},
|
|
590
|
+
}).catch((e) => {
|
|
523
591
|
logger.error('[NEW-WORKTREE] Background error:', e);
|
|
524
592
|
void notifyError(e, 'Background worktree creation failed (in-thread)');
|
|
525
593
|
});
|
|
@@ -153,14 +153,12 @@ export const handleUserCommand = async ({ command, appId, }) => {
|
|
|
153
153
|
rest: command.client.rest,
|
|
154
154
|
});
|
|
155
155
|
}
|
|
156
|
-
const
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return result;
|
|
163
|
-
})();
|
|
156
|
+
const worktreeResult = worktreePromise ? await worktreePromise : projectDirectory;
|
|
157
|
+
if (worktreeResult instanceof Error) {
|
|
158
|
+
await command.editReply(`Worktree creation failed: ${worktreeResult.message}`);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
const sessionDirectory = worktreeResult;
|
|
164
162
|
await command.editReply(`Started /${commandName} in ${newThread.toString()}`);
|
|
165
163
|
const runtime = getOrCreateRuntime({
|
|
166
164
|
threadId: newThread.id,
|
|
@@ -9,22 +9,15 @@ import { deleteThreadWorkspace, getChannelWorktreesEnabled, setChannelWorktreesE
|
|
|
9
9
|
import { getDb } from '../db.js';
|
|
10
10
|
import { splitTablesFromMarkdown, truncateComponents } from '../format-tables.js';
|
|
11
11
|
import { buildHtmlActionCustomId, cancelHtmlActionsForOwner, registerHtmlAction, } from '../html-actions.js';
|
|
12
|
-
import * as errore from 'errore';
|
|
13
12
|
import crypto from 'node:crypto';
|
|
14
|
-
import {
|
|
13
|
+
import { OpenCodeSdkError } from '../errors.js';
|
|
15
14
|
import { resolveTextChannel, resolveWorkingDirectory } from '../discord-utils.js';
|
|
16
15
|
import { initializeOpencodeForDirectory } from '../opencode.js';
|
|
17
|
-
import { deleteWorktree, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
|
|
16
|
+
import { deleteWorktree, extractGitExecOutput, git, getDefaultBranch, listGitWorktrees, } from '../worktrees.js';
|
|
18
17
|
import path from 'node:path';
|
|
19
|
-
// Extracts the git stderr from a deleteWorktree error via errore.findCause.
|
|
20
|
-
// Chain: Error { cause: GitCommandError { cause: CommandError { stderr } } }.
|
|
21
18
|
export function extractGitStderr(error) {
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
if (stderr && stderr.length > 0) {
|
|
25
|
-
return stderr;
|
|
26
|
-
}
|
|
27
|
-
return undefined;
|
|
19
|
+
const stderr = extractGitExecOutput(error).stderr;
|
|
20
|
+
return stderr.length > 0 ? stderr : undefined;
|
|
28
21
|
}
|
|
29
22
|
export function formatTimeAgo(date) {
|
|
30
23
|
const diffMs = Date.now() - date.getTime();
|
|
@@ -73,8 +66,7 @@ function detectWorktreeSource({ branch, directory, }) {
|
|
|
73
66
|
// Returns null when the directory is missing / git commands fail / timeout.
|
|
74
67
|
async function getWorktreeGitStatus({ directory, defaultBranch, }) {
|
|
75
68
|
try {
|
|
76
|
-
// Use raw git calls so
|
|
77
|
-
// errors and returns false, which would render "merged" instead of "unknown".
|
|
69
|
+
// Use raw git calls so status and ahead-count share one timeout window.
|
|
78
70
|
const [statusResult, aheadResult] = await Promise.all([
|
|
79
71
|
git(directory, 'status --porcelain', { timeout: GIT_CMD_TIMEOUT }),
|
|
80
72
|
git(directory, `rev-list --count "${defaultBranch}..HEAD"`, {
|
package/dist/config.js
CHANGED
|
@@ -10,7 +10,7 @@ import { store } from './store.js';
|
|
|
10
10
|
const DEFAULT_DATA_DIR = path.join(os.homedir(), '.kimaki');
|
|
11
11
|
/**
|
|
12
12
|
* Get the data directory path.
|
|
13
|
-
*
|
|
13
|
+
* Order: store value, vitest temp dir, KIMAKI_DATA_DIR, then ~/.kimaki.
|
|
14
14
|
* Under vitest (KIMAKI_VITEST env var), auto-creates an isolated temp dir so
|
|
15
15
|
* tests never touch the real ~/.kimaki/ database. Tests that need a specific
|
|
16
16
|
* dir can still call setDataDir() before any DB access to override this.
|
|
@@ -20,11 +20,20 @@ export function getDataDir() {
|
|
|
20
20
|
if (current) {
|
|
21
21
|
return current;
|
|
22
22
|
}
|
|
23
|
+
// Tests stay isolated even if the parent process exported KIMAKI_DATA_DIR.
|
|
23
24
|
if (process.env.KIMAKI_VITEST) {
|
|
24
25
|
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-test-'));
|
|
25
26
|
store.setState({ dataDir: tmpDir });
|
|
26
27
|
return tmpDir;
|
|
27
28
|
}
|
|
29
|
+
// Child processes (OpenCode server, kimaki shim commands) inherit this from
|
|
30
|
+
// opencode.ts. Without it they would silently use ~/.kimaki.
|
|
31
|
+
const fromEnv = process.env.KIMAKI_DATA_DIR;
|
|
32
|
+
if (fromEnv) {
|
|
33
|
+
const resolved = path.resolve(fromEnv);
|
|
34
|
+
store.setState({ dataDir: resolved });
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
28
37
|
store.setState({ dataDir: DEFAULT_DATA_DIR });
|
|
29
38
|
return DEFAULT_DATA_DIR;
|
|
30
39
|
}
|
|
@@ -79,6 +88,12 @@ export function getPermissionTimeoutMs() {
|
|
|
79
88
|
export function getRestrictExternalDirectories() {
|
|
80
89
|
return store.getState().restrictExternalDirectories;
|
|
81
90
|
}
|
|
91
|
+
export function getOpencodeHostname() {
|
|
92
|
+
return store.getState().opencodeHostname;
|
|
93
|
+
}
|
|
94
|
+
export function getOpencodePort() {
|
|
95
|
+
return store.getState().opencodePort;
|
|
96
|
+
}
|
|
82
97
|
const DEFAULT_LOCK_PORT = 29988;
|
|
83
98
|
/**
|
|
84
99
|
* Derive a lock port from the data directory path.
|
package/dist/database.js
CHANGED
|
@@ -213,6 +213,104 @@ export async function setSessionStartSource({ sessionId, scheduleKind, scheduled
|
|
|
213
213
|
set: { schedule_kind: scheduleKind, scheduled_task_id: scheduledTaskId ?? null, updated_at: new Date() },
|
|
214
214
|
});
|
|
215
215
|
}
|
|
216
|
+
export async function upsertSessionSleep({ sessionId, wakeAt, reason, }) {
|
|
217
|
+
const db = await getDb();
|
|
218
|
+
// A new occurrence gets a fresh delivery_id, which invalidates any in-flight
|
|
219
|
+
// wake for the previous occurrence: every delivery write is guarded on it.
|
|
220
|
+
const deliveryId = crypto.randomUUID();
|
|
221
|
+
await db.insert(schema.session_sleeps)
|
|
222
|
+
.values({
|
|
223
|
+
session_id: sessionId,
|
|
224
|
+
wake_at: wakeAt,
|
|
225
|
+
reason: reason ?? null,
|
|
226
|
+
status: 'planned',
|
|
227
|
+
delivery_id: deliveryId,
|
|
228
|
+
})
|
|
229
|
+
.onConflictDoUpdate({
|
|
230
|
+
target: schema.session_sleeps.session_id,
|
|
231
|
+
set: {
|
|
232
|
+
wake_at: wakeAt,
|
|
233
|
+
reason: reason ?? null,
|
|
234
|
+
status: 'planned',
|
|
235
|
+
delivery_id: deliveryId,
|
|
236
|
+
attempts: 0,
|
|
237
|
+
last_attempt_at: null,
|
|
238
|
+
created_at: new Date(),
|
|
239
|
+
},
|
|
240
|
+
});
|
|
241
|
+
return deliveryId;
|
|
242
|
+
}
|
|
243
|
+
export async function getSessionSleep({ sessionId }) {
|
|
244
|
+
const db = await getDb();
|
|
245
|
+
return await db.query.session_sleeps.findFirst({
|
|
246
|
+
where: { session_id: sessionId },
|
|
247
|
+
}) ?? null;
|
|
248
|
+
}
|
|
249
|
+
/** Rows that are due and not attempted too recently. Status stays `planned`. */
|
|
250
|
+
export async function getDueSessionSleeps({ now, retryAfterMs, limit, }) {
|
|
251
|
+
const db = await getDb();
|
|
252
|
+
const retryBefore = new Date(now.getTime() - retryAfterMs);
|
|
253
|
+
return db.select()
|
|
254
|
+
.from(schema.session_sleeps)
|
|
255
|
+
.where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
|
|
256
|
+
.orderBy(orm.asc(schema.session_sleeps.wake_at))
|
|
257
|
+
.limit(limit);
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Reserve one delivery attempt. The row never leaves `planned` here.
|
|
261
|
+
*
|
|
262
|
+
* Only `last_attempt_at` moves, which keeps concurrent ticks off the same row
|
|
263
|
+
* for retryAfterMs. Because posting does not change the status either, one
|
|
264
|
+
* retry path covers every loss: dying before the post, dying after it, or
|
|
265
|
+
* never receiving the gateway event for the message we posted.
|
|
266
|
+
*/
|
|
267
|
+
export async function claimSessionSleepAttempt({ sessionId, deliveryId, now, retryAfterMs, }) {
|
|
268
|
+
const db = await getDb();
|
|
269
|
+
const retryBefore = new Date(now.getTime() - retryAfterMs);
|
|
270
|
+
const rows = await db.update(schema.session_sleeps)
|
|
271
|
+
.set({ last_attempt_at: now, attempts: orm.sql `${schema.session_sleeps.attempts} + 1` })
|
|
272
|
+
.where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned'), orm.lte(schema.session_sleeps.wake_at, now), orm.or(orm.isNull(schema.session_sleeps.last_attempt_at), orm.lte(schema.session_sleeps.last_attempt_at, retryBefore))))
|
|
273
|
+
.returning();
|
|
274
|
+
return rows[0] ?? null;
|
|
275
|
+
}
|
|
276
|
+
export async function markSessionSleepFailed({ sessionId, deliveryId, }) {
|
|
277
|
+
const db = await getDb();
|
|
278
|
+
const rows = await db.update(schema.session_sleeps)
|
|
279
|
+
.set({ status: 'failed' })
|
|
280
|
+
.where(orm.and(orm.eq(schema.session_sleeps.session_id, sessionId), orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
|
|
281
|
+
.returning({ session_id: schema.session_sleeps.session_id });
|
|
282
|
+
return countRows(rows) > 0;
|
|
283
|
+
}
|
|
284
|
+
/**
|
|
285
|
+
* Ingress commit point. Marks the wake as delivered to the session.
|
|
286
|
+
*
|
|
287
|
+
* Returns false when the row is missing or no longer `planned`, which is how a
|
|
288
|
+
* wake that raced a user cancellation, or a duplicate of one already delivered,
|
|
289
|
+
* gets dropped instead of starting a turn.
|
|
290
|
+
*/
|
|
291
|
+
export async function consumeSessionSleepWake({ deliveryId }) {
|
|
292
|
+
const db = await getDb();
|
|
293
|
+
const rows = await db.update(schema.session_sleeps)
|
|
294
|
+
.set({ status: 'consumed' })
|
|
295
|
+
.where(orm.and(orm.eq(schema.session_sleeps.delivery_id, deliveryId), orm.eq(schema.session_sleeps.status, 'planned')))
|
|
296
|
+
.returning({ session_id: schema.session_sleeps.session_id });
|
|
297
|
+
return countRows(rows) > 0;
|
|
298
|
+
}
|
|
299
|
+
/**
|
|
300
|
+
* Cancel the pending sleep of the session currently bound to a thread.
|
|
301
|
+
*
|
|
302
|
+
* Runs on every ingress, so the binding is resolved with a correlated subquery
|
|
303
|
+
* rather than a second round trip. thread_id is the primary key of
|
|
304
|
+
* thread_sessions, so it yields at most one session.
|
|
305
|
+
*/
|
|
306
|
+
export async function cancelSessionSleepForThread({ threadId }) {
|
|
307
|
+
const db = await getDb();
|
|
308
|
+
const rows = await db.update(schema.session_sleeps)
|
|
309
|
+
.set({ status: 'cancelled' })
|
|
310
|
+
.where(orm.and(orm.eq(schema.session_sleeps.status, 'planned'), orm.eq(schema.session_sleeps.session_id, orm.sql `(SELECT ${schema.thread_sessions.session_id} FROM ${schema.thread_sessions} WHERE ${schema.thread_sessions.thread_id} = ${threadId})`)))
|
|
311
|
+
.returning({ session_id: schema.session_sleeps.session_id });
|
|
312
|
+
return countRows(rows) > 0;
|
|
313
|
+
}
|
|
216
314
|
export async function getSessionStartSourcesBySessionIds(sessionIds) {
|
|
217
315
|
if (sessionIds.length === 0)
|
|
218
316
|
return new Map();
|
|
@@ -440,9 +538,18 @@ export async function setThreadSession(threadId, sessionId) {
|
|
|
440
538
|
}
|
|
441
539
|
export async function upsertThreadSession({ threadId, sessionId, source }) {
|
|
442
540
|
const db = await getDb();
|
|
541
|
+
// updated_at is written explicitly on BOTH paths, never left to the column's
|
|
542
|
+
// CURRENT_TIMESTAMP default. The default stores "YYYY-MM-DD HH:MM:SS" while
|
|
543
|
+
// Date values store ISO with a "T", and those two text formats do not sort
|
|
544
|
+
// against each other correctly. Writing ISO everywhere keeps the ordering in
|
|
545
|
+
// getThreadIdBySessionId honest and gives it millisecond resolution.
|
|
546
|
+
const boundAt = new Date();
|
|
443
547
|
await db.insert(schema.thread_sessions)
|
|
444
|
-
.values({ thread_id: threadId, session_id: sessionId, source })
|
|
445
|
-
.onConflictDoUpdate({
|
|
548
|
+
.values({ thread_id: threadId, session_id: sessionId, source, updated_at: boundAt })
|
|
549
|
+
.onConflictDoUpdate({
|
|
550
|
+
target: schema.thread_sessions.thread_id,
|
|
551
|
+
set: { session_id: sessionId, source, updated_at: boundAt },
|
|
552
|
+
});
|
|
446
553
|
}
|
|
447
554
|
export async function getThreadParentSessionId(threadId) {
|
|
448
555
|
const db = await getDb();
|
|
@@ -462,9 +569,23 @@ export async function getThreadSessionSource(threadId) {
|
|
|
462
569
|
const db = await getDb();
|
|
463
570
|
return (await db.query.thread_sessions.findFirst({ where: { thread_id: threadId }, columns: { source: true } }))?.source;
|
|
464
571
|
}
|
|
572
|
+
/**
|
|
573
|
+
* Reverse lookup of the Discord thread that currently owns an OpenCode session.
|
|
574
|
+
*
|
|
575
|
+
* `thread_sessions.session_id` is NOT unique: /resume binds an existing session
|
|
576
|
+
* to a brand new thread without clearing the old row. An unordered findFirst
|
|
577
|
+
* therefore returns an arbitrary (often dead) thread, which made plugin tools
|
|
578
|
+
* post into the wrong conversation. Order by the most recent binding instead,
|
|
579
|
+
* falling back to created_at for rows written before updated_at existed.
|
|
580
|
+
*/
|
|
465
581
|
export async function getThreadIdBySessionId(sessionId) {
|
|
466
582
|
const db = await getDb();
|
|
467
|
-
|
|
583
|
+
const rows = await db.select({ thread_id: schema.thread_sessions.thread_id })
|
|
584
|
+
.from(schema.thread_sessions)
|
|
585
|
+
.where(orm.eq(schema.thread_sessions.session_id, sessionId))
|
|
586
|
+
.orderBy(orm.desc(orm.sql `COALESCE(${schema.thread_sessions.updated_at}, ${schema.thread_sessions.created_at})`))
|
|
587
|
+
.limit(1);
|
|
588
|
+
return rows[0]?.thread_id;
|
|
468
589
|
}
|
|
469
590
|
export async function getAllThreadSessionIds() {
|
|
470
591
|
const db = await getDb();
|