kimaki 0.6.0 → 0.7.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 (67) hide show
  1. package/dist/anthropic-auth-plugin.js +10 -3
  2. package/dist/bundled-skills.js +37 -0
  3. package/dist/cli.js +3 -3
  4. package/dist/commands/add-dir.js +1 -1
  5. package/dist/commands/add-dir.test.js +32 -28
  6. package/dist/commands/btw.js +2 -2
  7. package/dist/commands/fork-subagent.js +177 -0
  8. package/dist/commands/fork.js +71 -29
  9. package/dist/discord-command-registration.js +7 -2
  10. package/dist/format-tables.js +197 -8
  11. package/dist/format-tables.test.js +153 -2
  12. package/dist/hrana-server.js +12 -24
  13. package/dist/interaction-handler.js +9 -1
  14. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +12 -5
  15. package/dist/kimaki-opencode-plugin.js +0 -1
  16. package/dist/message-preprocessing.js +5 -4
  17. package/dist/message-preprocessing.test.js +35 -0
  18. package/dist/onboarding-tutorial.js +6 -15
  19. package/dist/opencode.js +13 -1
  20. package/dist/orphan-opencode-sweep.test.js +80 -0
  21. package/dist/session-handler/event-stream-state.js +29 -1
  22. package/dist/session-handler/event-stream-state.test.js +70 -1
  23. package/dist/store.js +1 -1
  24. package/dist/system-message.js +77 -30
  25. package/dist/system-message.test.js +88 -32
  26. package/dist/thread-message-queue.e2e.test.js +2 -2
  27. package/dist/tools.js +16 -24
  28. package/dist/voice.js +10 -1
  29. package/package.json +7 -6
  30. package/skills/batch/SKILL.md +1 -1
  31. package/skills/goke/SKILL.md +1 -1
  32. package/skills/new-skill/SKILL.md +1 -1
  33. package/skills/npm-package/SKILL.md +62 -23
  34. package/skills/profano/SKILL.md +5 -13
  35. package/skills/sigillo/SKILL.md +101 -0
  36. package/skills/spiceflow/SKILL.md +16 -2
  37. package/skills/tuistory/SKILL.md +60 -212
  38. package/skills/zele/SKILL.md +32 -124
  39. package/src/anthropic-auth-plugin.ts +13 -4
  40. package/src/cli.ts +3 -3
  41. package/src/commands/add-dir.test.ts +35 -28
  42. package/src/commands/add-dir.ts +1 -1
  43. package/src/commands/btw.ts +2 -2
  44. package/src/commands/fork-subagent.ts +263 -0
  45. package/src/commands/fork.ts +105 -40
  46. package/src/discord-command-registration.ts +7 -2
  47. package/src/format-tables.test.ts +168 -8
  48. package/src/format-tables.ts +282 -9
  49. package/src/hrana-server.ts +12 -27
  50. package/src/interaction-handler.ts +17 -1
  51. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +13 -5
  52. package/src/kimaki-opencode-plugin.ts +0 -1
  53. package/src/message-preprocessing.ts +5 -4
  54. package/src/onboarding-tutorial.ts +6 -15
  55. package/src/opencode.ts +18 -1
  56. package/src/session-handler/event-stream-state.test.ts +74 -0
  57. package/src/session-handler/event-stream-state.ts +54 -2
  58. package/src/store.ts +1 -1
  59. package/src/system-message.test.ts +103 -44
  60. package/src/system-message.ts +77 -30
  61. package/src/thread-message-queue.e2e.test.ts +2 -2
  62. package/src/tools.ts +26 -41
  63. package/src/voice.ts +11 -0
  64. package/skills/jitter/dist/jitter-utils.js +0 -620
  65. package/src/bash-tool.test.ts +0 -103
  66. package/src/bash-tool.ts +0 -287
  67. package/src/system-prompt-drift-plugin.ts +0 -354
@@ -15,12 +15,75 @@ const MAX_COMPONENTS = 40;
15
15
  * pairs. Large tables are split across multiple component segments.
16
16
  */
17
17
  export function splitTablesFromMarkdown(markdown, options = {}) {
18
+ const blocks = splitMarkdownByCallouts({ markdown });
19
+ return blocks.flatMap((block) => {
20
+ if (block.type === 'callout') {
21
+ const innerSegments = splitTablesFromMarkdown(block.content, options);
22
+ return buildCalloutSegments({
23
+ segments: innerSegments,
24
+ callout: block.callout,
25
+ });
26
+ }
27
+ return splitTableSegmentsFromText({
28
+ markdown: block.text,
29
+ options,
30
+ });
31
+ });
32
+ }
33
+ function splitMarkdownByCallouts({ markdown, }) {
34
+ const lines = markdown.match(/.*(?:\n|$)/g)?.filter((line) => {
35
+ return line.length > 0;
36
+ }) ?? [markdown];
37
+ const blocks = [];
38
+ let textBuffer = '';
39
+ for (let index = 0; index < lines.length; index++) {
40
+ const line = lines[index];
41
+ const callout = parseCalloutOpenLine({ line });
42
+ if (!callout) {
43
+ textBuffer += line;
44
+ continue;
45
+ }
46
+ if (textBuffer.length > 0) {
47
+ blocks.push({ type: 'text', text: textBuffer });
48
+ textBuffer = '';
49
+ }
50
+ const body = collectCalloutBodyFromLines({
51
+ lines,
52
+ startIndex: index,
53
+ });
54
+ if (body instanceof Error) {
55
+ textBuffer += line;
56
+ continue;
57
+ }
58
+ blocks.push({
59
+ type: 'callout',
60
+ content: body.content,
61
+ callout,
62
+ });
63
+ index = body.endIndex;
64
+ }
65
+ if (textBuffer.length > 0) {
66
+ blocks.push({ type: 'text', text: textBuffer });
67
+ }
68
+ return blocks;
69
+ }
70
+ function splitTableSegmentsFromText({ markdown, options, }) {
18
71
  const lexer = new Lexer();
19
- const tokens = lexer.lex(markdown);
72
+ return splitTokensIntoSegments({
73
+ tokens: lexer.lex(markdown),
74
+ options,
75
+ });
76
+ }
77
+ function splitTokensIntoSegments({ tokens, options, }) {
20
78
  const segments = [];
21
79
  let textBuffer = '';
80
+ const isTableToken = (token) => {
81
+ return (token.type === 'table' &&
82
+ Object.hasOwn(token, 'header') &&
83
+ Object.hasOwn(token, 'rows'));
84
+ };
22
85
  for (const token of tokens) {
23
- if (token.type === 'table') {
86
+ if (isTableToken(token)) {
24
87
  if (textBuffer.trim()) {
25
88
  segments.push({ type: 'text', text: textBuffer });
26
89
  textBuffer = '';
@@ -37,6 +100,126 @@ export function splitTablesFromMarkdown(markdown, options = {}) {
37
100
  }
38
101
  return segments;
39
102
  }
103
+ function buildCalloutSegments({ segments, callout, }) {
104
+ const children = flattenCalloutChildren({ segments });
105
+ if (children.length === 0) {
106
+ return [];
107
+ }
108
+ const chunks = chunkCalloutChildrenByComponentLimit({ children });
109
+ return chunks.map((chunk) => {
110
+ const container = {
111
+ type: ComponentType.Container,
112
+ ...(callout.accentColor !== undefined
113
+ ? { accent_color: callout.accentColor }
114
+ : {}),
115
+ components: chunk,
116
+ };
117
+ const components = [container];
118
+ return {
119
+ type: 'components',
120
+ components,
121
+ };
122
+ });
123
+ }
124
+ function flattenCalloutChildren({ segments, }) {
125
+ return segments.flatMap((segment) => {
126
+ if (segment.type === 'text') {
127
+ if (!segment.text.trim()) {
128
+ return [];
129
+ }
130
+ return [
131
+ {
132
+ type: ComponentType.TextDisplay,
133
+ content: segment.text.trim(),
134
+ },
135
+ ];
136
+ }
137
+ return segment.components.flatMap((component) => {
138
+ if (component.type !== ComponentType.Container) {
139
+ return [];
140
+ }
141
+ return component.components;
142
+ });
143
+ });
144
+ }
145
+ function chunkCalloutChildrenByComponentLimit({ children, }) {
146
+ const chunks = [];
147
+ let currentChunk = [];
148
+ for (const child of children) {
149
+ if (currentChunk.length > 0 && currentChunk.length + 2 > MAX_COMPONENTS) {
150
+ chunks.push(currentChunk);
151
+ currentChunk = [];
152
+ }
153
+ currentChunk.push(child);
154
+ }
155
+ if (currentChunk.length > 0) {
156
+ chunks.push(currentChunk);
157
+ }
158
+ return chunks;
159
+ }
160
+ function collectCalloutBodyFromLines({ lines, startIndex, }) {
161
+ let depth = 0;
162
+ const contentLines = [];
163
+ for (let index = startIndex; index < lines.length; index++) {
164
+ const line = lines[index];
165
+ const nestedCallout = parseCalloutOpenLine({ line });
166
+ if (nestedCallout) {
167
+ if (depth > 0) {
168
+ contentLines.push(line);
169
+ }
170
+ depth += 1;
171
+ continue;
172
+ }
173
+ if (/^<\/callout>$/i.test(line.trim())) {
174
+ depth -= 1;
175
+ if (depth === 0) {
176
+ return {
177
+ content: contentLines.join(''),
178
+ endIndex: index,
179
+ };
180
+ }
181
+ contentLines.push(line);
182
+ continue;
183
+ }
184
+ if (depth > 0) {
185
+ contentLines.push(line);
186
+ }
187
+ }
188
+ return new Error('Unclosed <callout> block');
189
+ }
190
+ function parseCalloutOpenLine({ line, }) {
191
+ const match = line.trim().match(/^<callout(?:\s+[^>]*)?>$/i);
192
+ if (!match) {
193
+ return null;
194
+ }
195
+ const accentValue = line.match(/\baccent=(['"])(.*?)\1/i)?.[2]?.trim();
196
+ const accentColor = accentValue
197
+ ? parseAccentColor({ value: accentValue })
198
+ : undefined;
199
+ return {
200
+ accentColor: accentColor instanceof Error ? undefined : accentColor,
201
+ };
202
+ }
203
+ function parseAccentColor({ value, }) {
204
+ const hex = value.trim().toLowerCase();
205
+ if (/^#[0-9a-f]{6}$/.test(hex)) {
206
+ return Number.parseInt(hex.slice(1), 16);
207
+ }
208
+ if (/^#[0-9a-f]{3}$/.test(hex)) {
209
+ const expanded = hex
210
+ .slice(1)
211
+ .split('')
212
+ .map((char) => {
213
+ return `${char}${char}`;
214
+ })
215
+ .join('');
216
+ return Number.parseInt(expanded, 16);
217
+ }
218
+ if (/^\d+$/.test(hex)) {
219
+ return Number.parseInt(hex, 10);
220
+ }
221
+ return new Error(`Unsupported callout accent color: ${value}`);
222
+ }
40
223
  /**
41
224
  * Build CV2 components for a table. Plain rows render as one TextDisplay with
42
225
  * bold key-value lines. Rows with resolved button cells render as a TextDisplay
@@ -72,9 +255,10 @@ export function buildTableComponents(table, options = {}) {
72
255
  type: ComponentType.Container,
73
256
  components: children,
74
257
  };
258
+ const components = [container];
75
259
  return {
76
260
  type: 'components',
77
- components: [container],
261
+ components,
78
262
  };
79
263
  });
80
264
  }
@@ -289,12 +473,17 @@ function extractTokenText(token) {
289
473
  case 'br':
290
474
  return ' ';
291
475
  default: {
292
- const tokenAny = token;
293
- if (tokenAny.tokens && Array.isArray(tokenAny.tokens)) {
294
- return extractCellText(tokenAny.tokens);
476
+ const nestedTokens = Reflect.get(token, 'tokens');
477
+ if (Array.isArray(nestedTokens)) {
478
+ return extractCellText(nestedTokens.filter((value) => {
479
+ return (typeof value === 'object' &&
480
+ value !== null &&
481
+ typeof Reflect.get(value, 'type') === 'string');
482
+ }));
295
483
  }
296
- if (typeof tokenAny.text === 'string') {
297
- return tokenAny.text;
484
+ const text = Reflect.get(token, 'text');
485
+ if (typeof text === 'string') {
486
+ return text;
298
487
  }
299
488
  return '';
300
489
  }
@@ -1,10 +1,22 @@
1
1
  import { test, expect, describe } from 'vitest';
2
2
  import { splitTablesFromMarkdown, buildTableComponents, } from './format-tables.js';
3
3
  import { Lexer } from 'marked';
4
+ import { ComponentType } from 'discord.js';
5
+ function isTableToken(token) {
6
+ return (token.type === 'table' &&
7
+ Object.hasOwn(token, 'header') &&
8
+ Object.hasOwn(token, 'rows'));
9
+ }
4
10
  function parseTable(markdown) {
5
11
  const lexer = new Lexer();
6
12
  const tokens = lexer.lex(markdown);
7
- return tokens.find((t) => t.type === 'table');
13
+ const table = tokens.find((token) => {
14
+ return isTableToken(token);
15
+ });
16
+ if (!table || !isTableToken(table)) {
17
+ throw new Error('Expected markdown to contain a table token');
18
+ }
19
+ return table;
8
20
  }
9
21
  /** Extract the first container's children from buildTableComponents result */
10
22
  function getContainerChildren(segments) {
@@ -13,7 +25,20 @@ function getContainerChildren(segments) {
13
25
  throw new Error('Expected components segment');
14
26
  }
15
27
  const container = seg.components[0];
16
- return container.components;
28
+ if (!container || container.type !== ComponentType.Container) {
29
+ throw new Error('Expected first top-level component to be a container');
30
+ }
31
+ return container.components.map((component) => {
32
+ const content = component.type === ComponentType.TextDisplay ? component.content : undefined;
33
+ const divider = component.type === ComponentType.Separator ? component.divider : undefined;
34
+ const spacing = component.type === ComponentType.Separator ? component.spacing : undefined;
35
+ return {
36
+ type: component.type,
37
+ content,
38
+ divider,
39
+ spacing,
40
+ };
41
+ });
17
42
  }
18
43
  describe('buildTableComponents', () => {
19
44
  test('builds container with key-value TextDisplays', () => {
@@ -305,4 +330,130 @@ Done.`);
305
330
  ]
306
331
  `);
307
332
  });
333
+ test('renders callout text inside an accented container', () => {
334
+ const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
335
+ ## Important
336
+
337
+ Read this first.
338
+ </callout>`);
339
+ expect(result).toMatchInlineSnapshot(`
340
+ [
341
+ {
342
+ "components": [
343
+ {
344
+ "accent_color": 2850815,
345
+ "components": [
346
+ {
347
+ "content": "## Important
348
+
349
+ Read this first.",
350
+ "type": 10,
351
+ },
352
+ ],
353
+ "type": 17,
354
+ },
355
+ ],
356
+ "type": "components",
357
+ },
358
+ ]
359
+ `);
360
+ });
361
+ test('renders tables inside callouts recursively', () => {
362
+ const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
363
+ ## Important
364
+
365
+ | Key | Value |
366
+ | --- | --- |
367
+ | a | 1 |
368
+ </callout>`);
369
+ expect(result).toMatchInlineSnapshot(`
370
+ [
371
+ {
372
+ "components": [
373
+ {
374
+ "accent_color": 2850815,
375
+ "components": [
376
+ {
377
+ "content": "## Important",
378
+ "type": 10,
379
+ },
380
+ {
381
+ "content": "**Key** a
382
+ **Value** 1",
383
+ "type": 10,
384
+ },
385
+ ],
386
+ "type": 17,
387
+ },
388
+ ],
389
+ "type": "components",
390
+ },
391
+ ]
392
+ `);
393
+ });
394
+ test('renders button rows inside callouts recursively', () => {
395
+ const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
396
+ ## Actions
397
+
398
+ | Name | Action |
399
+ | --- | --- |
400
+ | feature-a | <button id="delete-a" variant="secondary">Delete</button> |
401
+ </callout>`, {
402
+ resolveButtonCustomId: ({ button }) => {
403
+ return `html_action:${button.id}`;
404
+ },
405
+ });
406
+ expect(result).toMatchInlineSnapshot(`
407
+ [
408
+ {
409
+ "components": [
410
+ {
411
+ "accent_color": 2850815,
412
+ "components": [
413
+ {
414
+ "content": "## Actions",
415
+ "type": 10,
416
+ },
417
+ {
418
+ "content": "**Name** feature-a",
419
+ "type": 10,
420
+ },
421
+ {
422
+ "components": [
423
+ {
424
+ "custom_id": "html_action:delete-a",
425
+ "disabled": false,
426
+ "label": "Delete",
427
+ "style": 2,
428
+ "type": 2,
429
+ },
430
+ ],
431
+ "type": 1,
432
+ },
433
+ ],
434
+ "type": 17,
435
+ },
436
+ ],
437
+ "type": "components",
438
+ },
439
+ ]
440
+ `);
441
+ });
442
+ test('falls back to plain text when a callout is not closed', () => {
443
+ const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
444
+ ## Important
445
+
446
+ Still open`);
447
+ expect(result).toMatchInlineSnapshot(`
448
+ [
449
+ {
450
+ "text": "<callout accent="#2b7fff">
451
+ ## Important
452
+
453
+ Still open",
454
+ "type": "text",
455
+ },
456
+ ]
457
+ `);
458
+ });
308
459
  });
@@ -235,29 +235,17 @@ export async function evictExistingInstance({ port }) {
235
235
  hranaLogger.log(`Failed to kill PID ${targetPid}: ${killResult.message}`);
236
236
  return;
237
237
  }
238
- await new Promise((resolve) => {
239
- setTimeout(resolve, 1000);
240
- });
241
- // Verify it's gone — if still alive, escalate to SIGKILL
242
- const secondProbe = await fetch(url, {
243
- signal: AbortSignal.timeout(500),
244
- }).catch((e) => new FetchError({ url, cause: e }));
245
- if (secondProbe instanceof Error)
246
- return;
247
- hranaLogger.log(`PID ${targetPid} still alive after SIGTERM, sending SIGKILL`);
248
- const forceKillResult = errore.try({
249
- try: () => {
250
- process.kill(targetPid, 'SIGKILL');
251
- },
252
- catch: (e) => new Error('Failed to send SIGKILL to existing kimaki process', {
253
- cause: e,
254
- }),
255
- });
256
- if (forceKillResult instanceof Error) {
257
- hranaLogger.log(`Failed to force-kill PID ${targetPid}: ${forceKillResult.message}`);
258
- return;
238
+ for (let attempt = 0; attempt < 10; attempt += 1) {
239
+ await new Promise((resolve) => {
240
+ setTimeout(resolve, 1000);
241
+ });
242
+ // Verify it's gone. Some shutdown paths need a few seconds to run cleanup,
243
+ // so we avoid SIGKILL and just poll for up to 10 seconds.
244
+ const secondProbe = await fetch(url, {
245
+ signal: AbortSignal.timeout(2000),
246
+ }).catch((e) => new FetchError({ url, cause: e }));
247
+ if (secondProbe instanceof Error)
248
+ return;
259
249
  }
260
- await new Promise((resolve) => {
261
- setTimeout(resolve, 1000);
262
- });
250
+ hranaLogger.log(`PID ${targetPid} still alive after 10s SIGTERM grace period`);
263
251
  }
@@ -18,7 +18,8 @@ import { handleAddDirCommand } from './commands/add-dir.js';
18
18
  import { handleCompactCommand } from './commands/compact.js';
19
19
  import { handleShareCommand } from './commands/share.js';
20
20
  import { handleDiffCommand } from './commands/diff.js';
21
- import { handleForkCommand, handleForkSelectMenu } from './commands/fork.js';
21
+ import { handleForkCommand, handleForkSelectMenu, } from './commands/fork.js';
22
+ import { handleForkSubagentCommand, handleForkSubagentSelectMenu, } from './commands/fork-subagent.js';
22
23
  import { handleBtwCommand } from './commands/btw.js';
23
24
  import { handleModelCommand, handleProviderSelectMenu, handleModelSelectMenu, handleModelScopeSelectMenu, } from './commands/model.js';
24
25
  import { handleUnsetModelCommand } from './commands/unset-model.js';
@@ -154,6 +155,9 @@ export function registerInteractionHandler({ discordClient, appId, }) {
154
155
  case 'fork':
155
156
  await handleForkCommand(interaction);
156
157
  return;
158
+ case 'fork-subagent':
159
+ await handleForkSubagentCommand(interaction);
160
+ return;
157
161
  case 'btw':
158
162
  await handleBtwCommand({ command: interaction, appId });
159
163
  return;
@@ -306,6 +310,10 @@ export function registerInteractionHandler({ discordClient, appId, }) {
306
310
  await handleForkSelectMenu(interaction);
307
311
  return;
308
312
  }
313
+ if (customId.startsWith('fork_subagent_select:')) {
314
+ await handleForkSubagentSelectMenu(interaction);
315
+ return;
316
+ }
309
317
  if (customId.startsWith('model_provider:')) {
310
318
  await handleProviderSelectMenu(interaction);
311
319
  return;
@@ -35,6 +35,17 @@ test('opencode server loads plugin without errors', async () => {
35
35
  const pluginPath = new URL('../src/kimaki-opencode-plugin.ts', import.meta.url).href;
36
36
  const stderrLines = [];
37
37
  const isolatedOpencodeRoot = path.join(projectDir, 'opencode-test-home');
38
+ const xdgDirectories = {
39
+ OPENCODE_CONFIG_DIR: path.join(isolatedOpencodeRoot, '.opencode-kimaki'),
40
+ XDG_CONFIG_HOME: path.join(isolatedOpencodeRoot, '.config'),
41
+ XDG_DATA_HOME: path.join(isolatedOpencodeRoot, '.local', 'share'),
42
+ XDG_CACHE_HOME: path.join(isolatedOpencodeRoot, '.cache'),
43
+ XDG_STATE_HOME: path.join(isolatedOpencodeRoot, '.local', 'state'),
44
+ };
45
+ fs.mkdirSync(isolatedOpencodeRoot, { recursive: true });
46
+ Object.values(xdgDirectories).forEach((directory) => {
47
+ fs.mkdirSync(directory, { recursive: true });
48
+ });
38
49
  const { command, args, windowsVerbatimArguments, } = getSpawnCommandAndArgs({
39
50
  resolvedCommand: resolveOpencodeCommand(),
40
51
  baseArgs: ['serve', '--port', port.toString(), '--print-logs', '--log-level', 'DEBUG'],
@@ -52,11 +63,7 @@ test('opencode server loads plugin without errors', async () => {
52
63
  plugin: [pluginPath],
53
64
  }),
54
65
  OPENCODE_TEST_HOME: isolatedOpencodeRoot,
55
- OPENCODE_CONFIG_DIR: path.join(isolatedOpencodeRoot, '.opencode-kimaki'),
56
- XDG_CONFIG_HOME: path.join(isolatedOpencodeRoot, '.config'),
57
- XDG_DATA_HOME: path.join(isolatedOpencodeRoot, '.local', 'share'),
58
- XDG_CACHE_HOME: path.join(isolatedOpencodeRoot, '.cache'),
59
- XDG_STATE_HOME: path.join(isolatedOpencodeRoot, '.local', 'state'),
66
+ ...xdgDirectories,
60
67
  },
61
68
  });
62
69
  serverProcess.stderr?.on('data', (data) => {
@@ -14,7 +14,6 @@ export { ipcToolsPlugin } from './ipc-tools-plugin.js';
14
14
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
15
15
  export { memoryOverviewPlugin } from './memory-overview-plugin.js';
16
16
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
17
- export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
18
17
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
19
18
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
20
19
  export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js';
@@ -37,12 +37,13 @@ async function fetchAvailableAgents(getClient, directory) {
37
37
  return { name: a.name, description: a.description };
38
38
  });
39
39
  }
40
- // Matches punctuation + "queue" at the end of a message (case-insensitive).
41
- // Supports any common punctuation before "queue" (. ! ? , ; :) and an optional
42
- // trailing period: ". queue", "! queue", ". queue.", "!queue." etc.
40
+ // Matches explicit queue markers at the end of a message (case-insensitive).
41
+ // Supported forms:
42
+ // - punctuation + queue: ". queue", "! queue", ". queue.", "!queue."
43
+ // - queue as its own final line: "text\nqueue" or just "queue"
43
44
  // When present the suffix is stripped and the message is routed through
44
45
  // kimaki's local queue (same as /queue command).
45
- const QUEUE_SUFFIX_RE = /[.!?,;:]\s*queue\.?\s*$/i;
46
+ const QUEUE_SUFFIX_RE = /(?:[.!?,;:]|^)\s*queue\.?\s*$|\n\s*queue\.?\s*$/i;
46
47
  const REPLIED_MESSAGE_TEXT_LIMIT = 1_000;
47
48
  function extractQueueSuffix(prompt) {
48
49
  if (!QUEUE_SUFFIX_RE.test(prompt)) {
@@ -0,0 +1,35 @@
1
+ // Tests queue suffix parsing for incoming Discord messages.
2
+ import { describe, expect, test } from 'vitest';
3
+ import { extractQueueSuffix } from './message-preprocessing.js';
4
+ describe('extractQueueSuffix', () => {
5
+ test('supports queue as its own final line', () => {
6
+ expect([
7
+ extractQueueSuffix('Fix the bug\nqueue'),
8
+ extractQueueSuffix('Fix the bug\n queue.'),
9
+ extractQueueSuffix('queue'),
10
+ ]).toMatchInlineSnapshot(`
11
+ [
12
+ {
13
+ "forceQueue": true,
14
+ "prompt": "Fix the bug",
15
+ },
16
+ {
17
+ "forceQueue": true,
18
+ "prompt": "Fix the bug",
19
+ },
20
+ {
21
+ "forceQueue": true,
22
+ "prompt": "",
23
+ },
24
+ ]
25
+ `);
26
+ });
27
+ test('does not treat plain trailing queue word on same line as a queue marker', () => {
28
+ expect(extractQueueSuffix('Tell me about queue')).toMatchInlineSnapshot(`
29
+ {
30
+ "forceQueue": false,
31
+ "prompt": "Tell me about queue",
32
+ }
33
+ `);
34
+ });
35
+ });
@@ -29,21 +29,13 @@ ${backticks}bash
29
29
  curl -fsSL https://bun.sh/install | bash
30
30
  ${backticks}
31
31
 
32
- **tmux** — needed to run the dev server in the background with kimaki tunnel:
32
+ **tuistory** — needed to run the dev server in the background with kimaki tunnel:
33
33
 
34
34
  ${backticks}bash
35
- tmux -V
35
+ bunx tuistory --help
36
36
  ${backticks}
37
37
 
38
- If missing, tell the user to install it: https://github.com/tmux/tmux/wiki/Installing — or:
39
-
40
- ${backticks}bash
41
- # macOS
42
- brew install tmux
43
-
44
- # Ubuntu/Debian
45
- sudo apt-get install tmux
46
- ${backticks}
38
+ This works without installing it globally because \`bunx\` can run it on demand.
47
39
 
48
40
  Do NOT use Node.js, npm, or npx. Use Bun for everything.
49
41
 
@@ -140,15 +132,14 @@ Pick a random port between 3000-9000 to avoid conflicts:
140
132
 
141
133
  ${backticks}bash
142
134
  PORT=$((RANDOM % 6000 + 3000))
143
- tmux kill-session -t game-dev 2>/dev/null
144
- tmux new-session -d -s game-dev -c "$PWD"
145
- tmux send-keys -t game-dev "PORT=$PORT kimaki tunnel -p $PORT -- bun run server.ts" Enter
135
+ bunx tuistory launch "PORT=$PORT kimaki tunnel -p $PORT -- bun run server.ts" -s game-dev --cwd "$PWD"
146
136
  ${backticks}
147
137
 
148
138
  Wait a moment, then get the tunnel URL:
149
139
 
150
140
  ${backticks}bash
151
- sleep 1 && tmux capture-pane -t game-dev -p
141
+ bunx tuistory -s game-dev wait "/tunnel|https?:\/\//i" --timeout 30000
142
+ bunx tuistory read -s game-dev
152
143
  ${backticks}
153
144
 
154
145
  If the tunnel URL is not visible yet, run the capture command again — it usually appears within a few seconds.
package/dist/opencode.js CHANGED
@@ -337,6 +337,11 @@ async function waitForServer({ port, directory, maxAttempts = 300, startupStderr
337
337
  // In-flight promise to prevent concurrent startups from racing
338
338
  let startingServer = null;
339
339
  let preferredStartupDirectory = null;
340
+ function ensureOpencodeHomeDirectories({ directories, }) {
341
+ Object.values(directories).map((directory) => {
342
+ fs.mkdirSync(directory, { recursive: true });
343
+ });
344
+ }
340
345
  async function ensureSingleServer({ directory, } = {}) {
341
346
  const startupDirectory = directory || preferredStartupDirectory || undefined;
342
347
  if (singleServer && !singleServer.process.killed) {
@@ -422,7 +427,7 @@ async function startSingleServer({ directory, } = {}) {
422
427
  return {};
423
428
  }
424
429
  const root = path.join(getDataDir(), 'opencode-vitest-home');
425
- return {
430
+ const directories = {
426
431
  OPENCODE_TEST_HOME: root,
427
432
  OPENCODE_CONFIG_DIR: path.join(root, '.opencode-kimaki'),
428
433
  XDG_CONFIG_HOME: path.join(root, '.config'),
@@ -430,6 +435,13 @@ async function startSingleServer({ directory, } = {}) {
430
435
  XDG_CACHE_HOME: path.join(root, '.cache'),
431
436
  XDG_STATE_HOME: path.join(root, '.local', 'state'),
432
437
  };
438
+ // OpenCode writes state/config files into these XDG locations during boot.
439
+ // In CI, a fresh temp data dir means the parent folders may not exist yet,
440
+ // and some writes fail closed with NotFound before OpenCode has a chance to
441
+ // create them lazily. Pre-create the directories so startup-time tests do
442
+ // not flap based on process scheduling.
443
+ ensureOpencodeHomeDirectories({ directories });
444
+ return directories;
433
445
  })();
434
446
  // Write config to a file instead of passing via OPENCODE_CONFIG_CONTENT env var.
435
447
  // OPENCODE_CONFIG (file path) is loaded before project config in opencode's