@otto-assistant/bridge 0.4.102 → 0.4.103

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 (70) hide show
  1. package/dist/agent-model.e2e.test.js +1 -0
  2. package/dist/anthropic-auth-plugin.js +22 -1
  3. package/dist/anthropic-auth-state.js +31 -0
  4. package/dist/btw-prefix-detection.js +17 -0
  5. package/dist/btw-prefix-detection.test.js +63 -0
  6. package/dist/cli.js +101 -15
  7. package/dist/commands/agent.js +21 -2
  8. package/dist/commands/ask-question.js +50 -4
  9. package/dist/commands/ask-question.test.js +92 -0
  10. package/dist/commands/btw.js +71 -66
  11. package/dist/commands/new-worktree.js +92 -35
  12. package/dist/commands/queue.js +17 -0
  13. package/dist/commands/worktrees.js +196 -139
  14. package/dist/context-awareness-plugin.js +16 -8
  15. package/dist/context-awareness-plugin.test.js +4 -2
  16. package/dist/discord-bot.js +35 -2
  17. package/dist/discord-command-registration.js +9 -2
  18. package/dist/memory-overview-plugin.js +3 -1
  19. package/dist/opencode.js +9 -0
  20. package/dist/queue-question-select-drain.e2e.test.js +135 -10
  21. package/dist/session-handler/thread-runtime-state.js +27 -0
  22. package/dist/session-handler/thread-session-runtime.js +58 -28
  23. package/dist/session-title-rename.test.js +12 -0
  24. package/dist/skill-filter.js +31 -0
  25. package/dist/skill-filter.test.js +65 -0
  26. package/dist/store.js +2 -0
  27. package/dist/system-message.js +12 -3
  28. package/dist/system-message.test.js +10 -6
  29. package/dist/thread-message-queue.e2e.test.js +109 -0
  30. package/dist/worktree-lifecycle.e2e.test.js +4 -1
  31. package/dist/worktrees.js +106 -12
  32. package/dist/worktrees.test.js +232 -6
  33. package/package.json +2 -2
  34. package/skills/goke/SKILL.md +13 -619
  35. package/skills/new-skill/SKILL.md +34 -10
  36. package/skills/npm-package/SKILL.md +336 -2
  37. package/skills/profano/SKILL.md +24 -0
  38. package/skills/zele/SKILL.md +50 -21
  39. package/src/agent-model.e2e.test.ts +1 -0
  40. package/src/anthropic-auth-plugin.ts +24 -4
  41. package/src/anthropic-auth-state.ts +45 -0
  42. package/src/btw-prefix-detection.test.ts +73 -0
  43. package/src/btw-prefix-detection.ts +23 -0
  44. package/src/cli.ts +138 -46
  45. package/src/commands/agent.ts +24 -2
  46. package/src/commands/ask-question.test.ts +111 -0
  47. package/src/commands/ask-question.ts +69 -4
  48. package/src/commands/btw.ts +105 -85
  49. package/src/commands/new-worktree.ts +107 -40
  50. package/src/commands/queue.ts +22 -0
  51. package/src/commands/worktrees.ts +246 -154
  52. package/src/context-awareness-plugin.test.ts +4 -2
  53. package/src/context-awareness-plugin.ts +16 -8
  54. package/src/discord-bot.ts +40 -2
  55. package/src/discord-command-registration.ts +12 -2
  56. package/src/memory-overview-plugin.ts +3 -1
  57. package/src/opencode.ts +9 -0
  58. package/src/queue-question-select-drain.e2e.test.ts +174 -10
  59. package/src/session-handler/thread-runtime-state.ts +36 -1
  60. package/src/session-handler/thread-session-runtime.ts +72 -32
  61. package/src/session-title-rename.test.ts +18 -0
  62. package/src/skill-filter.test.ts +83 -0
  63. package/src/skill-filter.ts +42 -0
  64. package/src/store.ts +17 -0
  65. package/src/system-message.test.ts +10 -6
  66. package/src/system-message.ts +12 -3
  67. package/src/thread-message-queue.e2e.test.ts +126 -0
  68. package/src/worktree-lifecycle.e2e.test.ts +6 -1
  69. package/src/worktrees.test.ts +274 -9
  70. package/src/worktrees.ts +144 -23
@@ -18,7 +18,9 @@ function createSessionState() {
18
18
  };
19
19
  }
20
20
  function buildMemoryOverviewReminder({ condensed }) {
21
- return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>`;
21
+ // Trailing newline so this synthetic part does not fuse with the next text
22
+ // part when the model concatenates message parts.
23
+ return `<system-reminder>Project memory from MEMORY.md (condensed table of contents, line numbers shown):\n${condensed}\nOnly headings are shown above — section bodies are hidden. Use Grep to search MEMORY.md for specific topics, or Read with offset and limit to read a section's content. When writing to MEMORY.md, keep titles concise (under 10 words) and content brief (2-3 sentences max). Only track non-obvious learnings that prevent future mistakes and are not already documented in code comments or AGENTS.md. Do not duplicate information that is self-evident from the code.</system-reminder>\n`;
22
24
  }
23
25
  async function freezeMemoryOverview({ directory, state, }) {
24
26
  if (state.hasFrozenOverview) {
package/dist/opencode.js CHANGED
@@ -28,6 +28,7 @@ import { createLogger, LogPrefix } from './logger.js';
28
28
  import { notifyError } from './sentry.js';
29
29
  import { DirectoryNotAccessibleError, ServerStartError, ServerNotReadyError, FetchError, } from './errors.js';
30
30
  import { ensureKimakiCommandShim, getPathEnvKey, getSpawnCommandAndArgs, prependPathEntry, selectResolvedCommand, } from './opencode-command.js';
31
+ import { computeSkillPermission } from './skill-filter.js';
31
32
  const opencodeLogger = createLogger(LogPrefix.OPENCODE);
32
33
  // Tracks directories that have been initialized, to avoid repeated log spam
33
34
  // from the external sync polling loop.
@@ -394,6 +395,13 @@ async function startSingleServer() {
394
395
  // OPENCODE_CONFIG_CONTENT was loaded last and overrode user project configs,
395
396
  // causing issue #90 (project permissions not being respected).
396
397
  const isDev = import.meta.url.endsWith('.ts') || import.meta.url.endsWith('.tsx');
398
+ // Skill whitelist/blacklist from --enable-skill / --disable-skill CLI flags.
399
+ // Applied as opencode permission.skill rules so every agent inherits the
400
+ // filter via Permission.merge(defaults, agentRules, user).
401
+ const skillPermission = computeSkillPermission({
402
+ enabledSkills: store.getState().enabledSkills,
403
+ disabledSkills: store.getState().disabledSkills,
404
+ });
397
405
  const opencodeConfig = {
398
406
  $schema: 'https://opencode.ai/config.json',
399
407
  lsp: false,
@@ -406,6 +414,7 @@ async function startSingleServer() {
406
414
  bash: 'allow',
407
415
  external_directory: externalDirectoryPermissions,
408
416
  webfetch: 'allow',
417
+ ...(skillPermission && { skill: skillPermission }),
409
418
  },
410
419
  agent: {
411
420
  explore: {
@@ -22,6 +22,22 @@ async function waitForPendingQuestion({ threadId, timeoutMs, }) {
22
22
  }
23
23
  throw new Error('Timed out waiting for pending question context');
24
24
  }
25
+ async function expectNoBotMessageContaining({ discord, threadId, text, timeout, }) {
26
+ const start = Date.now();
27
+ while (Date.now() - start < timeout) {
28
+ const messages = await discord.thread(threadId).getMessages();
29
+ const match = messages.find((message) => {
30
+ return (message.author.id === discord.botUserId
31
+ && message.content.includes(text));
32
+ });
33
+ if (match) {
34
+ throw new Error(`Unexpected bot message containing ${JSON.stringify(text)} while it should still be queued`);
35
+ }
36
+ await new Promise((resolve) => {
37
+ setTimeout(resolve, 20);
38
+ });
39
+ }
40
+ }
25
41
  describe('queue drain after question select answer', () => {
26
42
  const ctx = setupQueueAdvancedSuite({
27
43
  channelId: TEXT_CHANNEL_ID,
@@ -52,16 +68,10 @@ describe('queue drain after question select answer', () => {
52
68
  });
53
69
  // Get the pending question context hash from the internal map.
54
70
  // By this point the question message is visible so the context must exist.
55
- const pending = (() => {
56
- const entry = [...pendingQuestionContexts.entries()].find(([, context]) => {
57
- return context.thread.id === thread.id;
58
- });
59
- return entry ? { contextHash: entry[0] } : null;
60
- })();
61
- expect(pending).toBeTruthy();
62
- if (!pending) {
63
- throw new Error('Expected pending question context');
64
- }
71
+ const pending = await waitForPendingQuestion({
72
+ threadId: thread.id,
73
+ timeoutMs: 8_000,
74
+ });
65
75
  const questionMsg = questionMessages.find((m) => {
66
76
  return m.content.includes('How to proceed?');
67
77
  });
@@ -117,4 +127,119 @@ describe('queue drain after question select answer', () => {
117
127
  expect(timeline).toContain('⬥ ok');
118
128
  expect(timeline).toContain('*project ⋅ main ⋅');
119
129
  }, 20_000);
130
+ test('only the first queued message is handed off after dropdown answer', async () => {
131
+ const marker = 'QUESTION_SELECT_QUEUE_MARKER second-test';
132
+ await ctx.discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
133
+ content: marker,
134
+ });
135
+ const thread = await ctx.discord.channel(TEXT_CHANNEL_ID).waitForThread({
136
+ timeout: 8_000,
137
+ predicate: (t) => {
138
+ return t.name === marker;
139
+ },
140
+ });
141
+ const th = ctx.discord.thread(thread.id);
142
+ const questionMessages = await waitForBotMessageContaining({
143
+ discord: ctx.discord,
144
+ threadId: thread.id,
145
+ text: 'How to proceed?',
146
+ timeout: 12_000,
147
+ });
148
+ const pending = await waitForPendingQuestion({
149
+ threadId: thread.id,
150
+ timeoutMs: 8_000,
151
+ });
152
+ const questionMsg = questionMessages.find((message) => {
153
+ return message.content.includes('How to proceed?');
154
+ });
155
+ expect(questionMsg).toBeTruthy();
156
+ if (!questionMsg) {
157
+ throw new Error('Expected question message');
158
+ }
159
+ const firstQueuedPrompt = 'SLOW_ABORT_MARKER run long response';
160
+ const secondQueuedPrompt = 'Reply with exactly: post-question-second';
161
+ const { id: firstQueueInteractionId } = await th.user(TEST_USER_ID)
162
+ .runSlashCommand({
163
+ name: 'queue',
164
+ options: [{ name: 'message', type: 3, value: firstQueuedPrompt }],
165
+ });
166
+ await th.waitForInteractionAck({
167
+ interactionId: firstQueueInteractionId,
168
+ timeout: 8_000,
169
+ });
170
+ const { id: secondQueueInteractionId } = await th.user(TEST_USER_ID)
171
+ .runSlashCommand({
172
+ name: 'queue',
173
+ options: [{ name: 'message', type: 3, value: secondQueuedPrompt }],
174
+ });
175
+ await th.waitForInteractionAck({
176
+ interactionId: secondQueueInteractionId,
177
+ timeout: 8_000,
178
+ });
179
+ const interaction = await th.user(TEST_USER_ID).selectMenu({
180
+ messageId: questionMsg.id,
181
+ customId: `ask_question:${pending.contextHash}:0`,
182
+ values: ['0'],
183
+ });
184
+ await th.waitForInteractionAck({
185
+ interactionId: interaction.id,
186
+ timeout: 8_000,
187
+ });
188
+ await waitForBotMessageContaining({
189
+ discord: ctx.discord,
190
+ threadId: thread.id,
191
+ text: `» **question-select-tester:** ${firstQueuedPrompt}`,
192
+ timeout: 8_000,
193
+ });
194
+ await expectNoBotMessageContaining({
195
+ discord: ctx.discord,
196
+ threadId: thread.id,
197
+ text: `» **question-select-tester:** ${secondQueuedPrompt}`,
198
+ timeout: 200,
199
+ });
200
+ await waitForFooterMessage({
201
+ discord: ctx.discord,
202
+ threadId: thread.id,
203
+ timeout: 8_000,
204
+ afterMessageIncludes: `» **question-select-tester:** ${firstQueuedPrompt}`,
205
+ afterAuthorId: ctx.discord.botUserId,
206
+ });
207
+ await waitForBotMessageContaining({
208
+ discord: ctx.discord,
209
+ threadId: thread.id,
210
+ text: `» **question-select-tester:** ${secondQueuedPrompt}`,
211
+ timeout: 8_000,
212
+ });
213
+ await waitForFooterMessage({
214
+ discord: ctx.discord,
215
+ threadId: thread.id,
216
+ timeout: 8_000,
217
+ afterMessageIncludes: `» **question-select-tester:** ${secondQueuedPrompt}`,
218
+ afterAuthorId: ctx.discord.botUserId,
219
+ });
220
+ const timeline = await th.text({ showInteractions: true });
221
+ expect(timeline).toMatchInlineSnapshot(`
222
+ "--- from: user (question-select-tester)
223
+ QUESTION_SELECT_QUEUE_MARKER second-test
224
+ --- from: assistant (TestBot)
225
+ **Select action**
226
+ How to proceed?
227
+ ✓ _Alpha_
228
+ [user interaction]
229
+ Queued message (position 1)
230
+ [user interaction]
231
+ Queued message (position 2)
232
+ [user selects dropdown: 0]
233
+ » **question-select-tester:** SLOW_ABORT_MARKER run long response
234
+ ⬥ slow-response-started
235
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
236
+ » **question-select-tester:** Reply with exactly: post-question-second
237
+ ⬥ ok
238
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
239
+ `);
240
+ expect(timeline).toContain(`» **question-select-tester:** ${firstQueuedPrompt}`);
241
+ expect(timeline).toContain('⬥ slow-response-started');
242
+ expect(timeline).toContain(`» **question-select-tester:** ${secondQueuedPrompt}`);
243
+ expect(timeline).toContain('⬥ ok');
244
+ }, 20_000);
120
245
  });
@@ -95,6 +95,33 @@ export function dequeueItem(threadId) {
95
95
  export function clearQueueItems(threadId) {
96
96
  updateThread(threadId, (t) => ({ ...t, queueItems: [] }));
97
97
  }
98
+ export function removeQueueItemAtPosition(threadId, position) {
99
+ if (position < 1) {
100
+ return undefined;
101
+ }
102
+ let removedItem;
103
+ store.setState((s) => {
104
+ const t = s.threads.get(threadId);
105
+ if (!t) {
106
+ return s;
107
+ }
108
+ const index = position - 1;
109
+ const removed = t.queueItems[index];
110
+ if (!removed) {
111
+ return s;
112
+ }
113
+ removedItem = removed;
114
+ const newThreads = new Map(s.threads);
115
+ newThreads.set(threadId, {
116
+ ...t,
117
+ queueItems: t.queueItems.filter((_, itemIndex) => {
118
+ return itemIndex !== index;
119
+ }),
120
+ });
121
+ return { threads: newThreads };
122
+ });
123
+ return removedItem;
124
+ }
98
125
  // ── Queries ──────────────────────────────────────────────────────
99
126
  export function getThreadState(threadId) {
100
127
  return store.getState().threads.get(threadId);
@@ -238,11 +238,13 @@ export function isEssentialToolPart(part) {
238
238
  // ── Thread title derivation ──────────────────────────────────────
239
239
  const DISCORD_THREAD_NAME_MAX = 100;
240
240
  const WORKTREE_THREAD_PREFIX = '⬦ ';
241
- // Pure derivation: given an OpenCode session title and the current thread name,
242
- // return the new thread name to apply, or undefined when no rename is needed.
243
- // - Skips placeholder titles ("New Session - ...") to match external-sync.
244
- // - Preserves worktree prefix when the current name carries it.
245
- // - Returns undefined when the candidate matches currentName already.
241
+ // Prefixes that should survive OpenCode session title renames.
242
+ // When a thread starts with one of these, the rename preserves it.
243
+ const PRESERVED_THREAD_PREFIXES = [
244
+ WORKTREE_THREAD_PREFIX,
245
+ 'btw: ',
246
+ 'Fork: ',
247
+ ];
246
248
  export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, }) {
247
249
  const trimmed = sessionTitle?.trim();
248
250
  if (!trimmed) {
@@ -251,9 +253,10 @@ export function deriveThreadNameFromSessionTitle({ sessionTitle, currentName, })
251
253
  if (/^new session\s*-/i.test(trimmed)) {
252
254
  return undefined;
253
255
  }
254
- const hasWorktreePrefix = currentName.startsWith(WORKTREE_THREAD_PREFIX);
255
- const prefix = hasWorktreePrefix ? WORKTREE_THREAD_PREFIX : '';
256
- const candidate = `${prefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX);
256
+ const matchedPrefix = PRESERVED_THREAD_PREFIXES.find((p) => {
257
+ return currentName.startsWith(p);
258
+ }) ?? '';
259
+ const candidate = `${matchedPrefix}${trimmed}`.slice(0, DISCORD_THREAD_NAME_MAX);
257
260
  if (candidate === currentName) {
258
261
  return undefined;
259
262
  }
@@ -1493,6 +1496,7 @@ export class ThreadSessionRuntime {
1493
1496
  await this.handleMainPart(part);
1494
1497
  }
1495
1498
  async handleMainPart(part) {
1499
+ const sessionId = this.state?.sessionId;
1496
1500
  if (part.type === 'step-start') {
1497
1501
  this.ensureTypingNow();
1498
1502
  return;
@@ -1506,13 +1510,36 @@ export class ThreadSessionRuntime {
1506
1510
  await this.sendPartMessage({ part });
1507
1511
  // Track task tool spawning subtask sessions
1508
1512
  if (part.tool === 'task' && !this.state?.sentPartIds.has(part.id)) {
1509
- const description = part.state.input?.description || '';
1510
- const agent = part.state.input?.subagent_type || 'task';
1511
- const childSessionId = part.state.metadata?.sessionId || '';
1513
+ const description = typeof part.state.input?.description === 'string'
1514
+ ? part.state.input.description
1515
+ : '';
1516
+ const agent = typeof part.state.input?.subagent_type === 'string'
1517
+ ? part.state.input.subagent_type
1518
+ : 'task';
1519
+ const childSessionId = typeof part.state.metadata?.sessionId === 'string'
1520
+ ? part.state.metadata.sessionId
1521
+ : '';
1512
1522
  if (description && childSessionId) {
1513
1523
  if ((await this.getVerbosity()) !== 'text_only') {
1514
1524
  const taskDisplay = `┣ ${agent} **${description}**`;
1515
- await sendThreadMessage(this.thread, taskDisplay + '\n\n');
1525
+ threadState.updateThread(this.threadId, (t) => {
1526
+ const newIds = new Set(t.sentPartIds);
1527
+ newIds.add(part.id);
1528
+ return { ...t, sentPartIds: newIds };
1529
+ });
1530
+ const sendResult = await errore.tryAsync(() => {
1531
+ return sendThreadMessage(this.thread, taskDisplay + '\n\n');
1532
+ });
1533
+ if (sendResult instanceof Error) {
1534
+ threadState.updateThread(this.threadId, (t) => {
1535
+ const newIds = new Set(t.sentPartIds);
1536
+ newIds.delete(part.id);
1537
+ return { ...t, sentPartIds: newIds };
1538
+ });
1539
+ discordLogger.error(`ERROR: Failed to send task part ${part.id}:`, sendResult);
1540
+ return;
1541
+ }
1542
+ await setPartMessage(part.id, sendResult.id, this.thread.id);
1516
1543
  }
1517
1544
  }
1518
1545
  }
@@ -1899,8 +1926,9 @@ export class ThreadSessionRuntime {
1899
1926
  this.onInteractiveUiStateChanged();
1900
1927
  // When a question is answered and the local queue has items, the model may
1901
1928
  // continue the same run without ever reaching the local-queue idle gate.
1902
- // Hand the queued items to OpenCode's own prompt queue immediately instead
1903
- // of waiting for tryDrainQueue() to see an idle session.
1929
+ // Hand off only the next queued item to OpenCode immediately so the queue
1930
+ // resumes, but keep later items local so their `» user:` indicators still
1931
+ // appear one-by-one when they actually become active.
1904
1932
  if (this.getQueueLength() > 0 && !this.questionReplyQueueHandoffPromise) {
1905
1933
  logger.log(`[QUESTION REPLIED] Queue has ${this.getQueueLength()} items, handing off to opencode queue`);
1906
1934
  this.questionReplyQueueHandoffPromise = this.handoffQueuedItemsAfterQuestionReply({
@@ -1916,8 +1944,8 @@ export class ThreadSessionRuntime {
1916
1944
  }
1917
1945
  }
1918
1946
  // Detached helper promise for the "question answered while local queue has
1919
- // items" flow. Prevents starting two overlapping local->opencode queue
1920
- // handoff sequences when multiple question replies land close together.
1947
+ // items" flow. Prevents starting two overlapping single-item handoffs when
1948
+ // multiple question replies land close together.
1921
1949
  questionReplyQueueHandoffPromise = null;
1922
1950
  async handoffQueuedItemsAfterQuestionReply({ sessionId, }) {
1923
1951
  if (this.listenerAborted) {
@@ -1927,19 +1955,17 @@ export class ThreadSessionRuntime {
1927
1955
  logger.log(`[QUESTION REPLIED] Session changed before queue handoff for thread ${this.threadId}`);
1928
1956
  return;
1929
1957
  }
1930
- while (this.state?.sessionId === sessionId) {
1931
- const next = threadState.dequeueItem(this.threadId);
1932
- if (!next) {
1933
- return;
1934
- }
1935
- const displayText = next.command
1936
- ? `/${next.command.name}`
1937
- : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
1938
- if (displayText.trim()) {
1939
- await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
1940
- }
1941
- await this.submitViaOpencodeQueue(next);
1958
+ const next = threadState.dequeueItem(this.threadId);
1959
+ if (!next) {
1960
+ return;
1942
1961
  }
1962
+ const displayText = next.command
1963
+ ? `/${next.command.name}`
1964
+ : `${next.prompt.slice(0, 150)}${next.prompt.length > 150 ? '...' : ''}`;
1965
+ if (displayText.trim()) {
1966
+ await sendThreadMessage(this.thread, `» **${next.username}:** ${displayText}`);
1967
+ }
1968
+ await this.submitViaOpencodeQueue(next);
1943
1969
  }
1944
1970
  async handleSessionStatus(properties) {
1945
1971
  const sessionId = this.state?.sessionId;
@@ -2556,6 +2582,10 @@ export class ThreadSessionRuntime {
2556
2582
  clearQueue() {
2557
2583
  threadState.clearQueueItems(this.threadId);
2558
2584
  }
2585
+ /** Remove a queued message by its 1-based position. */
2586
+ removeQueuePosition(position) {
2587
+ return threadState.removeQueueItemAtPosition(this.threadId, position);
2588
+ }
2559
2589
  // ── Queue Drain ─────────────────────────────────────────────
2560
2590
  /**
2561
2591
  * Check if we can dispatch the next queued message. If so, dequeue and
@@ -67,6 +67,18 @@ describe('deriveThreadNameFromSessionTitle', () => {
67
67
  currentName: 'seed',
68
68
  })).toMatchInlineSnapshot(`undefined`);
69
69
  });
70
+ test('preserves btw: prefix from current name', () => {
71
+ expect(deriveThreadNameFromSessionTitle({
72
+ sessionTitle: 'Side question about auth',
73
+ currentName: 'btw: why is auth broken',
74
+ })).toMatchInlineSnapshot(`"btw: Side question about auth"`);
75
+ });
76
+ test('preserves Fork: prefix from current name', () => {
77
+ expect(deriveThreadNameFromSessionTitle({
78
+ sessionTitle: 'Forked task title',
79
+ currentName: 'Fork: old session title',
80
+ })).toMatchInlineSnapshot(`"Fork: Forked task title"`);
81
+ });
70
82
  test('returns undefined for null/undefined title', () => {
71
83
  expect(deriveThreadNameFromSessionTitle({
72
84
  sessionTitle: null,
@@ -0,0 +1,31 @@
1
+ // Computes opencode permission.skill rules from kimaki's --enable-skill /
2
+ // --disable-skill CLI flags.
3
+ //
4
+ // OpenCode filters skills available to the model via
5
+ // Permission.evaluate("skill", skill.name, agent.permission). We inject a
6
+ // top-level permission.skill ruleset into the generated opencode-config.json
7
+ // so every agent inherits the same whitelist/blacklist via Permission.merge.
8
+ //
9
+ // Whitelist mode: { '*': 'deny', 'name': 'allow', ... }
10
+ // Blacklist mode: { 'name': 'deny', ... }
11
+ // Neither set: undefined (skills are unfiltered)
12
+ //
13
+ // cli.ts validates mutual exclusion of the two flags at startup, so this
14
+ // helper assumes at most one of the two arrays is non-empty.
15
+ export function computeSkillPermission({ enabledSkills, disabledSkills, }) {
16
+ if (enabledSkills.length > 0) {
17
+ const rules = { '*': 'deny' };
18
+ for (const name of enabledSkills) {
19
+ rules[name] = 'allow';
20
+ }
21
+ return rules;
22
+ }
23
+ if (disabledSkills.length > 0) {
24
+ const rules = {};
25
+ for (const name of disabledSkills) {
26
+ rules[name] = 'deny';
27
+ }
28
+ return rules;
29
+ }
30
+ return undefined;
31
+ }
@@ -0,0 +1,65 @@
1
+ import { describe, test, expect } from 'vitest';
2
+ import { computeSkillPermission } from './skill-filter.js';
3
+ describe('computeSkillPermission', () => {
4
+ test('empty inputs returns undefined (no filtering)', () => {
5
+ expect(computeSkillPermission({ enabledSkills: [], disabledSkills: [] })).toMatchInlineSnapshot(`undefined`);
6
+ });
7
+ test('whitelist single skill', () => {
8
+ expect(computeSkillPermission({
9
+ enabledSkills: ['npm-package'],
10
+ disabledSkills: [],
11
+ })).toMatchInlineSnapshot(`
12
+ {
13
+ "*": "deny",
14
+ "npm-package": "allow",
15
+ }
16
+ `);
17
+ });
18
+ test('whitelist multiple skills', () => {
19
+ expect(computeSkillPermission({
20
+ enabledSkills: ['npm-package', 'playwriter', 'errore'],
21
+ disabledSkills: [],
22
+ })).toMatchInlineSnapshot(`
23
+ {
24
+ "*": "deny",
25
+ "errore": "allow",
26
+ "npm-package": "allow",
27
+ "playwriter": "allow",
28
+ }
29
+ `);
30
+ });
31
+ test('blacklist single skill', () => {
32
+ expect(computeSkillPermission({
33
+ enabledSkills: [],
34
+ disabledSkills: ['jitter'],
35
+ })).toMatchInlineSnapshot(`
36
+ {
37
+ "jitter": "deny",
38
+ }
39
+ `);
40
+ });
41
+ test('blacklist multiple skills', () => {
42
+ expect(computeSkillPermission({
43
+ enabledSkills: [],
44
+ disabledSkills: ['jitter', 'termcast'],
45
+ })).toMatchInlineSnapshot(`
46
+ {
47
+ "jitter": "deny",
48
+ "termcast": "deny",
49
+ }
50
+ `);
51
+ });
52
+ test('whitelist takes precedence when both are set (cli.ts is expected to reject this upstream)', () => {
53
+ // cli.ts validates mutual exclusion before reaching this helper. This
54
+ // test documents the defensive behavior if both arrays ever leak through.
55
+ expect(computeSkillPermission({
56
+ enabledSkills: ['npm-package'],
57
+ disabledSkills: ['jitter'],
58
+ })).toMatchInlineSnapshot(`
59
+ {
60
+ "*": "deny",
61
+ "npm-package": "allow",
62
+ }
63
+ `);
64
+ });
65
+ });
package/dist/store.js CHANGED
@@ -9,6 +9,8 @@ export const store = createStore(() => ({
9
9
  defaultVerbosity: 'text_and_essential_tools',
10
10
  defaultMentionMode: false,
11
11
  critiqueEnabled: true,
12
+ enabledSkills: [],
13
+ disabledSkills: [],
12
14
  discordBaseUrl: 'https://discord.com',
13
15
  gatewayToken: null,
14
16
  registeredUserCommands: [],
@@ -236,11 +236,17 @@ ${escapePromptText(repliedMessage.text)}
236
236
  : []),
237
237
  ...(worktree && worktreeChanged
238
238
  ? [
239
- `<system-reminder>\nThis session is running inside a git worktree.\n- Worktree path: ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo: ${worktree.mainRepoDirectory}\nRun checks in this worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.\n</system-reminder>`,
239
+ `<system-reminder>\nThis session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.\n- New worktree path (new cwd / pwd, edit files here): ${worktree.worktreeDirectory}\n- Branch: ${worktree.branch}\n- Main repo path (previous folder, DO NOT TOUCH): ${worktree.mainRepoDirectory}\nYou MUST read, write, and edit files only under the new worktree path ${worktree.worktreeDirectory}. You MUST NOT read, write, or edit any files under the main repo path ${worktree.mainRepoDirectory} — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.\n</system-reminder>`,
240
240
  ]
241
241
  : []),
242
242
  ];
243
- return sections.join('\n\n');
243
+ if (sections.length === 0) {
244
+ return '';
245
+ }
246
+ // Always end synthetic context with a trailing newline so it does not fuse
247
+ // with the next text part (for example the user's actual prompt) when the
248
+ // model concatenates message parts.
249
+ return `${sections.join('\n\n')}\n`;
244
250
  }
245
251
  export function getOpencodeSystemMessage({ sessionId, channelId, guildId, threadId, channelTopic, agents, username, }) {
246
252
  const userArg = ` --user ${JSON.stringify(username || 'username')}`;
@@ -266,7 +272,7 @@ This is required to distinguish essential bash calls from read-only ones in low-
266
272
 
267
273
  Your current OpenCode session ID is: ${sessionId}${channelId ? `\nYour current Discord channel ID is: ${channelId}` : ''}${threadId ? `\nYour current Discord thread ID is: ${threadId}` : ''}${guildId ? `\nYour current Discord guild ID is: ${guildId}` : ''}
268
274
 
269
- Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts. Worktree reminders are emitted only when the worktree changes.
275
+ Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
270
276
 
271
277
  ## permissions
272
278
 
@@ -414,6 +420,8 @@ Notification strategy for scheduled tasks:
414
420
  - Replace \`@username\` with the relevant user from the current thread context.
415
421
  - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
416
422
  - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
423
+ - If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
424
+ - Example no-op cleanup command: \`kimaki session archive --session ${sessionId}\`
417
425
 
418
426
  Manage scheduled tasks with:
419
427
 
@@ -429,6 +437,7 @@ Use case patterns:
429
437
  - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
430
438
  - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
431
439
  - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
440
+ - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ${sessionId}\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
432
441
  - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
433
442
 
434
443
  kimaki send --session ${sessionId} --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only --agent <current_agent>
@@ -30,7 +30,7 @@ describe('system-message', () => {
30
30
  Your current Discord thread ID is: thread_123
31
31
  Your current Discord guild ID is: guild_123
32
32
 
33
- Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts. Worktree reminders are emitted only when the worktree changes.
33
+ Per-turn Discord metadata like the current user and current agent is delivered in synthetic user message parts.
34
34
 
35
35
  ## permissions
36
36
 
@@ -181,6 +181,8 @@ describe('system-message', () => {
181
181
  - Replace \`@username\` with the relevant user from the current thread context.
182
182
  - Without \`--user\`, there is no guaranteed direct user mention path; task output should mention users only when relevant.
183
183
  - With \`--user\`, the user is added to the thread and may receive more frequent thread-level notifications.
184
+ - If a scheduled task completes with no actionable result and no user-visible change, prefer archiving the session after the final message so Discord does not keep a no-op thread highlighted.
185
+ - Example no-op cleanup command: \`kimaki session archive --session ses_123\`
184
186
 
185
187
  Manage scheduled tasks with:
186
188
 
@@ -196,6 +198,7 @@ describe('system-message', () => {
196
198
  - Weekly QA: schedule "run full test suite, inspect failures, post summary, and mention @username only when failures require review".
197
199
  - Weekly benchmark automation: schedule a benchmark prompt that runs model evals, writes JSON outputs in the repo, commits results, and mentions only for regressions.
198
200
  - Recurring maintenance: use cron \`--send-at\` for repetitive tasks like rotating secrets, checking dependency updates, running security audits, or cleaning up stale branches. Example: \`--send-at "0 9 1 * *"\` to run on the 1st of every month.
201
+ - Quiet no-op checks: if a recurring task checks something and finds nothing to report, let it post a brief final summary and then archive the session with \`kimaki session archive --session ses_123\`. Example: a scheduled email triage run that finds no new emails should archive itself so it does not add noise to Discord.
199
202
  - Thread reminders: when the user says "remind me about this in 2 hours" (or any duration), use \`--send-at\` with \`--thread\` to resurface the current thread. Compute the future UTC time and send a mention so Discord shows a notification:
200
203
 
201
204
  kimaki send --session ses_123 --prompt "Reminder: <@USER_ID> you asked to be reminded about this thread." --send-at "<future_UTC_time>" --notify-only --agent <current_agent>
@@ -605,12 +608,13 @@ describe('system-message', () => {
605
608
  </system-reminder>
606
609
 
607
610
  <system-reminder>
608
- This session is running inside a git worktree.
609
- - Worktree path: /repo/.worktrees/prompt-cache
611
+ This session is running inside a git worktree. The working directory (cwd / pwd) has changed. The user expects you to edit files in the new cwd. You MUST operate inside the new worktree from now on.
612
+ - New worktree path (new cwd / pwd, edit files here): /repo/.worktrees/prompt-cache
610
613
  - Branch: prompt-cache
611
- - Main repo: /repo
612
- Run checks in this worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.
613
- </system-reminder>"
614
+ - Main repo path (previous folder, DO NOT TOUCH): /repo
615
+ You MUST read, write, and edit files only under the new worktree path /repo/.worktrees/prompt-cache. You MUST NOT read, write, or edit any files under the main repo path /repo — even though it is the same project, that folder is a separate checkout and the user or another agent may be actively working there, so writing to it would override their unrelated changes. Run all checks (tests, builds, lint) inside the new worktree. Do not create another worktree by default. Ask before merging changes back to the main branch.
616
+ </system-reminder>
617
+ "
614
618
  `);
615
619
  });
616
620
  });