kimaki 0.4.38 → 0.4.39

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 (49) hide show
  1. package/dist/cli.js +9 -3
  2. package/dist/commands/abort.js +15 -6
  3. package/dist/commands/add-project.js +9 -0
  4. package/dist/commands/agent.js +13 -1
  5. package/dist/commands/fork.js +13 -2
  6. package/dist/commands/model.js +12 -0
  7. package/dist/commands/remove-project.js +26 -16
  8. package/dist/commands/resume.js +9 -0
  9. package/dist/commands/session.js +13 -0
  10. package/dist/commands/share.js +10 -1
  11. package/dist/commands/undo-redo.js +13 -4
  12. package/dist/database.js +9 -5
  13. package/dist/discord-bot.js +21 -8
  14. package/dist/errors.js +110 -0
  15. package/dist/genai-worker.js +18 -16
  16. package/dist/markdown.js +96 -85
  17. package/dist/markdown.test.js +10 -3
  18. package/dist/message-formatting.js +50 -37
  19. package/dist/opencode.js +43 -46
  20. package/dist/session-handler.js +100 -2
  21. package/dist/system-message.js +2 -0
  22. package/dist/tools.js +18 -8
  23. package/dist/voice-handler.js +48 -25
  24. package/dist/voice.js +159 -131
  25. package/package.json +2 -1
  26. package/src/cli.ts +12 -3
  27. package/src/commands/abort.ts +17 -7
  28. package/src/commands/add-project.ts +9 -0
  29. package/src/commands/agent.ts +13 -1
  30. package/src/commands/fork.ts +18 -7
  31. package/src/commands/model.ts +12 -0
  32. package/src/commands/remove-project.ts +28 -16
  33. package/src/commands/resume.ts +9 -0
  34. package/src/commands/session.ts +13 -0
  35. package/src/commands/share.ts +11 -1
  36. package/src/commands/undo-redo.ts +15 -6
  37. package/src/database.ts +9 -4
  38. package/src/discord-bot.ts +21 -7
  39. package/src/errors.ts +208 -0
  40. package/src/genai-worker.ts +20 -17
  41. package/src/markdown.test.ts +13 -3
  42. package/src/markdown.ts +111 -95
  43. package/src/message-formatting.ts +55 -38
  44. package/src/opencode.ts +52 -49
  45. package/src/session-handler.ts +118 -3
  46. package/src/system-message.ts +2 -0
  47. package/src/tools.ts +18 -8
  48. package/src/voice-handler.ts +48 -23
  49. package/src/voice.ts +195 -148
@@ -3,12 +3,13 @@
3
3
  // Resamples 24kHz GenAI output to 48kHz stereo Opus packets for Discord.
4
4
  import { parentPort, threadId } from 'node:worker_threads';
5
5
  import { createWriteStream } from 'node:fs';
6
- import { mkdir } from 'node:fs/promises';
7
6
  import path from 'node:path';
7
+ import * as errore from 'errore';
8
8
  import { Resampler } from '@purinton/resampler';
9
9
  import * as prism from 'prism-media';
10
10
  import { startGenAiSession } from './genai.js';
11
11
  import { getTools } from './tools.js';
12
+ import { mkdir } from 'node:fs/promises';
12
13
  import { createLogger } from './logger.js';
13
14
  if (!parentPort) {
14
15
  throw new Error('This module must be run as a worker thread');
@@ -98,23 +99,24 @@ async function createAssistantAudioLogStream(guildId, channelId) {
98
99
  return null;
99
100
  const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
100
101
  const audioDir = path.join(process.cwd(), 'discord-audio-logs', guildId, channelId);
101
- try {
102
- await mkdir(audioDir, { recursive: true });
103
- // Create stream for assistant audio (24kHz mono s16le PCM)
104
- const outputFileName = `assistant_${timestamp}.24.pcm`;
105
- const outputFilePath = path.join(audioDir, outputFileName);
106
- const outputAudioStream = createWriteStream(outputFilePath);
107
- // Add error handler to prevent crashes
108
- outputAudioStream.on('error', (error) => {
109
- workerLogger.error(`Assistant audio log stream error:`, error);
110
- });
111
- workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
112
- return outputAudioStream;
113
- }
114
- catch (error) {
115
- workerLogger.error(`Failed to create audio log directory:`, error);
102
+ const mkdirError = await errore.tryAsync({
103
+ try: () => mkdir(audioDir, { recursive: true }),
104
+ catch: (e) => e,
105
+ });
106
+ if (errore.isError(mkdirError)) {
107
+ workerLogger.error(`Failed to create audio log directory:`, mkdirError.message);
116
108
  return null;
117
109
  }
110
+ // Create stream for assistant audio (24kHz mono s16le PCM)
111
+ const outputFileName = `assistant_${timestamp}.24.pcm`;
112
+ const outputFilePath = path.join(audioDir, outputFileName);
113
+ const outputAudioStream = createWriteStream(outputFilePath);
114
+ // Add error handler to prevent crashes
115
+ outputAudioStream.on('error', (error) => {
116
+ workerLogger.error(`Assistant audio log stream error:`, error);
117
+ });
118
+ workerLogger.log(`Created assistant audio log: ${outputFilePath}`);
119
+ return outputAudioStream;
118
120
  }
119
121
  // Handle encoded Opus packets
120
122
  opusEncoder.on('data', (packet) => {
package/dist/markdown.js CHANGED
@@ -1,10 +1,16 @@
1
1
  // Session-to-markdown renderer for sharing.
2
2
  // Generates shareable markdown from OpenCode sessions, formatting
3
3
  // user messages, assistant responses, tool calls, and reasoning blocks.
4
+ // Uses errore for type-safe error handling.
5
+ import * as errore from 'errore';
4
6
  import * as yaml from 'js-yaml';
5
7
  import { formatDateTime } from './utils.js';
6
8
  import { extractNonXmlContent } from './xml.js';
7
9
  import { createLogger } from './logger.js';
10
+ import { SessionNotFoundError, MessagesNotFoundError } from './errors.js';
11
+ // Generic error for unexpected exceptions in async operations
12
+ class UnexpectedError extends errore.TaggedError('UnexpectedError')() {
13
+ }
8
14
  const markdownLogger = createLogger('MARKDOWN');
9
15
  export class ShareMarkdown {
10
16
  client;
@@ -14,7 +20,7 @@ export class ShareMarkdown {
14
20
  /**
15
21
  * Generate a markdown representation of a session
16
22
  * @param options Configuration options
17
- * @returns Markdown string representation of the session
23
+ * @returns Error or markdown string
18
24
  */
19
25
  async generate(options) {
20
26
  const { sessionID, includeSystemInfo, lastAssistantOnly } = options;
@@ -23,7 +29,7 @@ export class ShareMarkdown {
23
29
  path: { id: sessionID },
24
30
  });
25
31
  if (!sessionResponse.data) {
26
- throw new Error(`Session ${sessionID} not found`);
32
+ return new SessionNotFoundError({ sessionId: sessionID });
27
33
  }
28
34
  const session = sessionResponse.data;
29
35
  // Get all messages
@@ -31,7 +37,7 @@ export class ShareMarkdown {
31
37
  path: { id: sessionID },
32
38
  });
33
39
  if (!messagesResponse.data) {
34
- throw new Error(`No messages found for session ${sessionID}`);
40
+ return new MessagesNotFoundError({ sessionId: sessionID });
35
41
  }
36
42
  const messages = messagesResponse.data;
37
43
  // If lastAssistantOnly, filter to only the last assistant message
@@ -211,98 +217,103 @@ export class ShareMarkdown {
211
217
  * Includes system prompt (optional), user messages, assistant text,
212
218
  * and tool calls in compact form (name + params only, no output).
213
219
  */
214
- export async function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
215
- try {
216
- const messagesResponse = await client.session.messages({
217
- path: { id: sessionId },
218
- });
219
- const messages = messagesResponse.data || [];
220
- const lines = [];
221
- // Get system prompt if requested
222
- // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
223
- // 1. session.system field (if available in future SDK versions)
224
- // 2. synthetic text part in first assistant message (current approach)
225
- if (includeSystemPrompt && messages.length > 0) {
226
- const firstAssistant = messages.find((m) => m.info.role === 'assistant');
227
- if (firstAssistant) {
228
- // look for text part marked as synthetic (system prompt)
229
- const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
230
- if (systemPart && 'text' in systemPart && systemPart.text) {
231
- lines.push('[System Prompt]');
232
- const truncated = systemPart.text.slice(0, 3000);
233
- lines.push(truncated);
234
- if (systemPart.text.length > 3000) {
235
- lines.push('...(truncated)');
220
+ export function getCompactSessionContext({ client, sessionId, includeSystemPrompt = false, maxMessages = 20, }) {
221
+ return errore.tryAsync({
222
+ try: async () => {
223
+ const messagesResponse = await client.session.messages({
224
+ path: { id: sessionId },
225
+ });
226
+ const messages = messagesResponse.data || [];
227
+ const lines = [];
228
+ // Get system prompt if requested
229
+ // Note: OpenCode SDK doesn't expose system prompt directly. We try multiple approaches:
230
+ // 1. session.system field (if available in future SDK versions)
231
+ // 2. synthetic text part in first assistant message (current approach)
232
+ if (includeSystemPrompt && messages.length > 0) {
233
+ const firstAssistant = messages.find((m) => m.info.role === 'assistant');
234
+ if (firstAssistant) {
235
+ // look for text part marked as synthetic (system prompt)
236
+ const systemPart = (firstAssistant.parts || []).find((p) => p.type === 'text' && p.synthetic === true);
237
+ if (systemPart && 'text' in systemPart && systemPart.text) {
238
+ lines.push('[System Prompt]');
239
+ const truncated = systemPart.text.slice(0, 3000);
240
+ lines.push(truncated);
241
+ if (systemPart.text.length > 3000) {
242
+ lines.push('...(truncated)');
243
+ }
244
+ lines.push('');
236
245
  }
237
- lines.push('');
238
- }
239
- }
240
- }
241
- // Process recent messages
242
- const recentMessages = messages.slice(-maxMessages);
243
- for (const msg of recentMessages) {
244
- if (msg.info.role === 'user') {
245
- const textParts = (msg.parts || [])
246
- .filter((p) => p.type === 'text' && 'text' in p)
247
- .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
248
- .filter(Boolean);
249
- if (textParts.length > 0) {
250
- lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
251
- lines.push('');
252
246
  }
253
247
  }
254
- else if (msg.info.role === 'assistant') {
255
- // Get assistant text parts (non-synthetic, non-empty)
256
- const textParts = (msg.parts || [])
257
- .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
258
- .map((p) => ('text' in p ? p.text : ''))
259
- .filter(Boolean);
260
- if (textParts.length > 0) {
261
- lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
262
- lines.push('');
248
+ // Process recent messages
249
+ const recentMessages = messages.slice(-maxMessages);
250
+ for (const msg of recentMessages) {
251
+ if (msg.info.role === 'user') {
252
+ const textParts = (msg.parts || [])
253
+ .filter((p) => p.type === 'text' && 'text' in p)
254
+ .map((p) => ('text' in p ? extractNonXmlContent(p.text || '') : ''))
255
+ .filter(Boolean);
256
+ if (textParts.length > 0) {
257
+ lines.push(`[User]: ${textParts.join(' ').slice(0, 1000)}`);
258
+ lines.push('');
259
+ }
263
260
  }
264
- // Get tool calls in compact form (name + params only)
265
- const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
266
- for (const part of toolParts) {
267
- if (part.type === 'tool' && 'tool' in part && 'state' in part) {
268
- const toolName = part.tool;
269
- // skip noisy tools
270
- if (toolName === 'todoread' || toolName === 'todowrite') {
271
- continue;
261
+ else if (msg.info.role === 'assistant') {
262
+ // Get assistant text parts (non-synthetic, non-empty)
263
+ const textParts = (msg.parts || [])
264
+ .filter((p) => p.type === 'text' && 'text' in p && !p.synthetic && p.text)
265
+ .map((p) => ('text' in p ? p.text : ''))
266
+ .filter(Boolean);
267
+ if (textParts.length > 0) {
268
+ lines.push(`[Assistant]: ${textParts.join(' ').slice(0, 1000)}`);
269
+ lines.push('');
270
+ }
271
+ // Get tool calls in compact form (name + params only)
272
+ const toolParts = (msg.parts || []).filter((p) => p.type === 'tool' && 'state' in p && p.state?.status === 'completed');
273
+ for (const part of toolParts) {
274
+ if (part.type === 'tool' && 'tool' in part && 'state' in part) {
275
+ const toolName = part.tool;
276
+ // skip noisy tools
277
+ if (toolName === 'todoread' || toolName === 'todowrite') {
278
+ continue;
279
+ }
280
+ const input = part.state?.input || {};
281
+ const normalize = (value) => value.replace(/\s+/g, ' ').trim();
282
+ // compact params: just key=value on one line
283
+ const params = Object.entries(input)
284
+ .map(([k, v]) => {
285
+ const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
286
+ return `${k}=${normalize(val)}`;
287
+ })
288
+ .join(', ');
289
+ lines.push(`[Tool ${toolName}]: ${params}`);
272
290
  }
273
- const input = part.state?.input || {};
274
- // compact params: just key=value on one line
275
- const params = Object.entries(input)
276
- .map(([k, v]) => {
277
- const val = typeof v === 'string' ? v.slice(0, 100) : JSON.stringify(v).slice(0, 100);
278
- return `${k}=${val}`;
279
- })
280
- .join(', ');
281
- lines.push(`[Tool ${toolName}]: ${params}`);
282
291
  }
283
292
  }
284
293
  }
285
- }
286
- return lines.join('\n').slice(0, 8000);
287
- }
288
- catch (e) {
289
- markdownLogger.error('Failed to get compact session context:', e);
290
- return '';
291
- }
294
+ return lines.join('\n').slice(0, 8000);
295
+ },
296
+ catch: (e) => {
297
+ markdownLogger.error('Failed to get compact session context:', e);
298
+ return new UnexpectedError({ message: 'Failed to get compact session context', cause: e });
299
+ },
300
+ });
292
301
  }
293
302
  /**
294
303
  * Get the last session for a directory (excluding the current one).
295
304
  */
296
- export async function getLastSessionId({ client, excludeSessionId, }) {
297
- try {
298
- const sessionsResponse = await client.session.list();
299
- const sessions = sessionsResponse.data || [];
300
- // Sessions are sorted by time, get the most recent one that isn't the current
301
- const lastSession = sessions.find((s) => s.id !== excludeSessionId);
302
- return lastSession?.id || null;
303
- }
304
- catch (e) {
305
- markdownLogger.error('Failed to get last session:', e);
306
- return null;
307
- }
305
+ export function getLastSessionId({ client, excludeSessionId, }) {
306
+ return errore.tryAsync({
307
+ try: async () => {
308
+ const sessionsResponse = await client.session.list();
309
+ const sessions = sessionsResponse.data || [];
310
+ // Sessions are sorted by time, get the most recent one that isn't the current
311
+ const lastSession = sessions.find((s) => s.id !== excludeSessionId);
312
+ return lastSession?.id || null;
313
+ },
314
+ catch: (e) => {
315
+ markdownLogger.error('Failed to get last session:', e);
316
+ return new UnexpectedError({ message: 'Failed to get last session', cause: e });
317
+ },
318
+ });
308
319
  }
@@ -1,6 +1,7 @@
1
1
  import { test, expect, beforeAll, afterAll } from 'vitest';
2
2
  import { spawn } from 'child_process';
3
3
  import { OpencodeClient } from '@opencode-ai/sdk';
4
+ import * as errore from 'errore';
4
5
  import { ShareMarkdown, getCompactSessionContext } from './markdown.js';
5
6
  let serverProcess;
6
7
  let client;
@@ -100,10 +101,12 @@ test('generate markdown from first available session', async () => {
100
101
  // Create markdown exporter
101
102
  const exporter = new ShareMarkdown(client);
102
103
  // Generate markdown with system info
103
- const markdown = await exporter.generate({
104
+ const markdownResult = await exporter.generate({
104
105
  sessionID,
105
106
  includeSystemInfo: true,
106
107
  });
108
+ expect(errore.isOk(markdownResult)).toBe(true);
109
+ const markdown = errore.unwrap(markdownResult);
107
110
  console.log(`Generated markdown length: ${markdown.length} characters`);
108
111
  // Basic assertions
109
112
  expect(markdown).toBeTruthy();
@@ -233,12 +236,14 @@ test('generate markdown from multiple sessions', async () => {
233
236
  // test for getCompactSessionContext - disabled in CI since it requires a specific session
234
237
  test.skipIf(process.env.CI)('getCompactSessionContext generates compact format', async () => {
235
238
  const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
236
- const context = await getCompactSessionContext({
239
+ const contextResult = await getCompactSessionContext({
237
240
  client,
238
241
  sessionId,
239
242
  includeSystemPrompt: true,
240
243
  maxMessages: 15,
241
244
  });
245
+ expect(errore.isOk(contextResult)).toBe(true);
246
+ const context = errore.unwrap(contextResult);
242
247
  console.log(`Generated compact context length: ${context.length} characters`);
243
248
  expect(context).toBeTruthy();
244
249
  expect(context.length).toBeGreaterThan(0);
@@ -248,12 +253,14 @@ test.skipIf(process.env.CI)('getCompactSessionContext generates compact format',
248
253
  });
249
254
  test.skipIf(process.env.CI)('getCompactSessionContext without system prompt', async () => {
250
255
  const sessionId = 'ses_46c2205e8ffeOll1JUSuYChSAM';
251
- const context = await getCompactSessionContext({
256
+ const contextResult = await getCompactSessionContext({
252
257
  client,
253
258
  sessionId,
254
259
  includeSystemPrompt: false,
255
260
  maxMessages: 10,
256
261
  });
262
+ expect(errore.isOk(contextResult)).toBe(true);
263
+ const context = errore.unwrap(contextResult);
257
264
  console.log(`Generated compact context (no system) length: ${context.length} characters`);
258
265
  expect(context).toBeTruthy();
259
266
  // should NOT have system prompt
@@ -3,7 +3,9 @@
3
3
  // handles file attachments, and provides tool summary generation.
4
4
  import fs from 'node:fs';
5
5
  import path from 'node:path';
6
+ import * as errore from 'errore';
6
7
  import { createLogger } from './logger.js';
8
+ import { FetchError } from './errors.js';
7
9
  const ATTACHMENTS_DIR = path.join(process.cwd(), 'tmp', 'discord-attachments');
8
10
  const logger = createLogger('FORMATTING');
9
11
  /**
@@ -56,18 +58,18 @@ export async function getTextAttachments(message) {
56
58
  return '';
57
59
  }
58
60
  const textContents = await Promise.all(textAttachments.map(async (attachment) => {
59
- try {
60
- const response = await fetch(attachment.url);
61
- if (!response.ok) {
62
- return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
63
- }
64
- const text = await response.text();
65
- return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
61
+ const response = await errore.tryAsync({
62
+ try: () => fetch(attachment.url),
63
+ catch: (e) => new FetchError({ url: attachment.url, cause: e }),
64
+ });
65
+ if (errore.isError(response)) {
66
+ return `<attachment filename="${attachment.name}" error="${response.message}" />`;
66
67
  }
67
- catch (error) {
68
- const errMsg = error instanceof Error ? error.message : String(error);
69
- return `<attachment filename="${attachment.name}" error="${errMsg}" />`;
68
+ if (!response.ok) {
69
+ return `<attachment filename="${attachment.name}" error="Failed to fetch: ${response.status}" />`;
70
70
  }
71
+ const text = await response.text();
72
+ return `<attachment filename="${attachment.name}" mime="${attachment.contentType}">\n${text}\n</attachment>`;
71
73
  }));
72
74
  return textContents.join('\n\n');
73
75
  }
@@ -84,27 +86,28 @@ export async function getFileAttachments(message) {
84
86
  fs.mkdirSync(ATTACHMENTS_DIR, { recursive: true });
85
87
  }
86
88
  const results = await Promise.all(fileAttachments.map(async (attachment) => {
87
- try {
88
- const response = await fetch(attachment.url);
89
- if (!response.ok) {
90
- logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
91
- return null;
92
- }
93
- const buffer = Buffer.from(await response.arrayBuffer());
94
- const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
95
- fs.writeFileSync(localPath, buffer);
96
- logger.log(`Downloaded attachment to ${localPath}`);
97
- return {
98
- type: 'file',
99
- mime: attachment.contentType || 'application/octet-stream',
100
- filename: attachment.name,
101
- url: localPath,
102
- };
89
+ const response = await errore.tryAsync({
90
+ try: () => fetch(attachment.url),
91
+ catch: (e) => new FetchError({ url: attachment.url, cause: e }),
92
+ });
93
+ if (errore.isError(response)) {
94
+ logger.error(`Error downloading attachment ${attachment.name}:`, response.message);
95
+ return null;
103
96
  }
104
- catch (error) {
105
- logger.error(`Error downloading attachment ${attachment.name}:`, error);
97
+ if (!response.ok) {
98
+ logger.error(`Failed to fetch attachment ${attachment.name}: ${response.status}`);
106
99
  return null;
107
100
  }
101
+ const buffer = Buffer.from(await response.arrayBuffer());
102
+ const localPath = path.join(ATTACHMENTS_DIR, `${message.id}-${attachment.name}`);
103
+ fs.writeFileSync(localPath, buffer);
104
+ logger.log(`Downloaded attachment to ${localPath}`);
105
+ return {
106
+ type: 'file',
107
+ mime: attachment.contentType || 'application/octet-stream',
108
+ filename: attachment.name,
109
+ url: localPath,
110
+ };
108
111
  }));
109
112
  return results.filter((r) => r !== null);
110
113
  }
@@ -157,9 +160,9 @@ export function getToolSummaryText(part) {
157
160
  if (part.tool === 'bash' || part.tool === 'todoread' || part.tool === 'todowrite') {
158
161
  return '';
159
162
  }
163
+ // Task tool display is handled via subtask part in session-handler (shows label like explore-1)
160
164
  if (part.tool === 'task') {
161
- const description = part.state.input?.description || '';
162
- return description ? `_${escapeInlineMarkdown(description)}_` : '';
165
+ return '';
163
166
  }
164
167
  if (part.tool === 'skill') {
165
168
  const name = part.state.input?.name || '';
@@ -197,10 +200,15 @@ export function formatTodoList(part) {
197
200
  const content = activeTodo.content.charAt(0).toLowerCase() + activeTodo.content.slice(1);
198
201
  return `${num} **${escapeInlineMarkdown(content)}**`;
199
202
  }
200
- export function formatPart(part) {
203
+ export function formatPart(part, prefix) {
204
+ const pfx = prefix ? `${prefix}: ` : '';
201
205
  if (part.type === 'text') {
202
206
  if (!part.text?.trim())
203
207
  return '';
208
+ // For subtask text, always use bullet with prefix
209
+ if (prefix) {
210
+ return `⬥ ${pfx}${part.text.trim()}`;
211
+ }
204
212
  const trimmed = part.text.trimStart();
205
213
  const firstChar = trimmed[0] || '';
206
214
  const markdownStarters = ['#', '*', '_', '-', '>', '`', '[', '|'];
@@ -213,28 +221,33 @@ export function formatPart(part) {
213
221
  if (part.type === 'reasoning') {
214
222
  if (!part.text?.trim())
215
223
  return '';
216
- return `┣ thinking`;
224
+ return `┣ ${pfx}thinking`;
217
225
  }
218
226
  if (part.type === 'file') {
219
- return `📄 ${part.filename || 'File'}`;
227
+ return prefix ? `📄 ${pfx}${part.filename || 'File'}` : `📄 ${part.filename || 'File'}`;
220
228
  }
221
229
  if (part.type === 'step-start' || part.type === 'step-finish' || part.type === 'patch') {
222
230
  return '';
223
231
  }
224
232
  if (part.type === 'agent') {
225
- return `┣ agent ${part.id}`;
233
+ return `┣ ${pfx}agent ${part.id}`;
226
234
  }
227
235
  if (part.type === 'snapshot') {
228
- return `┣ snapshot ${part.snapshot}`;
236
+ return `┣ ${pfx}snapshot ${part.snapshot}`;
229
237
  }
230
238
  if (part.type === 'tool') {
231
239
  if (part.tool === 'todowrite') {
232
- return formatTodoList(part);
240
+ const formatted = formatTodoList(part);
241
+ return prefix && formatted ? `┣ ${pfx}${formatted}` : formatted;
233
242
  }
234
243
  // Question tool is handled via Discord dropdowns, not text
235
244
  if (part.tool === 'question') {
236
245
  return '';
237
246
  }
247
+ // Task tool display is handled in session-handler with proper label
248
+ if (part.tool === 'task') {
249
+ return '';
250
+ }
238
251
  if (part.state.status === 'pending') {
239
252
  return '';
240
253
  }
@@ -270,7 +283,7 @@ export function formatPart(part) {
270
283
  }
271
284
  return '┣';
272
285
  })();
273
- return `${icon} ${part.tool} ${toolTitle} ${summaryText}`;
286
+ return `${icon} ${pfx}${part.tool} ${toolTitle} ${summaryText}`.trim();
274
287
  }
275
288
  logger.warn('Unknown part type:', part);
276
289
  return '';