kimaki 0.5.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 (75) hide show
  1. package/dist/anthropic-auth-plugin.js +16 -12
  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 +39 -0
  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 +2 -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-interrupt-plugin.js +29 -2
  20. package/dist/opencode.js +31 -25
  21. package/dist/orphan-opencode-sweep.test.js +80 -0
  22. package/dist/plugin-logger.js +9 -0
  23. package/dist/session-handler/event-stream-state.js +29 -1
  24. package/dist/session-handler/event-stream-state.test.js +70 -1
  25. package/dist/session-handler/thread-session-runtime.js +4 -0
  26. package/dist/store.js +1 -1
  27. package/dist/subagent-rate-limit-plugin.js +175 -0
  28. package/dist/subagent-rate-limit-plugin.test.js +120 -0
  29. package/dist/system-message.js +77 -30
  30. package/dist/system-message.test.js +88 -32
  31. package/dist/system-prompt-drift-plugin.js +1 -5
  32. package/dist/thread-message-queue.e2e.test.js +2 -2
  33. package/dist/voice.js +10 -1
  34. package/package.json +8 -7
  35. package/skills/batch/SKILL.md +1 -1
  36. package/skills/goke/SKILL.md +1 -1
  37. package/skills/new-skill/SKILL.md +4 -2
  38. package/skills/npm-package/SKILL.md +62 -23
  39. package/skills/opensrc/SKILL.md +78 -0
  40. package/skills/profano/SKILL.md +5 -13
  41. package/skills/sigillo/SKILL.md +101 -0
  42. package/skills/spiceflow/SKILL.md +16 -2
  43. package/skills/tuistory/SKILL.md +60 -212
  44. package/skills/zele/SKILL.md +32 -124
  45. package/src/anthropic-auth-plugin.ts +21 -18
  46. package/src/cli.ts +3 -3
  47. package/src/commands/add-dir.test.ts +45 -0
  48. package/src/commands/add-dir.ts +1 -1
  49. package/src/commands/btw.ts +2 -2
  50. package/src/commands/fork-subagent.ts +263 -0
  51. package/src/commands/fork.ts +105 -40
  52. package/src/discord-command-registration.ts +7 -2
  53. package/src/format-tables.test.ts +168 -8
  54. package/src/format-tables.ts +282 -9
  55. package/src/hrana-server.ts +12 -27
  56. package/src/interaction-handler.ts +17 -1
  57. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +13 -5
  58. package/src/kimaki-opencode-plugin.ts +2 -1
  59. package/src/message-preprocessing.ts +5 -4
  60. package/src/onboarding-tutorial.ts +6 -15
  61. package/src/opencode-interrupt-plugin.ts +32 -2
  62. package/src/opencode.ts +43 -35
  63. package/src/plugin-logger.ts +16 -0
  64. package/src/session-handler/event-stream-state.test.ts +74 -0
  65. package/src/session-handler/event-stream-state.ts +54 -2
  66. package/src/session-handler/thread-session-runtime.ts +4 -0
  67. package/src/store.ts +1 -1
  68. package/src/subagent-rate-limit-plugin.ts +218 -0
  69. package/src/system-message.test.ts +103 -44
  70. package/src/system-message.ts +77 -30
  71. package/src/thread-message-queue.e2e.test.ts +2 -2
  72. package/src/voice.ts +11 -0
  73. package/skills/gitchamber/SKILL.md +0 -93
  74. package/skills/jitter/dist/jitter-utils.js +0 -620
  75. package/src/system-prompt-drift-plugin.ts +0 -365
@@ -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) => {
@@ -8,13 +8,14 @@
8
8
  // - context-awareness-plugin: branch, pwd, memory reminder, onboarding tutorial
9
9
  // - memory-overview-plugin: frozen MEMORY.md heading overview per session
10
10
  // - opencode-interrupt-plugin: interrupt queued messages at step boundaries
11
+ // - subagent-rate-limit-plugin: aborts only task subagents after rate limits
11
12
  // - kitty-graphics-plugin: extract Kitty Graphics Protocol images from bash output
12
13
  export { ipcToolsPlugin } from './ipc-tools-plugin.js';
13
14
  export { contextAwarenessPlugin } from './context-awareness-plugin.js';
14
15
  export { memoryOverviewPlugin } from './memory-overview-plugin.js';
15
16
  export { interruptOpencodeSessionOnUserMessage } from './opencode-interrupt-plugin.js';
16
- export { systemPromptDriftPlugin } from './system-prompt-drift-plugin.js';
17
17
  export { anthropicAuthPlugin } from './anthropic-auth-plugin.js';
18
18
  export { imageOptimizerPlugin } from './image-optimizer-plugin.js';
19
+ export { subagentRateLimitPlugin } from './subagent-rate-limit-plugin.js';
19
20
  export { kittyGraphicsPlugin } from 'kitty-graphics-agent';
20
21
  export { injectionGuardInternal as injectionGuard } from 'opencode-injection-guard';
@@ -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.