kimaki 0.28.0 → 0.29.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.
Files changed (71) hide show
  1. package/dist/bash-tool-schema-plugin.js +18 -0
  2. package/dist/bash-tool-schema-plugin.test.js +20 -1
  3. package/dist/channel-management.js +113 -51
  4. package/dist/cli-commands/misc.js +4 -1
  5. package/dist/cli-commands/project.js +7 -4
  6. package/dist/cli-commands/session.js +84 -33
  7. package/dist/cli-commands/thread.js +63 -0
  8. package/dist/cli.js +2 -0
  9. package/dist/commands/queue.js +79 -31
  10. package/dist/commands/queue.test.js +15 -0
  11. package/dist/commands/restart-opencode-server.js +7 -1
  12. package/dist/commands/session-id.js +5 -5
  13. package/dist/database.js +76 -0
  14. package/dist/db.js +34 -0
  15. package/dist/db.test.js +96 -1
  16. package/dist/default-channel-provisioning.test.js +2 -1
  17. package/dist/discord-bot.js +9 -3
  18. package/dist/discord-command-registration.js +1 -1
  19. package/dist/guild-categories.test.js +225 -0
  20. package/dist/interaction-handler.js +5 -1
  21. package/dist/kimaki-opencode-plugin.js +2 -0
  22. package/dist/kimaki-opencode-plugin.test.js +28 -0
  23. package/dist/list-channel-threads.js +163 -0
  24. package/dist/list-channel-threads.test.js +80 -0
  25. package/dist/message-formatting.js +9 -16
  26. package/dist/message-formatting.test.js +91 -2
  27. package/dist/schema.js +21 -0
  28. package/dist/session-handler/event-stream-state.js +53 -5
  29. package/dist/session-handler/event-stream-state.test.js +223 -0
  30. package/dist/session-handler/thread-runtime-state.js +4 -0
  31. package/dist/session-handler/thread-session-runtime.js +314 -73
  32. package/dist/system-message.js +55 -13
  33. package/dist/system-message.test.js +50 -13
  34. package/dist/task-id-plugin.js +16 -0
  35. package/package.json +5 -5
  36. package/skills/strada/SKILL.md +61 -232
  37. package/src/bash-tool-schema-plugin.test.ts +29 -0
  38. package/src/bash-tool-schema-plugin.ts +31 -0
  39. package/src/channel-management.ts +144 -65
  40. package/src/cli-commands/misc.ts +4 -1
  41. package/src/cli-commands/project.ts +9 -4
  42. package/src/cli-commands/session.ts +123 -34
  43. package/src/cli-commands/thread.ts +86 -0
  44. package/src/cli.ts +2 -0
  45. package/src/commands/queue.test.ts +19 -0
  46. package/src/commands/queue.ts +106 -40
  47. package/src/commands/restart-opencode-server.ts +12 -1
  48. package/src/commands/session-id.ts +5 -5
  49. package/src/database.ts +114 -0
  50. package/src/db.test.ts +112 -0
  51. package/src/db.ts +38 -0
  52. package/src/default-channel-provisioning.test.ts +2 -1
  53. package/src/discord-bot.ts +11 -2
  54. package/src/discord-command-registration.ts +3 -1
  55. package/src/guild-categories.test.ts +260 -0
  56. package/src/interaction-handler.ts +7 -0
  57. package/src/kimaki-opencode-plugin.test.ts +34 -0
  58. package/src/kimaki-opencode-plugin.ts +2 -0
  59. package/src/list-channel-threads.test.ts +90 -0
  60. package/src/list-channel-threads.ts +252 -0
  61. package/src/message-formatting.test.ts +105 -1
  62. package/src/message-formatting.ts +7 -12
  63. package/src/schema.sql +16 -0
  64. package/src/schema.ts +23 -0
  65. package/src/session-handler/event-stream-state.test.ts +226 -0
  66. package/src/session-handler/event-stream-state.ts +57 -5
  67. package/src/session-handler/thread-runtime-state.ts +5 -0
  68. package/src/session-handler/thread-session-runtime.ts +380 -81
  69. package/src/system-message.test.ts +50 -13
  70. package/src/system-message.ts +58 -12
  71. package/src/task-id-plugin.ts +17 -0
@@ -3,7 +3,22 @@
3
3
  // Assign jsonSchema only. Leave parameters as the Effect Schema used to decode
4
4
  // args before execute. Extra fields stay in the model-facing schema and in
5
5
  // Discord input; they never reach the shell.
6
+ //
7
+ // Also injects KIMAKI_SESSION_ID into bash env. /btw and /fork clone parent
8
+ // history, so the copied system prompt still says --session <parent>. Upload
9
+ // then prefers this live env over that stale flag.
6
10
  import { z } from 'zod';
11
+ export const KIMAKI_SESSION_ID_ENV = 'KIMAKI_SESSION_ID';
12
+ export function injectKimakiSessionEnv({ sessionID, env, }) {
13
+ if (!sessionID)
14
+ return;
15
+ env[KIMAKI_SESSION_ID_ENV] = sessionID;
16
+ }
17
+ export function resolveUploadToDiscordSessionId({ flagSessionId, envSessionId = process.env[KIMAKI_SESSION_ID_ENV], } = {}) {
18
+ if (envSessionId)
19
+ return envSessionId;
20
+ return flagSessionId;
21
+ }
7
22
  export const bashParameters = z.object({
8
23
  command: z.string().describe('The command to execute'),
9
24
  description: z
@@ -28,5 +43,8 @@ export const bashToolSchemaPlugin = async () => {
28
43
  return;
29
44
  extendBashToolDefinition(output);
30
45
  },
46
+ 'shell.env': async (input, output) => {
47
+ injectKimakiSessionEnv({ sessionID: input.sessionID, env: output.env });
48
+ },
31
49
  };
32
50
  };
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, test } from 'vitest';
2
- import { extendBashToolDefinition, } from './bash-tool-schema-plugin.js';
2
+ import { extendBashToolDefinition, injectKimakiSessionEnv, KIMAKI_SESSION_ID_ENV, resolveUploadToDiscordSessionId, } from './bash-tool-schema-plugin.js';
3
3
  describe('extendBashToolDefinition', () => {
4
4
  test('keeps parameters and assigns jsonSchema from bashParameters', () => {
5
5
  const parameters = { keep: true };
@@ -47,3 +47,22 @@ describe('extendBashToolDefinition', () => {
47
47
  `);
48
48
  });
49
49
  });
50
+ describe('upload-to-discord session targeting', () => {
51
+ test('bash env uses the live OpenCode session, not a copied --session flag', () => {
52
+ const env = {};
53
+ injectKimakiSessionEnv({ sessionID: 'ses_child', env });
54
+ expect(env).toEqual({ [KIMAKI_SESSION_ID_ENV]: 'ses_child' });
55
+ expect(resolveUploadToDiscordSessionId({
56
+ flagSessionId: 'ses_parent',
57
+ envSessionId: env[KIMAKI_SESSION_ID_ENV],
58
+ })).toBe('ses_child');
59
+ });
60
+ test('falls back to --session when bash is not inside an OpenCode session', () => {
61
+ expect(resolveUploadToDiscordSessionId({
62
+ flagSessionId: 'ses_parent',
63
+ })).toBe('ses_parent');
64
+ });
65
+ test('returns undefined when neither live session nor flag is set', () => {
66
+ expect(resolveUploadToDiscordSessionId({})).toBeUndefined();
67
+ });
68
+ });
@@ -4,7 +4,7 @@
4
4
  import { ChannelType, } from 'discord.js';
5
5
  import fs from 'node:fs';
6
6
  import path from 'node:path';
7
- import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, } from './database.js';
7
+ import { getChannelDirectory, setChannelDirectory, findChannelsByDirectory, listTrackedTextChannels, getGuildCategories, setGuildCategoryId, } from './database.js';
8
8
  import { getProjectsDir } from './config.js';
9
9
  import { execAsync } from './worktrees.js';
10
10
  import { createLogger, LogPrefix } from './logger.js';
@@ -39,41 +39,117 @@ async function trackProjectRegistered({ projectKind, source, }) {
39
39
  trackEvent('project_registered', props);
40
40
  }
41
41
  const logger = createLogger(LogPrefix.CHANNEL);
42
- export async function ensureKimakiCategory(guild, botName) {
43
- // Skip appending bot name if it's already "kimaki" to avoid "Kimaki kimaki"
42
+ function defaultCategoryName(kind, botName) {
44
43
  const isKimakiBot = botName?.toLowerCase() === 'kimaki';
45
- const categoryName = botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
46
- const existingCategory = guild.channels.cache.find((channel) => {
47
- if (channel.type !== ChannelType.GuildCategory) {
48
- return false;
44
+ if (kind === 'audio') {
45
+ return botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
46
+ }
47
+ return botName && !isKimakiBot ? `Kimaki ${botName}` : 'Kimaki';
48
+ }
49
+ function defaultKimakiChannelName({ botName, isGatewayMode, }) {
50
+ if (isGatewayMode || !botName)
51
+ return 'kimaki';
52
+ const sanitized = botName
53
+ .toLowerCase()
54
+ .replace(/[^a-z0-9-]/g, '-')
55
+ .replace(/-+/g, '-')
56
+ .replace(/^-|-$/g, '');
57
+ if (!sanitized || sanitized === 'kimaki')
58
+ return 'kimaki';
59
+ return `kimaki-${sanitized}`.slice(0, 100);
60
+ }
61
+ const categoryEnsures = new Map();
62
+ function ensureCategorySerialized({ key, run, }) {
63
+ const existing = categoryEnsures.get(key);
64
+ if (existing)
65
+ return existing;
66
+ const promise = run().finally(() => {
67
+ if (categoryEnsures.get(key) === promise) {
68
+ categoryEnsures.delete(key);
49
69
  }
50
- return channel.name.toLowerCase() === categoryName.toLowerCase();
51
70
  });
52
- if (existingCategory) {
53
- return existingCategory;
71
+ categoryEnsures.set(key, promise);
72
+ return promise;
73
+ }
74
+ function isUnknownDiscordChannel(error) {
75
+ const code = error instanceof Error ? Reflect.get(error, 'code') : undefined;
76
+ const status = error instanceof Error ? Reflect.get(error, 'status') : undefined;
77
+ return code === 10003 || status === 404;
78
+ }
79
+ function isCategoryChannel(channel) {
80
+ return channel?.type === ChannelType.GuildCategory;
81
+ }
82
+ async function fetchCategoryById(guild, categoryId) {
83
+ const cached = guild.channels.cache.get(categoryId);
84
+ if (isCategoryChannel(cached))
85
+ return cached;
86
+ try {
87
+ const fetched = await guild.channels.fetch(categoryId);
88
+ return isCategoryChannel(fetched) ? fetched : null;
89
+ }
90
+ catch (error) {
91
+ if (isUnknownDiscordChannel(error))
92
+ return null;
93
+ throw error;
54
94
  }
55
- return guild.channels.create({
56
- name: categoryName,
57
- type: ChannelType.GuildCategory,
58
- });
59
95
  }
60
- export async function ensureKimakiAudioCategory(guild, botName) {
61
- // Skip appending bot name if it's already "kimaki" to avoid "Kimaki Audio kimaki"
62
- const isKimakiBot = botName?.toLowerCase() === 'kimaki';
63
- const categoryName = botName && !isKimakiBot ? `Kimaki Audio ${botName}` : 'Kimaki Audio';
64
- const existingCategory = guild.channels.cache.find((channel) => {
65
- if (channel.type !== ChannelType.GuildCategory) {
66
- return false;
67
- }
68
- return channel.name.toLowerCase() === categoryName.toLowerCase();
96
+ async function adoptParentFromTrackedChannels({ guild, channelType, }) {
97
+ const mappings = await findChannelsByDirectory({ channelType });
98
+ const channels = await guild.channels.fetch();
99
+ for (const row of mappings) {
100
+ if (row.guild_id && row.guild_id !== guild.id)
101
+ continue;
102
+ const channel = channels.get(row.channel_id);
103
+ if (!channel?.parentId)
104
+ continue;
105
+ const parent = await fetchCategoryById(guild, channel.parentId);
106
+ if (parent)
107
+ return parent;
108
+ }
109
+ return null;
110
+ }
111
+ async function resolveKimakiCategory({ guild, kind, botName, }) {
112
+ const stored = await getGuildCategories(guild.id);
113
+ const storedId = kind === 'audio' ? stored?.audio_category_id : stored?.category_id;
114
+ if (storedId) {
115
+ const existing = await fetchCategoryById(guild, storedId);
116
+ if (existing)
117
+ return existing;
118
+ }
119
+ const adopted = await adoptParentFromTrackedChannels({
120
+ guild,
121
+ channelType: kind === 'audio' ? 'voice' : 'text',
69
122
  });
70
- if (existingCategory) {
71
- return existingCategory;
123
+ if (adopted) {
124
+ await setGuildCategoryId({
125
+ guildId: guild.id,
126
+ kind,
127
+ categoryId: adopted.id,
128
+ });
129
+ return adopted;
72
130
  }
73
- return guild.channels.create({
74
- name: categoryName,
131
+ const created = await guild.channels.create({
132
+ name: defaultCategoryName(kind, botName),
75
133
  type: ChannelType.GuildCategory,
76
134
  });
135
+ await setGuildCategoryId({
136
+ guildId: guild.id,
137
+ kind,
138
+ categoryId: created.id,
139
+ });
140
+ return created;
141
+ }
142
+ export function ensureKimakiCategory(guild, botName) {
143
+ return ensureCategorySerialized({
144
+ key: `${guild.id}:text`,
145
+ run: () => resolveKimakiCategory({ guild, kind: 'text', botName }),
146
+ });
147
+ }
148
+ export function ensureKimakiAudioCategory(guild, botName) {
149
+ return ensureCategorySerialized({
150
+ key: `${guild.id}:audio`,
151
+ run: () => resolveKimakiCategory({ guild, kind: 'audio', botName }),
152
+ });
77
153
  }
78
154
  export async function createProjectChannels({ guild, projectDirectory, botName, enableVoiceChannels = false, analyticsSource = 'cli', }) {
79
155
  const baseName = path.basename(projectDirectory);
@@ -92,6 +168,7 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
92
168
  channelId: textChannel.id,
93
169
  directory: projectDirectory,
94
170
  channelType: 'text',
171
+ guildId: guild.id,
95
172
  });
96
173
  await trackProjectRegistered({
97
174
  projectKind: 'user',
@@ -109,6 +186,7 @@ export async function createProjectChannels({ guild, projectDirectory, botName,
109
186
  channelId: voiceChannel.id,
110
187
  directory: projectDirectory,
111
188
  channelType: 'voice',
189
+ guildId: guild.id,
112
190
  });
113
191
  voiceChannelId = voiceChannel.id;
114
192
  }
@@ -158,8 +236,9 @@ const DEFAULT_CHANNEL_TOPIC = 'General channel for misc tasks with Kimaki. Not c
158
236
  * Directory is ~/.kimaki/projects/kimaki, git-initialized with a .gitignore.
159
237
  *
160
238
  * Idempotency: checks the database for an existing channel mapped to the
161
- * kimaki projects directory. Also scans guild channels by name+category
162
- * as a fallback for channels created before DB mapping existed.
239
+ * kimaki projects directory. Also scans this machine's category for the
240
+ * exact default channel name as a fallback for channels created before
241
+ * DB mapping existed.
163
242
  */
164
243
  export async function createDefaultKimakiChannel({ guild, botName, appId, isGatewayMode, }) {
165
244
  const projectDirectory = getDefaultKimakiDirectory();
@@ -209,11 +288,9 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
209
288
  logger.log(`Default kimaki channel was previously provisioned for guild ${guild.name} (${guild.id}) as ${staleForThisGuild.channel_id}, but no longer exists. Skipping recreation.`);
210
289
  return null;
211
290
  }
212
- // 2. Fallback: detect existing channel by name+category.
213
- // If a "kimaki" channel already exists in the guild but is NOT in our local
214
- // DB, it was likely created by another kimaki instance (different machine).
215
- // Do NOT adopt it — just skip channel creation entirely to avoid both
216
- // instances fighting over the same channel.
291
+ // 2. Fallback: detect an existing default channel in THIS machine's group.
292
+ // A #kimaki channel in another machine's group is ignored.
293
+ const channelName = defaultKimakiChannelName({ botName, isGatewayMode });
217
294
  const kimakiCategory = await ensureKimakiCategory(guild, botName);
218
295
  const existingByName = guild.channels.cache.find((ch) => {
219
296
  if (ch.type !== ChannelType.GuildText) {
@@ -222,10 +299,10 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
222
299
  if (ch.parentId !== kimakiCategory.id) {
223
300
  return false;
224
301
  }
225
- return ch.name === 'kimaki' || ch.name.startsWith('kimaki-');
302
+ return ch.name === channelName;
226
303
  });
227
304
  if (existingByName) {
228
- logger.log(`Found existing default kimaki channel by name: ${existingByName.id}, but it is not in our DB — skipping (likely owned by another kimaki instance)`);
305
+ logger.log(`Found existing default kimaki channel by name: ${existingByName.id}. Skipping recreation.`);
229
306
  return null;
230
307
  }
231
308
  // Git init — gracefully skip if git is not installed
@@ -244,21 +321,6 @@ export async function createDefaultKimakiChannel({ guild, botName, appId, isGate
244
321
  if (!fs.existsSync(gitignorePath)) {
245
322
  fs.writeFileSync(gitignorePath, DEFAULT_GITIGNORE);
246
323
  }
247
- // Channel name: "kimaki-{botName}" for self-hosted, "kimaki" for gateway
248
- const channelName = (() => {
249
- if (isGatewayMode || !botName) {
250
- return 'kimaki';
251
- }
252
- const sanitized = botName
253
- .toLowerCase()
254
- .replace(/[^a-z0-9-]/g, '-')
255
- .replace(/-+/g, '-')
256
- .replace(/^-|-$/g, '');
257
- if (!sanitized || sanitized === 'kimaki') {
258
- return 'kimaki';
259
- }
260
- return `kimaki-${sanitized}`.slice(0, 100);
261
- })();
262
324
  const textChannel = await guild.channels.create({
263
325
  name: channelName,
264
326
  type: ChannelType.GuildText,
@@ -24,6 +24,7 @@ import { execAsync, validateWorktreeDirectory } from '../worktrees.js';
24
24
  import { upgrade, getCurrentVersion } from '../upgrade.js';
25
25
  import { getPromptPreview, parseSendAtValue, parseScheduledTaskPayload, serializeScheduledTaskPayload } from '../task-schedule.js';
26
26
  import { EXIT_NO_RESTART, formatMemberLookupUnavailableMessage, formatRelativeTime, formatTaskScheduleLine, isDiscordMemberLookupUnavailable, isGuildMemberSearchResult, isThreadChannelType, printDiscordInstallUrlAndExit, resolveBotCredentials, resolveDiscordUserOption, sendDiscordMessageWithOptionalAttachment, } from '../cli-runner.js';
27
+ import { resolveUploadToDiscordSessionId } from '../bash-tool-schema-plugin.js';
27
28
  const cliLogger = createLogger(LogPrefix.CLI);
28
29
  const cli = goke();
29
30
  cli
@@ -31,7 +32,9 @@ cli
31
32
  .option('-s, --session <sessionId>', 'OpenCode session ID')
32
33
  .action(async (files, options) => {
33
34
  try {
34
- const { session: sessionId } = options;
35
+ const sessionId = resolveUploadToDiscordSessionId({
36
+ flagSessionId: options.session,
37
+ });
35
38
  if (!sessionId) {
36
39
  cliLogger.error('Session ID is required. Use --session <sessionId>');
37
40
  process.exit(EXIT_NO_RESTART);
@@ -12,7 +12,7 @@ import { spawn, execSync } from 'node:child_process';
12
12
  import { createLogger, LogPrefix, initLogFile } from '../logger.js';
13
13
  import { createDiscordClient, initDatabase, getChannelDirectory, initializeOpencodeForDirectory, createProjectChannels } from '../discord-bot.js';
14
14
  import { getDefaultKimakiDirectory } from '../channel-management.js';
15
- import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError } from '../database.js';
15
+ import { getBotTokenWithMode, getThreadSession, getThreadIdBySessionId, getSessionEventSnapshot, getDb, createScheduledTask, listScheduledTasks, cancelScheduledTask, getScheduledTask, updateScheduledTask, getSessionStartSourcesBySessionIds, deleteChannelDirectoryById, findChannelsByDirectory, findRegisteredTextChannelForDirectory, formatProjectAlreadyRegisteredError, listGuildCategoryIds } from '../database.js';
16
16
  import { ShareMarkdown } from '../markdown.js';
17
17
  import { parseSessionSearchPattern, findFirstSessionSearchHit, buildSessionSearchSnippet, getPartSearchTexts } from '../session-search.js';
18
18
  import { formatWorktreeName, formatAutoWorktreeName } from '../commands/new-worktree.js';
@@ -88,7 +88,7 @@ cli
88
88
  cli
89
89
  .command('project list', 'List all registered projects with their Discord channels')
90
90
  .option('--json', 'Output as JSON')
91
- .option('--all', 'Include remote projects from other machines (scans Kimaki category in Discord)')
91
+ .option('--all', 'Include remote projects from other machines (scans Kimaki groups in Discord)')
92
92
  .option('-g, --guild <guildId>', 'Discord guild/server ID to scan (used with --all when no local projects exist)')
93
93
  .option('--prune', 'Remove stale entries whose Discord channel no longer exists')
94
94
  .action(async (options) => {
@@ -158,12 +158,15 @@ cli
158
158
  process.exit(EXIT_NO_RESTART);
159
159
  }
160
160
  let guildScanFailures = 0;
161
+ const storedCategoryIds = new Set(await listGuildCategoryIds());
161
162
  for (const guildId of uniqueGuildIds) {
162
163
  try {
163
164
  const guildChannels = (await rest.get(Routes.guildChannels(guildId)));
164
- // Find Kimaki category channels (type 4 = GuildCategory)
165
+ // Kimaki groups: name still starts with Kimaki, or this machine stored the id
166
+ // after a rename. Other machines' renamed groups cannot be found by name.
165
167
  const kimakiCategoryIds = new Set(guildChannels
166
- .filter((ch) => ch.type === 4 && /^kimaki(\s|$)/i.test(ch.name))
168
+ .filter((ch) => ch.type === 4 &&
169
+ (/^kimaki(\s|$)/i.test(ch.name) || storedCategoryIds.has(ch.id)))
167
170
  .map((ch) => ch.id));
168
171
  // Find text channels (type 0) in Kimaki categories that are not in our local DB
169
172
  for (const ch of guildChannels) {
@@ -54,45 +54,82 @@ 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
- cliLogger.log('Connecting to OpenCode server...');
68
- const getClient = await initializeOpencodeForDirectory(projectDirectory);
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 sessionsResponse = await getClient().session.list();
74
- const sessions = sessionsResponse.data || [];
75
- const statuses = await (async () => {
76
- if (!options.active)
77
- return null;
78
- const response = await getClient().session.status({
79
- directory: projectDirectory,
80
- });
81
- if (response.error) {
82
- cliLogger.error('Failed to list active sessions');
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
- return response.data || {};
86
- })();
87
- const selectedSessions = sessions.filter((session) => {
88
- if (session.id === options.exclude)
108
+ const client = getClient();
109
+ const [sessionsResponse, statusResponse] = await Promise.all([
110
+ client.session.list(),
111
+ client.session.status({ directory: projectDirectory }).catch(() => null),
112
+ ]);
113
+ const statuses = statusResponse?.data || {};
114
+ for (const session of sessionsResponse.data || []) {
115
+ const status = statuses[session.id];
116
+ gathered.push({
117
+ session,
118
+ projectDirectory,
119
+ status: status && status.type !== 'idle' ? 'busy' : 'idle',
120
+ });
121
+ }
122
+ }
123
+ const selected = gathered
124
+ .filter((entry) => {
125
+ if (entry.session.id === options.exclude)
89
126
  return false;
90
- if (!options.active)
91
- return true;
92
- const status = statuses?.[session.id];
93
- return Boolean(status && status.type !== 'idle');
94
- });
95
- if (selectedSessions.length === 0) {
127
+ if (options.active && entry.status !== 'busy')
128
+ return false;
129
+ return true;
130
+ })
131
+ .sort((a, b) => b.session.time.updated - a.session.time.updated);
132
+ if (selected.length === 0) {
96
133
  if (options.json)
97
134
  console.log('[]');
98
135
  else
@@ -107,15 +144,26 @@ cli
107
144
  const sessionToThread = new Map(threadSessions
108
145
  .filter((row) => row.session_id !== '')
109
146
  .map((row) => [row.session_id, row.thread_id]));
110
- const sessionStartSources = await getSessionStartSourcesBySessionIds(selectedSessions.map((session) => session.id));
147
+ const sessionStartSources = await getSessionStartSourcesBySessionIds(selected.map((entry) => entry.session.id));
111
148
  const scheduleModeLabel = ({ scheduleKind, }) => {
112
149
  if (scheduleKind === 'at') {
113
150
  return 'delay';
114
151
  }
115
152
  return 'cron';
116
153
  };
154
+ // Token footprint straight from the session object (no message fetch).
155
+ // Session.tokens is a per-session aggregate, so it is reported as a token
156
+ // count rather than a context-window percentage.
157
+ const contextInfo = (entry) => {
158
+ const tokens = entry.session.tokens;
159
+ if (!tokens)
160
+ return null;
161
+ const total = getSessionTokenTotal(tokens);
162
+ return total > 0 ? total : null;
163
+ };
117
164
  if (options.json) {
118
- const output = selectedSessions.map((session) => {
165
+ const output = selected.map((entry) => {
166
+ const session = entry.session;
119
167
  const startSource = sessionStartSources.get(session.id);
120
168
  const startedBy = startSource
121
169
  ? `scheduled-${scheduleModeLabel({ scheduleKind: startSource.schedule_kind })}`
@@ -127,15 +175,18 @@ cli
127
175
  updated: new Date(session.time.updated).toISOString(),
128
176
  source: sessionToThread.has(session.id) ? 'kimaki' : 'opencode',
129
177
  threadId: sessionToThread.get(session.id) || null,
178
+ status: entry.status,
179
+ model: session.model?.id || null,
180
+ tokens: contextInfo(entry),
130
181
  startedBy,
131
182
  scheduledTaskId: startSource?.scheduled_task_id || null,
132
- status: options.active ? statuses?.[session.id]?.type || 'busy' : undefined,
133
183
  };
134
184
  });
135
185
  console.log(JSON.stringify(output, null, 2));
136
186
  process.exit(0);
137
187
  }
138
- for (const session of selectedSessions) {
188
+ for (const entry of selected) {
189
+ const session = entry.session;
139
190
  const threadId = sessionToThread.get(session.id);
140
191
  const startSource = sessionStartSources.get(session.id);
141
192
  const source = threadId ? '(kimaki)' : '(opencode)';
@@ -144,10 +195,10 @@ cli
144
195
  : '';
145
196
  const updatedAt = new Date(session.time.updated).toISOString();
146
197
  const threadInfo = threadId ? ` | thread: ${threadId}` : '';
147
- const statusInfo = options.active
148
- ? ` | status: ${statuses?.[session.id]?.type || 'busy'}`
149
- : '';
150
- console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${threadInfo}${startedBy}${statusInfo}`);
198
+ const statusInfo = ` | status: ${entry.status === 'busy' ? 'working' : 'idle'}`;
199
+ const tokens = contextInfo(entry);
200
+ const tokensText = tokens ? ` | tokens: ${formatTokenCount(tokens)}` : '';
201
+ console.log(`${session.id} | ${session.title || 'Untitled Session'} | ${session.directory} | ${updatedAt} | ${source}${statusInfo}${tokensText}${threadInfo}${startedBy}`);
151
202
  }
152
203
  process.exit(0);
153
204
  }
@@ -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.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);
@@ -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);