kimaki 0.28.0 → 0.30.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 +33 -33
- package/dist/anthropic-account-identity.js +1 -1
- package/dist/anthropic-auth-plugin.js +5 -5
- package/dist/anthropic-auth-plugin.test.js +30 -2
- package/dist/anthropic-auth-state.js +1 -1
- package/dist/bash-tool-schema-plugin.js +18 -0
- package/dist/bash-tool-schema-plugin.test.js +20 -1
- package/dist/bin.js +7 -3
- package/dist/bot-lifecycle.test.js +206 -0
- package/dist/cache-drift-plugin.js +25 -18
- package/dist/cache-rewrite.js +39 -0
- package/dist/cache-rewrite.test.js +41 -0
- package/dist/channel-management.js +113 -51
- package/dist/cli-commands/misc.js +4 -1
- package/dist/cli-commands/project.js +7 -4
- package/dist/cli-commands/session.js +118 -36
- package/dist/cli-commands/thread.js +63 -0
- package/dist/cli-parsing.test.js +3 -3
- package/dist/cli-runner.js +11 -1
- package/dist/cli-send-thread.e2e.test.js +2 -2
- package/dist/cli.js +6 -4
- package/dist/commands/ask-question.js +72 -23
- package/dist/commands/queue.js +84 -36
- package/dist/commands/queue.test.js +15 -0
- package/dist/commands/restart-opencode-server.js +7 -1
- package/dist/commands/session-id.js +5 -5
- package/dist/database.js +149 -6
- package/dist/db.js +34 -0
- package/dist/db.test.js +199 -1
- package/dist/default-channel-provisioning.test.js +2 -1
- package/dist/discord-bot.js +220 -146
- package/dist/discord-command-registration.js +1 -1
- package/dist/external-directory-permissions.e2e.test.js +3 -3
- package/dist/external-directory-project-deny.e2e.test.js +1 -1
- package/dist/forum-sync/watchers.js +15 -0
- package/dist/gateway-proxy.e2e.test.js +9 -9
- package/dist/guild-categories.test.js +225 -0
- package/dist/hrana-server.js +68 -14
- package/dist/interaction-handler.js +5 -1
- package/dist/kimaki-opencode-plugin.js +2 -0
- package/dist/kimaki-opencode-plugin.test.js +28 -0
- package/dist/list-channel-threads.js +163 -0
- package/dist/list-channel-threads.test.js +80 -0
- package/dist/markdown.js +164 -102
- package/dist/markdown.test.js +88 -20
- package/dist/message-formatting.js +29 -18
- package/dist/message-formatting.test.js +111 -3
- package/dist/opencode.js +27 -12
- package/dist/question-abort-clears-dropdown.e2e.test.js +1 -1
- package/dist/question-abort-resume.e2e.test.js +100 -0
- package/dist/question-abort-retry-text-before-dropdown.e2e.test.js +1 -1
- package/dist/question-text-before-dropdown.e2e.test.js +1 -1
- package/dist/queue-advanced-abort.e2e.test.js +3 -3
- package/dist/queue-advanced-action-buttons.e2e.test.js +5 -5
- package/dist/queue-advanced-e2e-setup.js +63 -0
- package/dist/queue-advanced-footer.e2e.test.js +18 -18
- package/dist/queue-advanced-model-switch.e2e.test.js +3 -4
- package/dist/queue-advanced-permissions-typing.e2e.test.js +2 -2
- package/dist/queue-advanced-typing-interrupt.e2e.test.js +3 -3
- package/dist/queue-delete-message.e2e.test.js +4 -4
- package/dist/queue-drain-after-interactive-ui.e2e.test.js +3 -3
- package/dist/queue-interrupt-drain.e2e.test.js +10 -10
- package/dist/queue-question-select-drain.e2e.test.js +5 -5
- package/dist/runtime-lifecycle.e2e.test.js +95 -11
- package/dist/schema.js +21 -0
- package/dist/session-handler/event-stream-state.js +240 -6
- package/dist/session-handler/event-stream-state.test.js +1116 -130
- package/dist/session-handler/thread-runtime-state.js +4 -0
- package/dist/session-handler/thread-session-runtime.js +499 -89
- package/dist/sleep.e2e.test.js +7 -7
- package/dist/store.js +1 -1
- package/dist/system-message.js +64 -23
- package/dist/system-message.test.js +69 -23
- package/dist/task-id-plugin.js +16 -0
- package/dist/task-runner.js +7 -0
- package/dist/task-schedule.test.js +1 -1
- package/dist/test-utils.js +5 -3
- package/dist/thread-message-queue.e2e.test.js +30 -31
- package/dist/undo-redo.e2e.test.js +3 -3
- package/dist/voice-message.e2e.test.js +64 -26
- package/dist/voice-routing-input.e2e.test.js +4 -4
- package/dist/voice-routing-question.e2e.test.js +4 -4
- package/dist/voice.js +107 -21
- package/dist/voice.test.js +52 -1
- package/dist/wait-session.js +24 -2
- package/dist/worktree-lifecycle.e2e.test.js +8 -8
- package/package.json +7 -7
- package/skills/egaki/SKILL.md +14 -0
- package/skills/holocron/SKILL.md +125 -42
- package/skills/opensrc/SKILL.md +20 -3
- package/skills/spiceflow/SKILL.md +53 -172
- package/skills/strada/SKILL.md +61 -232
- package/src/agent-model.e2e.test.ts +33 -33
- package/src/anthropic-account-identity.ts +1 -1
- package/src/anthropic-auth-plugin.test.ts +37 -2
- package/src/anthropic-auth-plugin.ts +9 -7
- package/src/anthropic-auth-state.ts +1 -0
- package/src/bash-tool-schema-plugin.test.ts +29 -0
- package/src/bash-tool-schema-plugin.ts +31 -0
- package/src/bin.ts +7 -3
- package/src/bot-lifecycle.test.ts +252 -0
- package/src/cache-drift-plugin.ts +34 -24
- package/src/cache-rewrite.test.ts +49 -0
- package/src/cache-rewrite.ts +96 -0
- package/src/channel-management.ts +144 -65
- package/src/cli-commands/misc.ts +4 -1
- package/src/cli-commands/project.ts +9 -4
- package/src/cli-commands/session.ts +167 -38
- package/src/cli-commands/thread.ts +86 -0
- package/src/cli-parsing.test.ts +3 -3
- package/src/cli-runner.ts +11 -0
- package/src/cli-send-thread.e2e.test.ts +2 -2
- package/src/cli.ts +8 -6
- package/src/commands/ask-question.ts +88 -25
- package/src/commands/queue.test.ts +19 -0
- package/src/commands/queue.ts +111 -45
- package/src/commands/restart-opencode-server.ts +12 -1
- package/src/commands/session-id.ts +5 -5
- package/src/database.ts +219 -6
- package/src/db.test.ts +236 -0
- package/src/db.ts +38 -0
- package/src/default-channel-provisioning.test.ts +2 -1
- package/src/discord-bot.ts +272 -185
- package/src/discord-command-registration.ts +3 -1
- package/src/external-directory-permissions.e2e.test.ts +3 -3
- package/src/external-directory-project-deny.e2e.test.ts +1 -1
- package/src/forum-sync/watchers.ts +20 -0
- package/src/gateway-proxy.e2e.test.ts +9 -9
- package/src/guild-categories.test.ts +260 -0
- package/src/hrana-server.ts +96 -16
- package/src/interaction-handler.ts +7 -0
- package/src/kimaki-opencode-plugin.test.ts +34 -0
- package/src/kimaki-opencode-plugin.ts +2 -0
- package/src/list-channel-threads.test.ts +90 -0
- package/src/list-channel-threads.ts +252 -0
- package/src/markdown.test.ts +108 -22
- package/src/markdown.ts +216 -103
- package/src/message-formatting.test.ts +129 -2
- package/src/message-formatting.ts +28 -14
- package/src/opencode.ts +28 -13
- package/src/question-abort-clears-dropdown.e2e.test.ts +1 -1
- package/src/question-abort-resume.e2e.test.ts +121 -0
- package/src/question-abort-retry-text-before-dropdown.e2e.test.ts +1 -1
- package/src/question-text-before-dropdown.e2e.test.ts +1 -1
- package/src/queue-advanced-abort.e2e.test.ts +3 -3
- package/src/queue-advanced-action-buttons.e2e.test.ts +5 -5
- package/src/queue-advanced-e2e-setup.ts +65 -0
- package/src/queue-advanced-footer.e2e.test.ts +18 -18
- package/src/queue-advanced-model-switch.e2e.test.ts +3 -4
- package/src/queue-advanced-permissions-typing.e2e.test.ts +2 -2
- package/src/queue-advanced-typing-interrupt.e2e.test.ts +3 -3
- package/src/queue-delete-message.e2e.test.ts +4 -4
- package/src/queue-drain-after-interactive-ui.e2e.test.ts +3 -3
- package/src/queue-interrupt-drain.e2e.test.ts +10 -10
- package/src/queue-question-select-drain.e2e.test.ts +5 -5
- package/src/runtime-lifecycle.e2e.test.ts +104 -10
- package/src/schema.sql +16 -0
- package/src/schema.ts +23 -0
- package/src/session-handler/event-stream-state.test.ts +1184 -133
- package/src/session-handler/event-stream-state.ts +322 -8
- package/src/session-handler/thread-runtime-state.ts +5 -0
- package/src/session-handler/thread-session-runtime.ts +597 -95
- package/src/sleep.e2e.test.ts +7 -7
- package/src/store.ts +2 -2
- package/src/system-message.test.ts +70 -23
- package/src/system-message.ts +67 -22
- package/src/task-id-plugin.ts +17 -0
- package/src/task-runner.ts +8 -0
- package/src/task-schedule.test.ts +1 -1
- package/src/test-utils.ts +5 -3
- package/src/thread-message-queue.e2e.test.ts +30 -31
- package/src/undo-redo.e2e.test.ts +3 -3
- package/src/voice-message.e2e.test.ts +70 -26
- package/src/voice-routing-input.e2e.test.ts +4 -4
- package/src/voice-routing-question.e2e.test.ts +4 -4
- package/src/voice.test.ts +56 -0
- package/src/voice.ts +135 -25
- package/src/wait-session.ts +25 -2
- package/src/worktree-lifecycle.e2e.test.ts +8 -8
|
@@ -54,45 +54,92 @@ async function resolveSessionDirectoryFromDatabase({ sessionId, }) {
|
|
|
54
54
|
}
|
|
55
55
|
return new Error(`Session is not linked to a Kimaki thread in the local database: ${sessionId}`);
|
|
56
56
|
}
|
|
57
|
+
// Total token footprint of a session (input + output + reasoning + cache).
|
|
58
|
+
function getSessionTokenTotal(tokens) {
|
|
59
|
+
return (tokens.input +
|
|
60
|
+
tokens.output +
|
|
61
|
+
tokens.reasoning +
|
|
62
|
+
tokens.cache.read +
|
|
63
|
+
tokens.cache.write);
|
|
64
|
+
}
|
|
65
|
+
// Compact token count: 1234 -> "1k", 200000 -> "200k", 1_500_000 -> "1.5M".
|
|
66
|
+
function formatTokenCount(count) {
|
|
67
|
+
if (count >= 1_000_000) {
|
|
68
|
+
return `${(count / 1_000_000).toFixed(1)}M`;
|
|
69
|
+
}
|
|
70
|
+
if (count >= 1_000) {
|
|
71
|
+
return `${Math.round(count / 1_000)}k`;
|
|
72
|
+
}
|
|
73
|
+
return String(count);
|
|
74
|
+
}
|
|
57
75
|
cli
|
|
58
76
|
.command('session list', 'List all OpenCode sessions, marking which were started via Kimaki')
|
|
59
77
|
.option('--project <path>', 'Project directory to list sessions for (defaults to cwd)')
|
|
78
|
+
.option('--all', 'List sessions across every locally registered project')
|
|
60
79
|
.option('--active', 'Only list active sessions; exits 1 when none remain')
|
|
61
80
|
.option('--exclude <sessionId>', 'Exclude one session ID from the results')
|
|
62
81
|
.option('--json', 'Output as JSON')
|
|
63
82
|
.action(async (options) => {
|
|
64
83
|
try {
|
|
65
|
-
const projectDirectory = path.resolve(options.project || '.');
|
|
66
84
|
await initDatabase();
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if (getClient instanceof Error) {
|
|
70
|
-
cliLogger.error('Failed to connect to OpenCode:', getClient.message);
|
|
85
|
+
if (options.all && options.project) {
|
|
86
|
+
cliLogger.error('Use either --all or --project, not both');
|
|
71
87
|
process.exit(EXIT_NO_RESTART);
|
|
72
88
|
}
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
89
|
+
const projectDirectories = options.all
|
|
90
|
+
? Array.from(new Set((await getAllTextChannelDirectories()).map((directory) => path.resolve(directory))))
|
|
91
|
+
: [path.resolve(options.project || '.')];
|
|
92
|
+
if (projectDirectories.length === 0) {
|
|
93
|
+
cliLogger.error('No registered project directories found. Add a project with `kimaki project add`.');
|
|
94
|
+
process.exit(EXIT_NO_RESTART);
|
|
95
|
+
}
|
|
96
|
+
const gathered = [];
|
|
97
|
+
for (const projectDirectory of projectDirectories) {
|
|
98
|
+
cliLogger.log(`Connecting to OpenCode server for ${projectDirectory}...`);
|
|
99
|
+
const getClient = await initializeOpencodeForDirectory(projectDirectory);
|
|
100
|
+
if (getClient instanceof Error) {
|
|
101
|
+
if (options.all) {
|
|
102
|
+
cliLogger.warn(`Skipping ${projectDirectory}: failed to connect to OpenCode: ${getClient.message}`);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
cliLogger.error('Failed to connect to OpenCode:', getClient.message);
|
|
83
106
|
process.exit(EXIT_NO_RESTART);
|
|
84
107
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
108
|
+
const client = getClient();
|
|
109
|
+
const [sessionsResponse, statusResponse, questionsResponse] = await Promise.all([
|
|
110
|
+
client.session.list(),
|
|
111
|
+
client.session.status({ directory: projectDirectory }).catch(() => null),
|
|
112
|
+
client.question.list({ directory: projectDirectory }).catch(() => null),
|
|
113
|
+
]);
|
|
114
|
+
const statuses = statusResponse?.data || {};
|
|
115
|
+
const sessionsWithPendingQuestion = new Set((questionsResponse?.data || []).map((request) => request.sessionID));
|
|
116
|
+
for (const session of sessionsResponse.data || []) {
|
|
117
|
+
const status = statuses[session.id];
|
|
118
|
+
const isBusy = Boolean(status && status.type !== 'idle');
|
|
119
|
+
// Only relabel to showing-question when the session is actually busy.
|
|
120
|
+
// An orphaned question left in the list after an abort (session idle)
|
|
121
|
+
// must not hide or relabel an otherwise-idle session.
|
|
122
|
+
gathered.push({
|
|
123
|
+
session,
|
|
124
|
+
projectDirectory,
|
|
125
|
+
status: isBusy && sessionsWithPendingQuestion.has(session.id)
|
|
126
|
+
? 'showing-question'
|
|
127
|
+
: isBusy
|
|
128
|
+
? 'busy'
|
|
129
|
+
: 'idle',
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
const selected = gathered
|
|
134
|
+
.filter((entry) => {
|
|
135
|
+
if (entry.session.id === options.exclude)
|
|
89
136
|
return false;
|
|
90
|
-
if (
|
|
91
|
-
return
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
if (
|
|
137
|
+
if (options.active && entry.status !== 'busy')
|
|
138
|
+
return false;
|
|
139
|
+
return true;
|
|
140
|
+
})
|
|
141
|
+
.sort((a, b) => b.session.time.updated - a.session.time.updated);
|
|
142
|
+
if (selected.length === 0) {
|
|
96
143
|
if (options.json)
|
|
97
144
|
console.log('[]');
|
|
98
145
|
else
|
|
@@ -107,15 +154,26 @@ cli
|
|
|
107
154
|
const sessionToThread = new Map(threadSessions
|
|
108
155
|
.filter((row) => row.session_id !== '')
|
|
109
156
|
.map((row) => [row.session_id, row.thread_id]));
|
|
110
|
-
const sessionStartSources = await getSessionStartSourcesBySessionIds(
|
|
157
|
+
const sessionStartSources = await getSessionStartSourcesBySessionIds(selected.map((entry) => entry.session.id));
|
|
111
158
|
const scheduleModeLabel = ({ scheduleKind, }) => {
|
|
112
159
|
if (scheduleKind === 'at') {
|
|
113
160
|
return 'delay';
|
|
114
161
|
}
|
|
115
162
|
return 'cron';
|
|
116
163
|
};
|
|
164
|
+
// Token footprint straight from the session object (no message fetch).
|
|
165
|
+
// Session.tokens is a per-session aggregate, so it is reported as a token
|
|
166
|
+
// count rather than a context-window percentage.
|
|
167
|
+
const contextInfo = (entry) => {
|
|
168
|
+
const tokens = entry.session.tokens;
|
|
169
|
+
if (!tokens)
|
|
170
|
+
return null;
|
|
171
|
+
const total = getSessionTokenTotal(tokens);
|
|
172
|
+
return total > 0 ? total : null;
|
|
173
|
+
};
|
|
117
174
|
if (options.json) {
|
|
118
|
-
const output =
|
|
175
|
+
const output = selected.map((entry) => {
|
|
176
|
+
const session = entry.session;
|
|
119
177
|
const startSource = sessionStartSources.get(session.id);
|
|
120
178
|
const startedBy = startSource
|
|
121
179
|
? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
|
|
@@ -127,15 +185,18 @@ cli
|
|
|
127
185
|
updated: new Date(session.time.updated).toISOString(),
|
|
128
186
|
source: sessionToThread.has(session.id) ? 'kimaki' : 'opencode',
|
|
129
187
|
threadId: sessionToThread.get(session.id) || null,
|
|
188
|
+
status: entry.status,
|
|
189
|
+
model: session.model?.id || null,
|
|
190
|
+
tokens: contextInfo(entry),
|
|
130
191
|
startedBy,
|
|
131
192
|
scheduledTaskId: startSource?.scheduled_task_id || null,
|
|
132
|
-
status: options.active ? statuses?.[session.id]?.type || 'busy' : undefined,
|
|
133
193
|
};
|
|
134
194
|
});
|
|
135
195
|
console.log(JSON.stringify(output, null, 2));
|
|
136
196
|
process.exit(0);
|
|
137
197
|
}
|
|
138
|
-
for (const
|
|
198
|
+
for (const entry of selected) {
|
|
199
|
+
const session = entry.session;
|
|
139
200
|
const threadId = sessionToThread.get(session.id);
|
|
140
201
|
const startSource = sessionStartSources.get(session.id);
|
|
141
202
|
const source = threadId ? '(kimaki)' : '(opencode)';
|
|
@@ -144,10 +205,15 @@ cli
|
|
|
144
205
|
: '';
|
|
145
206
|
const updatedAt = new Date(session.time.updated).toISOString();
|
|
146
207
|
const threadInfo = threadId ? ` | thread: ${threadId}` : '';
|
|
147
|
-
const
|
|
148
|
-
?
|
|
149
|
-
: ''
|
|
150
|
-
|
|
208
|
+
const statusLabel = entry.status === 'busy'
|
|
209
|
+
? 'working'
|
|
210
|
+
: entry.status === 'showing-question'
|
|
211
|
+
? 'showing-question'
|
|
212
|
+
: 'idle';
|
|
213
|
+
const statusInfo = ` | status: ${statusLabel}`;
|
|
214
|
+
const tokens = contextInfo(entry);
|
|
215
|
+
const tokensText = tokens ? ` | tokens: ${formatTokenCount(tokens)}` : '';
|
|
216
|
+
console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${statusInfo}${tokensText}${threadInfo}${startedBy}`);
|
|
151
217
|
}
|
|
152
218
|
process.exit(0);
|
|
153
219
|
}
|
|
@@ -230,9 +296,18 @@ cli
|
|
|
230
296
|
}
|
|
231
297
|
});
|
|
232
298
|
cli
|
|
233
|
-
.command('session read <sessionId>',
|
|
299
|
+
.command('session read <sessionId>', dedent `
|
|
300
|
+
Read a session conversation as markdown (pipe to file to grep).
|
|
301
|
+
|
|
302
|
+
Thinking is omitted by default. Tool inputs are truncated. Use
|
|
303
|
+
\`--thinking\` and \`--verbose\` for the full dump.
|
|
304
|
+
`)
|
|
234
305
|
.option('--project <path>', 'Project directory (defaults to cwd)')
|
|
235
306
|
.option('--verbose', 'Show full tool inputs and outputs instead of compact summaries')
|
|
307
|
+
.option('--thinking', 'Include reasoning / thinking parts')
|
|
308
|
+
.option('--tool-input-max-chars <n>', z.number().default(80).describe('Max characters for compact tool input'))
|
|
309
|
+
.example('kimaki session read ses_xxx > ./tmp/session.md')
|
|
310
|
+
.example('kimaki session read ses_xxx --thinking --verbose')
|
|
236
311
|
.action(async (sessionId, options) => {
|
|
237
312
|
try {
|
|
238
313
|
const projectDirectory = path.resolve(options.project || '.');
|
|
@@ -246,7 +321,12 @@ cli
|
|
|
246
321
|
// Try current project first (fast path)
|
|
247
322
|
const compactTools = !options.verbose;
|
|
248
323
|
const markdown = new ShareMarkdown(getClient());
|
|
249
|
-
const result = await markdown.generate({
|
|
324
|
+
const result = await markdown.generate({
|
|
325
|
+
sessionID: sessionId,
|
|
326
|
+
compactTools,
|
|
327
|
+
includeThinking: options.thinking,
|
|
328
|
+
toolInputMaxChars: options.toolInputMaxChars,
|
|
329
|
+
});
|
|
250
330
|
if (!(result instanceof Error)) {
|
|
251
331
|
process.stdout.write(result);
|
|
252
332
|
process.exit(0);
|
|
@@ -280,6 +360,8 @@ cli
|
|
|
280
360
|
const otherResult = await otherMarkdown.generate({
|
|
281
361
|
sessionID: sessionId,
|
|
282
362
|
compactTools,
|
|
363
|
+
includeThinking: options.thinking,
|
|
364
|
+
toolInputMaxChars: options.toolInputMaxChars,
|
|
283
365
|
});
|
|
284
366
|
if (!(otherResult instanceof Error)) {
|
|
285
367
|
process.stdout.write(otherResult);
|
|
@@ -295,7 +377,7 @@ cli
|
|
|
295
377
|
}
|
|
296
378
|
});
|
|
297
379
|
cli
|
|
298
|
-
.command('session wait <sessionId>', 'Wait
|
|
380
|
+
.command('session wait <sessionId>', 'Wait until a session finishes or pauses for a user question, then print its conversation as markdown')
|
|
299
381
|
.action(async (sessionId) => {
|
|
300
382
|
try {
|
|
301
383
|
await initDatabase();
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { goke } from 'goke';
|
|
2
|
+
import { z } from 'zod';
|
|
3
|
+
import { createLogger, LogPrefix } from '../logger.js';
|
|
4
|
+
import { initDatabase } from '../discord-bot.js';
|
|
5
|
+
import { createDiscordRest } from '../discord-urls.js';
|
|
6
|
+
import { EXIT_NO_RESTART, resolveBotCredentials } from '../cli-runner.js';
|
|
7
|
+
import { listDiscordChannelThreads } from '../list-channel-threads.js';
|
|
8
|
+
const cliLogger = createLogger(LogPrefix.CLI);
|
|
9
|
+
const cli = goke();
|
|
10
|
+
cli
|
|
11
|
+
.command('thread list', 'List Discord threads in a channel. Use this to find a thread on another computer, then send with --thread.')
|
|
12
|
+
.option('-c, --channel <channelId>', 'Discord channel ID')
|
|
13
|
+
.option('--json', 'Output as JSON')
|
|
14
|
+
.option('--limit <n>', z.number().default(50).describe('Maximum threads to show'))
|
|
15
|
+
.example('kimaki thread list --channel 123456789012345678')
|
|
16
|
+
.example('kimaki thread list --channel 123456789012345678 --json')
|
|
17
|
+
.action(async (options, { console, process }) => {
|
|
18
|
+
try {
|
|
19
|
+
if (!options.channel) {
|
|
20
|
+
cliLogger.error('Channel ID is required. Use --channel <channelId>. Find remote channels with `kimaki project list --all --json`.');
|
|
21
|
+
process.exit(EXIT_NO_RESTART);
|
|
22
|
+
}
|
|
23
|
+
await initDatabase();
|
|
24
|
+
const { token: botToken } = await resolveBotCredentials({});
|
|
25
|
+
const rest = createDiscordRest(botToken);
|
|
26
|
+
const listed = await listDiscordChannelThreads({
|
|
27
|
+
rest,
|
|
28
|
+
channelId: options.channel,
|
|
29
|
+
});
|
|
30
|
+
if (!listed.ok) {
|
|
31
|
+
cliLogger.error(listed.error.message);
|
|
32
|
+
process.exit(EXIT_NO_RESTART);
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const limited = listed.threads.slice(0, options.limit);
|
|
36
|
+
if (options.json) {
|
|
37
|
+
console.log(JSON.stringify(limited.map((thread) => ({
|
|
38
|
+
thread_id: thread.id,
|
|
39
|
+
name: thread.name,
|
|
40
|
+
channel_id: thread.parentId,
|
|
41
|
+
guild_id: thread.guildId,
|
|
42
|
+
archived: thread.archived,
|
|
43
|
+
archive_state: thread.archiveState,
|
|
44
|
+
last_message_id: thread.lastMessageId,
|
|
45
|
+
})), null, 2));
|
|
46
|
+
process.exit(0);
|
|
47
|
+
}
|
|
48
|
+
if (limited.length === 0) {
|
|
49
|
+
console.log(`No threads found in channel ${options.channel}`);
|
|
50
|
+
process.exit(0);
|
|
51
|
+
}
|
|
52
|
+
for (const thread of limited) {
|
|
53
|
+
const archivedTag = thread.archived ? ' [archived]' : '';
|
|
54
|
+
console.log(`${thread.id} | ${thread.name}${archivedTag}`);
|
|
55
|
+
}
|
|
56
|
+
process.exit(0);
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
cliLogger.error('Error:', error instanceof Error ? error.stack : String(error));
|
|
60
|
+
process.exit(EXIT_NO_RESTART);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
export default cli;
|
package/dist/cli-parsing.test.js
CHANGED
|
@@ -50,7 +50,7 @@ async function parseRootBotOptions(argv) {
|
|
|
50
50
|
const script = [
|
|
51
51
|
"import { goke } from 'goke'",
|
|
52
52
|
'const cli = goke(\'kimaki\')',
|
|
53
|
-
"cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--
|
|
53
|
+
"cli.command('', 'bot').option('--no-analytics', 'Disable analytics').option('--enable-footer-mentions', 'Mention the thread creator in final footers')",
|
|
54
54
|
`const result = await cli.parse(${JSON.stringify(argv)}, { run: false })`,
|
|
55
55
|
'process.stdout.write(JSON.stringify({ args: result.args, options: result.options }))',
|
|
56
56
|
].join(';');
|
|
@@ -213,13 +213,13 @@ describe('goke CLI ID parsing', () => {
|
|
|
213
213
|
'node',
|
|
214
214
|
'kimaki',
|
|
215
215
|
'--no-analytics',
|
|
216
|
-
'--
|
|
216
|
+
'--enable-footer-mentions',
|
|
217
217
|
]);
|
|
218
218
|
expect(result.options).toMatchInlineSnapshot(`
|
|
219
219
|
{
|
|
220
220
|
"--": [],
|
|
221
|
+
"enableFooterMentions": true,
|
|
221
222
|
"noAnalytics": true,
|
|
222
|
-
"skipFooterMentions": true,
|
|
223
223
|
}
|
|
224
224
|
`);
|
|
225
225
|
});
|
package/dist/cli-runner.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// Keeps cli.ts focused on command composition while preserving the bot onboarding flow.
|
|
3
3
|
import { intro, outro, text, password, note, cancel, isCancel, confirm, log, multiselect, select, spinner, } from '@clack/prompts';
|
|
4
4
|
import { deduplicateByKey, generateBotInstallUrl, generateDiscordInstallUrlForBot, KIMAKI_GATEWAY_APP_ID, KIMAKI_WEBSITE_URL, abbreviatePath, } from './utils.js';
|
|
5
|
-
import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
|
|
5
|
+
import { getChannelsWithDescriptions, createDiscordClient, initDatabase, getChannelDirectory, startDiscordBot, registerBotLifecycleHandlers, initializeOpencodeForDirectory, assertCompatibleOpencodeVersion, createProjectChannels, createDefaultKimakiChannel, } from './discord-bot.js';
|
|
6
6
|
import { getBotTokenWithMode, ensureServiceAuthToken, setBotToken, setBotMode, setChannelDirectory, findChannelsByDirectory, getDb, } from './database.js';
|
|
7
7
|
import * as orm from 'drizzle-orm';
|
|
8
8
|
import * as dbSchema from './schema.js';
|
|
@@ -1175,6 +1175,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1175
1175
|
cliLogger.error('Failed to start hrana server:', hranaResult.message);
|
|
1176
1176
|
process.exit(EXIT_NO_RESTART);
|
|
1177
1177
|
}
|
|
1178
|
+
// We own the lock port from here on. Stop signals must release it in every
|
|
1179
|
+
// startup phase (Discord login, channel reconcile), not only after ready.
|
|
1180
|
+
registerBotLifecycleHandlers();
|
|
1178
1181
|
// Initialize database (connects to hrana server via HTTP)
|
|
1179
1182
|
await initDatabase();
|
|
1180
1183
|
const { appId, token, credentialSource, isGatewayMode, installerDiscordUserId } = await resolveCredentials({
|
|
@@ -1316,6 +1319,10 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1316
1319
|
}
|
|
1317
1320
|
process.exit(EXIT_NO_RESTART);
|
|
1318
1321
|
}
|
|
1322
|
+
// Stop signal during Discord login: shutdown owns the process now.
|
|
1323
|
+
if (global.shuttingDown) {
|
|
1324
|
+
return;
|
|
1325
|
+
}
|
|
1319
1326
|
await setBotToken(appId, token);
|
|
1320
1327
|
// In gateway mode the bot only sees guilds the user has installed
|
|
1321
1328
|
// it in. Zero guilds means the install URL callback never completed or the
|
|
@@ -1351,6 +1358,9 @@ export async function run({ restartOnboarding, addChannels, useWorktrees, enable
|
|
|
1351
1358
|
// Start bot immediately — channel sync happens in the background.
|
|
1352
1359
|
cliLogger.log('Starting Discord bot...');
|
|
1353
1360
|
await startDiscordBot({ token, appId, discordClient, useWorktrees });
|
|
1361
|
+
if (global.shuttingDown) {
|
|
1362
|
+
return;
|
|
1363
|
+
}
|
|
1354
1364
|
cliLogger.log('Discord bot is running!');
|
|
1355
1365
|
// Background channel sync + role reconciliation + default channel creation.
|
|
1356
1366
|
// Never blocks ready state.
|
|
@@ -319,9 +319,9 @@ describe('kimaki send --channel thread creation', () => {
|
|
|
319
319
|
"--- from: user (cli-send-tester)
|
|
320
320
|
thread empty content seed
|
|
321
321
|
--- from: assistant (TestBot)
|
|
322
|
-
|
|
322
|
+
-# *using deterministic-provider/deterministic-v2*
|
|
323
323
|
> caught-by-model
|
|
324
|
-
|
|
324
|
+
-# *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
|
|
325
325
|
--- from: user (cli-send-tester)
|
|
326
326
|
--- from: assistant (TestBot)
|
|
327
327
|
I can see you sent a message, but Discord did not include its text.
|
package/dist/cli.js
CHANGED
|
@@ -20,6 +20,7 @@ import projectCommands from './cli-commands/project.js';
|
|
|
20
20
|
import sendCommands from './cli-commands/send.js';
|
|
21
21
|
import sessionCommands from './cli-commands/session.js';
|
|
22
22
|
import taskCommands from './cli-commands/task.js';
|
|
23
|
+
import threadCommands from './cli-commands/thread.js';
|
|
23
24
|
import userCommands from './cli-commands/user.js';
|
|
24
25
|
import { EXIT_NO_RESTART, printDiscordInstallUrlAndExit, run, } from './cli-runner.js';
|
|
25
26
|
const cliLogger = createLogger(LogPrefix.CLI);
|
|
@@ -38,7 +39,7 @@ cli
|
|
|
38
39
|
.option('--verbosity <level>', 'Default verbosity for all channels (tools_and_text, text_and_essential_tools, or text_only)')
|
|
39
40
|
.option('--mention-mode', 'Bot only responds when @mentioned (default for all channels)')
|
|
40
41
|
.option('--no-critique', 'Disable automatic diff upload to critique.work in system prompts')
|
|
41
|
-
.option('--
|
|
42
|
+
.option('--enable-footer-mentions', 'Mention the thread creator in final session footers so they get a notification')
|
|
42
43
|
.option('--auto-restart', 'Automatically restart the bot on crash or OOM kill')
|
|
43
44
|
.option('--allow-all-users', 'Allow all Discord users to start sessions without needing Kimaki role or admin permissions (no-kimaki role still blocks)')
|
|
44
45
|
.option('--restrict-directories', 'Only allow the agent to access the session working directory and a few known-safe paths. Any other folder asks for permission. By default every directory is allowed and you protect folders with deny/ask rules in opencode.json')
|
|
@@ -175,7 +176,7 @@ cli
|
|
|
175
176
|
}),
|
|
176
177
|
...(options.mentionMode && { defaultMentionMode: true }),
|
|
177
178
|
...(options.noCritique && { critiqueEnabled: false }),
|
|
178
|
-
...(options.
|
|
179
|
+
...(options.enableFooterMentions && { footerMentionsEnabled: true }),
|
|
179
180
|
...(options.allowAllUsers && { allowAllUsers: true }),
|
|
180
181
|
...(options.restrictDirectories && { restrictExternalDirectories: true }),
|
|
181
182
|
...(permissionTimeoutMs !== undefined && { permissionTimeoutMs }),
|
|
@@ -211,8 +212,8 @@ cli
|
|
|
211
212
|
if (options.noCritique) {
|
|
212
213
|
cliLogger.log('Critique disabled: diffs will not be auto-uploaded to critique.work');
|
|
213
214
|
}
|
|
214
|
-
if (options.
|
|
215
|
-
cliLogger.log('Footer mentions
|
|
215
|
+
if (options.enableFooterMentions) {
|
|
216
|
+
cliLogger.log('Footer mentions enabled: final session footers will mention thread creators');
|
|
216
217
|
}
|
|
217
218
|
if (options.noAutoUpgrade) {
|
|
218
219
|
cliLogger.log('Auto-upgrade disabled: kimaki will not check for updates on startup');
|
|
@@ -258,6 +259,7 @@ cli.use(miscCommands);
|
|
|
258
259
|
cli.use(sendCommands);
|
|
259
260
|
cli.use(taskCommands);
|
|
260
261
|
cli.use(projectCommands);
|
|
262
|
+
cli.use(threadCommands);
|
|
261
263
|
cli.use(userCommands);
|
|
262
264
|
cli.use(sessionCommands);
|
|
263
265
|
cli.use(maintenanceCommands);
|
|
@@ -8,6 +8,7 @@ import { getOpencodeClient } from '../opencode.js';
|
|
|
8
8
|
import { DiscordOperationError } from '../errors.js';
|
|
9
9
|
import { createLogger, LogPrefix } from '../logger.js';
|
|
10
10
|
import { QUEUE_PREFIX } from '../message-formatting.js';
|
|
11
|
+
import { getRuntime } from '../session-handler/thread-session-runtime.js';
|
|
11
12
|
const logger = createLogger(LogPrefix.ASK_QUESTION);
|
|
12
13
|
// Store pending question contexts by hash.
|
|
13
14
|
// No TTL on purpose: a question can only be answered by the user, so expiring it
|
|
@@ -185,41 +186,89 @@ export async function handleAskQuestionSelectMenu(interaction) {
|
|
|
185
186
|
});
|
|
186
187
|
const username = interaction.user.globalName || interaction.user.username;
|
|
187
188
|
await sendThreadMessage(context.thread, `${QUEUE_PREFIX}**${username}:** ${answeredText}`);
|
|
188
|
-
// Check if all questions are answered
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
189
|
+
// Check if all questions are answered. Claim the context synchronously
|
|
190
|
+
// (delete before awaiting submit) so two concurrent final selections for a
|
|
191
|
+
// multi-question tool cannot both submit and double-resume the session.
|
|
192
|
+
if (areAllQuestionsAnswered(context)
|
|
193
|
+
&& pendingQuestionContexts.get(contextHash) === context) {
|
|
192
194
|
deletePendingQuestionContextsForRequest({
|
|
193
195
|
threadId: context.thread.id,
|
|
194
196
|
requestId: context.requestId,
|
|
195
197
|
});
|
|
198
|
+
await submitQuestionAnswers(context);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Format collected answers as a plain-text summary the model can read when
|
|
203
|
+
* the run is resumed after an abort (e.g. `"Which option?"="Alpha"`).
|
|
204
|
+
*
|
|
205
|
+
* Known limitation: if the user had queued items via /queue during the pending
|
|
206
|
+
* question AND aborted the run from another opencode client, the first queued
|
|
207
|
+
* item may have already been handed off to opencode and can lose its ordering
|
|
208
|
+
* on resume. That rare combination needs a queue-handoff redesign; the common
|
|
209
|
+
* (no-queue) abort case is handled correctly.
|
|
210
|
+
*/
|
|
211
|
+
function formatQuestionAnswersText(context) {
|
|
212
|
+
const parts = context.questions.map((q, i) => {
|
|
213
|
+
const answer = (context.answers[i] || []).join(', ') || 'Unanswered';
|
|
214
|
+
return `"${q.question}"="${answer}"`;
|
|
215
|
+
});
|
|
216
|
+
return `Answers to your previous questions: ${parts.join(', ')}`;
|
|
217
|
+
}
|
|
218
|
+
/** Resume the session by feeding the answers back as a new user prompt. */
|
|
219
|
+
async function resumeSessionWithAnswers(context) {
|
|
220
|
+
const runtime = getRuntime(context.thread.id);
|
|
221
|
+
const resumed = await runtime?.resumeWithText({
|
|
222
|
+
text: formatQuestionAnswersText(context),
|
|
223
|
+
});
|
|
224
|
+
if (!resumed) {
|
|
225
|
+
await sendThreadMessage(context.thread, '✗ Failed to submit answers: session is no longer active');
|
|
196
226
|
}
|
|
197
227
|
}
|
|
198
228
|
/**
|
|
199
229
|
* Submit all collected answers back to the OpenCode session.
|
|
200
|
-
*
|
|
230
|
+
*
|
|
231
|
+
* The decision is based on whether the session still has a live run:
|
|
232
|
+
*
|
|
233
|
+
* - Busy: a live run is parked on the question. Reply so it continues.
|
|
234
|
+
* - Idle: the run was aborted (from this or another opencode client). Replying
|
|
235
|
+
* would resolve a dead run and the session would never continue, so resume it
|
|
236
|
+
* with the answers as a fresh prompt instead.
|
|
237
|
+
*
|
|
238
|
+
* Note: `question.list` is not a reliable signal here. After an abort the
|
|
239
|
+
* question request can stay in the pending list but orphaned (no run awaiting
|
|
240
|
+
* it), so a reply would succeed yet the session would still not continue. The
|
|
241
|
+
* session busy state, derived from the event stream, is the accurate signal.
|
|
242
|
+
* The reply `.error` check is a safety net for the case where the request was
|
|
243
|
+
* actually removed while the session still looked busy.
|
|
201
244
|
*/
|
|
202
245
|
async function submitQuestionAnswers(context) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
}
|
|
208
|
-
// Build answers array: each element is an array of selected labels for that question
|
|
209
|
-
const answers = context.questions.map((_, i) => {
|
|
210
|
-
return context.answers[i] || [];
|
|
211
|
-
});
|
|
212
|
-
await client.question.reply({
|
|
213
|
-
requestID: context.requestId,
|
|
214
|
-
directory: context.directory,
|
|
215
|
-
answers,
|
|
216
|
-
});
|
|
217
|
-
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
|
|
246
|
+
const client = getOpencodeClient(context.directory);
|
|
247
|
+
if (!client) {
|
|
248
|
+
logger.error('OpenCode server not found for directory');
|
|
249
|
+
return;
|
|
218
250
|
}
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
251
|
+
const runtime = getRuntime(context.thread.id);
|
|
252
|
+
if (runtime && !runtime.isBusy()) {
|
|
253
|
+
logger.log(`Session ${context.sessionId} idle; resuming with answers for question ${context.requestId}`);
|
|
254
|
+
await resumeSessionWithAnswers(context);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
const answers = context.questions.map((_, i) => {
|
|
258
|
+
return context.answers[i] || [];
|
|
259
|
+
});
|
|
260
|
+
// throwOnError is off on this client, so failures come back in `.error`.
|
|
261
|
+
const replyResult = await client.question
|
|
262
|
+
.reply({ requestID: context.requestId, directory: context.directory, answers })
|
|
263
|
+
.catch((error) => ({ error }));
|
|
264
|
+
if (!replyResult.error) {
|
|
265
|
+
logger.log(`Submitted answers for question ${context.requestId} in session ${context.sessionId}`);
|
|
266
|
+
return;
|
|
222
267
|
}
|
|
268
|
+
// Reply failed (the request was removed while the session still looked busy).
|
|
269
|
+
// Resume so the answer still reaches the model.
|
|
270
|
+
logger.log(`Reply failed for question ${context.requestId}; resuming session ${context.sessionId} with answers`);
|
|
271
|
+
await resumeSessionWithAnswers(context);
|
|
223
272
|
}
|
|
224
273
|
/**
|
|
225
274
|
* Check if a tool part is an AskUserQuestion tool.
|