kimaki 0.4.76 → 0.4.78

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 (162) hide show
  1. package/dist/adapter-rest-boundary.test.js +34 -0
  2. package/dist/agent-model.e2e.test.js +2 -20
  3. package/dist/cli.js +50 -13
  4. package/dist/commands/channel-ref.js +16 -0
  5. package/dist/commands/diff.js +20 -85
  6. package/dist/commands/merge-worktree.js +5 -17
  7. package/dist/commands/new-worktree.js +5 -9
  8. package/dist/commands/permissions.js +77 -11
  9. package/dist/commands/resume.js +5 -9
  10. package/dist/commands/screenshare.js +295 -0
  11. package/dist/commands/session.js +6 -17
  12. package/dist/critique-utils.js +95 -0
  13. package/dist/diff-patch-plugin.js +314 -0
  14. package/dist/discord-bot.js +19 -14
  15. package/dist/discord-js-import-boundary.test.js +62 -0
  16. package/dist/discord-utils.js +44 -0
  17. package/dist/event-stream-real-capture.e2e.test.js +2 -20
  18. package/dist/gateway-proxy.e2e.test.js +2 -5
  19. package/dist/generated/cloudflare/browser.js +17 -0
  20. package/dist/generated/cloudflare/client.js +34 -0
  21. package/dist/generated/cloudflare/commonInputTypes.js +10 -0
  22. package/dist/generated/cloudflare/enums.js +48 -0
  23. package/dist/generated/cloudflare/internal/class.js +47 -0
  24. package/dist/generated/cloudflare/internal/prismaNamespace.js +252 -0
  25. package/dist/generated/cloudflare/internal/prismaNamespaceBrowser.js +222 -0
  26. package/dist/generated/cloudflare/internal/query_compiler_fast_bg.js +135 -0
  27. package/dist/generated/cloudflare/models/bot_api_keys.js +1 -0
  28. package/dist/generated/cloudflare/models/bot_tokens.js +1 -0
  29. package/dist/generated/cloudflare/models/channel_agents.js +1 -0
  30. package/dist/generated/cloudflare/models/channel_directories.js +1 -0
  31. package/dist/generated/cloudflare/models/channel_mention_mode.js +1 -0
  32. package/dist/generated/cloudflare/models/channel_models.js +1 -0
  33. package/dist/generated/cloudflare/models/channel_verbosity.js +1 -0
  34. package/dist/generated/cloudflare/models/channel_worktrees.js +1 -0
  35. package/dist/generated/cloudflare/models/forum_sync_configs.js +1 -0
  36. package/dist/generated/cloudflare/models/global_models.js +1 -0
  37. package/dist/generated/cloudflare/models/ipc_requests.js +1 -0
  38. package/dist/generated/cloudflare/models/part_messages.js +1 -0
  39. package/dist/generated/cloudflare/models/scheduled_tasks.js +1 -0
  40. package/dist/generated/cloudflare/models/session_agents.js +1 -0
  41. package/dist/generated/cloudflare/models/session_events.js +1 -0
  42. package/dist/generated/cloudflare/models/session_models.js +1 -0
  43. package/dist/generated/cloudflare/models/session_start_sources.js +1 -0
  44. package/dist/generated/cloudflare/models/thread_sessions.js +1 -0
  45. package/dist/generated/cloudflare/models/thread_worktrees.js +1 -0
  46. package/dist/generated/cloudflare/models.js +1 -0
  47. package/dist/generated/node/browser.js +17 -0
  48. package/dist/generated/node/client.js +37 -0
  49. package/dist/generated/node/commonInputTypes.js +10 -0
  50. package/dist/generated/node/enums.js +48 -0
  51. package/dist/generated/node/internal/class.js +49 -0
  52. package/dist/generated/node/internal/prismaNamespace.js +252 -0
  53. package/dist/generated/node/internal/prismaNamespaceBrowser.js +222 -0
  54. package/dist/generated/node/models/bot_api_keys.js +1 -0
  55. package/dist/generated/node/models/bot_tokens.js +1 -0
  56. package/dist/generated/node/models/channel_agents.js +1 -0
  57. package/dist/generated/node/models/channel_directories.js +1 -0
  58. package/dist/generated/node/models/channel_mention_mode.js +1 -0
  59. package/dist/generated/node/models/channel_models.js +1 -0
  60. package/dist/generated/node/models/channel_verbosity.js +1 -0
  61. package/dist/generated/node/models/channel_worktrees.js +1 -0
  62. package/dist/generated/node/models/forum_sync_configs.js +1 -0
  63. package/dist/generated/node/models/global_models.js +1 -0
  64. package/dist/generated/node/models/ipc_requests.js +1 -0
  65. package/dist/generated/node/models/part_messages.js +1 -0
  66. package/dist/generated/node/models/scheduled_tasks.js +1 -0
  67. package/dist/generated/node/models/session_agents.js +1 -0
  68. package/dist/generated/node/models/session_events.js +1 -0
  69. package/dist/generated/node/models/session_models.js +1 -0
  70. package/dist/generated/node/models/session_start_sources.js +1 -0
  71. package/dist/generated/node/models/thread_sessions.js +1 -0
  72. package/dist/generated/node/models/thread_worktrees.js +1 -0
  73. package/dist/generated/node/models.js +1 -0
  74. package/dist/interaction-handler.js +10 -0
  75. package/dist/kimaki-digital-twin.e2e.test.js +2 -20
  76. package/dist/message-flags-boundary.test.js +54 -0
  77. package/dist/message-formatting.js +3 -62
  78. package/dist/onboarding-tutorial-plugin.js +1 -1
  79. package/dist/opencode-command.js +129 -0
  80. package/dist/opencode-command.test.js +48 -0
  81. package/dist/opencode-interrupt-plugin.js +19 -1
  82. package/dist/opencode-interrupt-plugin.test.js +0 -5
  83. package/dist/opencode-plugin-loading.e2e.test.js +9 -20
  84. package/dist/opencode-plugin.js +4 -4
  85. package/dist/opencode.js +150 -27
  86. package/dist/patch-text-parser.js +97 -0
  87. package/dist/platform/components-v2.js +20 -0
  88. package/dist/platform/discord-adapter.js +1440 -0
  89. package/dist/platform/discord-routes.js +31 -0
  90. package/dist/platform/message-flags.js +8 -0
  91. package/dist/platform/platform-value.js +41 -0
  92. package/dist/platform/slack-adapter.js +872 -0
  93. package/dist/platform/slack-markdown.js +169 -0
  94. package/dist/platform/types.js +4 -0
  95. package/dist/queue-advanced-e2e-setup.js +265 -0
  96. package/dist/queue-advanced-footer.e2e.test.js +173 -0
  97. package/dist/queue-advanced-model-switch.e2e.test.js +299 -0
  98. package/dist/queue-advanced-permissions-typing.e2e.test.js +73 -1
  99. package/dist/runtime-lifecycle.e2e.test.js +2 -20
  100. package/dist/session-handler/event-stream-state.js +5 -0
  101. package/dist/session-handler/event-stream-state.test.js +6 -2
  102. package/dist/session-handler/thread-session-runtime.js +32 -2
  103. package/dist/system-message.js +26 -23
  104. package/dist/test-utils.js +16 -0
  105. package/dist/thread-message-queue.e2e.test.js +2 -20
  106. package/dist/utils.js +3 -1
  107. package/dist/voice-message.e2e.test.js +2 -20
  108. package/dist/voice.js +122 -9
  109. package/dist/voice.test.js +17 -2
  110. package/dist/websockify.js +69 -0
  111. package/dist/worktree-lifecycle.e2e.test.js +308 -0
  112. package/package.json +4 -2
  113. package/skills/critique/SKILL.md +17 -0
  114. package/skills/egaki/SKILL.md +35 -0
  115. package/skills/event-sourcing-state/SKILL.md +252 -0
  116. package/skills/goke/SKILL.md +1 -0
  117. package/skills/npm-package/SKILL.md +21 -2
  118. package/skills/playwriter/SKILL.md +1 -1
  119. package/skills/x-articles/SKILL.md +554 -0
  120. package/src/agent-model.e2e.test.ts +4 -19
  121. package/src/cli.ts +60 -13
  122. package/src/commands/diff.ts +25 -99
  123. package/src/commands/merge-worktree.ts +5 -21
  124. package/src/commands/new-worktree.ts +5 -11
  125. package/src/commands/permissions.ts +100 -15
  126. package/src/commands/resume.ts +5 -12
  127. package/src/commands/screenshare.ts +354 -0
  128. package/src/commands/session.ts +6 -23
  129. package/src/critique-utils.ts +139 -0
  130. package/src/discord-bot.ts +20 -15
  131. package/src/discord-utils.ts +53 -0
  132. package/src/event-stream-real-capture.e2e.test.ts +4 -20
  133. package/src/gateway-proxy.e2e.test.ts +2 -5
  134. package/src/interaction-handler.ts +15 -0
  135. package/src/kimaki-digital-twin.e2e.test.ts +2 -21
  136. package/src/message-formatting.ts +3 -68
  137. package/src/onboarding-tutorial-plugin.ts +1 -1
  138. package/src/opencode-command.test.ts +70 -0
  139. package/src/opencode-command.ts +188 -0
  140. package/src/opencode-interrupt-plugin.test.ts +0 -5
  141. package/src/opencode-interrupt-plugin.ts +34 -1
  142. package/src/opencode-plugin-loading.e2e.test.ts +25 -35
  143. package/src/opencode-plugin.ts +5 -4
  144. package/src/opencode.ts +199 -32
  145. package/src/patch-text-parser.ts +107 -0
  146. package/src/queue-advanced-e2e-setup.ts +273 -0
  147. package/src/queue-advanced-footer.e2e.test.ts +211 -0
  148. package/src/queue-advanced-model-switch.e2e.test.ts +383 -0
  149. package/src/queue-advanced-permissions-typing.e2e.test.ts +92 -0
  150. package/src/runtime-lifecycle.e2e.test.ts +4 -19
  151. package/src/session-handler/event-stream-state.test.ts +6 -2
  152. package/src/session-handler/event-stream-state.ts +5 -0
  153. package/src/session-handler/thread-session-runtime.ts +45 -2
  154. package/src/system-message.ts +26 -23
  155. package/src/test-utils.ts +17 -0
  156. package/src/thread-message-queue.e2e.test.ts +2 -20
  157. package/src/utils.ts +3 -1
  158. package/src/voice-message.e2e.test.ts +3 -20
  159. package/src/voice.test.ts +26 -2
  160. package/src/voice.ts +147 -9
  161. package/src/websockify.ts +101 -0
  162. package/src/worktree-lifecycle.e2e.test.ts +391 -0
@@ -0,0 +1,308 @@
1
+ // E2e test for worktree lifecycle: /new-worktree inside an existing thread,
2
+ // then verify the session still works after sdkDirectory switches.
3
+ // Validates that handleDirectoryChanged() reconnects the event listener
4
+ // so events from the worktree Instance reach the runtime (PR #75 fix).
5
+ //
6
+ // Uses opencode-deterministic-provider (no real LLM calls).
7
+ // Poll timeouts: 4s max, 100ms interval (except worktree creation which
8
+ // involves real git operations — 10s timeout there).
9
+ import fs from 'node:fs';
10
+ import path from 'node:path';
11
+ import url from 'node:url';
12
+ import { describe, beforeAll, afterAll, test, expect } from 'vitest';
13
+ import { ChannelType, Client, GatewayIntentBits, Partials } from 'discord.js';
14
+ import { DigitalDiscord } from 'discord-digital-twin/src';
15
+ import { buildDeterministicOpencodeConfig, } from 'opencode-deterministic-provider';
16
+ import { setDataDir } from './config.js';
17
+ import { store } from './store.js';
18
+ import { startDiscordBot } from './discord-bot.js';
19
+ import { getRuntime } from './session-handler/thread-session-runtime.js';
20
+ import { setBotToken, initDatabase, closeDatabase, setChannelDirectory, setChannelVerbosity, } from './database.js';
21
+ import { startHranaServer, stopHranaServer } from './hrana-server.js';
22
+ import { initializeOpencodeForDirectory, stopOpencodeServer, } from './opencode.js';
23
+ import { chooseLockPort, cleanupTestSessions, waitForBotMessageContaining, waitForBotReplyAfterUserMessage, } from './test-utils.js';
24
+ import { execAsync } from './worktrees.js';
25
+ const TEST_USER_ID = '200000000000000901';
26
+ const TEXT_CHANNEL_ID = '200000000000000902';
27
+ // Unique worktree name per run to avoid collisions with leftover worktrees
28
+ const WORKTREE_SUFFIX = Date.now().toString(36).slice(-6);
29
+ const WORKTREE_NAME = `wt-e2e-${WORKTREE_SUFFIX}`;
30
+ function createRunDirectories() {
31
+ const root = path.resolve(process.cwd(), 'tmp', 'worktree-lifecycle-e2e');
32
+ fs.mkdirSync(root, { recursive: true });
33
+ const dataDir = fs.mkdtempSync(path.join(root, 'data-'));
34
+ const projectDirectory = path.join(root, 'project');
35
+ fs.mkdirSync(projectDirectory, { recursive: true });
36
+ return { root, dataDir, projectDirectory };
37
+ }
38
+ function createDiscordJsClient({ restUrl }) {
39
+ return new Client({
40
+ intents: [
41
+ GatewayIntentBits.Guilds,
42
+ GatewayIntentBits.GuildMessages,
43
+ GatewayIntentBits.MessageContent,
44
+ GatewayIntentBits.GuildVoiceStates,
45
+ ],
46
+ partials: [
47
+ Partials.Channel,
48
+ Partials.Message,
49
+ Partials.User,
50
+ Partials.ThreadMember,
51
+ ],
52
+ rest: {
53
+ api: restUrl,
54
+ version: '10',
55
+ },
56
+ });
57
+ }
58
+ /** Initialize a git repo with an initial commit so worktrees can be created. */
59
+ async function initGitRepo(directory) {
60
+ // Check if already a git repo (directory may persist across runs)
61
+ const isRepo = fs.existsSync(path.join(directory, '.git'));
62
+ if (isRepo) {
63
+ // Commit any new/changed files (opencode.json may have been rewritten)
64
+ await execAsync('git add -A && git diff --cached --quiet || git commit -m "update"', {
65
+ cwd: directory,
66
+ }).catch(() => { return; });
67
+ return;
68
+ }
69
+ await execAsync('git init', { cwd: directory });
70
+ await execAsync('git config user.email "test@test.com"', { cwd: directory });
71
+ await execAsync('git config user.name "Test"', { cwd: directory });
72
+ await execAsync('git add -A && git commit -m "initial"', { cwd: directory });
73
+ }
74
+ function createDeterministicMatchers() {
75
+ const userReplyMatcher = {
76
+ id: 'user-reply',
77
+ priority: 10,
78
+ when: {
79
+ lastMessageRole: 'user',
80
+ rawPromptIncludes: 'Reply with exactly:',
81
+ },
82
+ then: {
83
+ parts: [
84
+ { type: 'stream-start', warnings: [] },
85
+ { type: 'text-start', id: 'default-reply' },
86
+ { type: 'text-delta', id: 'default-reply', delta: 'ok' },
87
+ { type: 'text-end', id: 'default-reply' },
88
+ {
89
+ type: 'finish',
90
+ finishReason: 'stop',
91
+ usage: { inputTokens: 1, outputTokens: 1, totalTokens: 2 },
92
+ },
93
+ ],
94
+ partDelaysMs: [0, 100, 0, 0, 0],
95
+ },
96
+ };
97
+ return [userReplyMatcher];
98
+ }
99
+ describe('worktree lifecycle', () => {
100
+ let directories;
101
+ let discord;
102
+ let botClient;
103
+ let previousDefaultVerbosity = null;
104
+ let testStartTime = Date.now();
105
+ beforeAll(async () => {
106
+ testStartTime = Date.now();
107
+ directories = createRunDirectories();
108
+ const lockPort = chooseLockPort({ key: TEXT_CHANNEL_ID });
109
+ process.env['KIMAKI_LOCK_PORT'] = String(lockPort);
110
+ setDataDir(directories.dataDir);
111
+ previousDefaultVerbosity = store.getState().defaultVerbosity;
112
+ store.setState({ defaultVerbosity: 'tools_and_text' });
113
+ const digitalDiscordDbPath = path.join(directories.dataDir, 'digital-discord.db');
114
+ discord = new DigitalDiscord({
115
+ guild: {
116
+ name: 'Worktree E2E Guild',
117
+ ownerId: TEST_USER_ID,
118
+ },
119
+ channels: [
120
+ {
121
+ id: TEXT_CHANNEL_ID,
122
+ name: 'worktree-e2e',
123
+ type: ChannelType.GuildText,
124
+ },
125
+ ],
126
+ users: [
127
+ {
128
+ id: TEST_USER_ID,
129
+ username: 'worktree-tester',
130
+ },
131
+ ],
132
+ dbUrl: `file:${digitalDiscordDbPath}`,
133
+ });
134
+ await discord.start();
135
+ const providerNpm = url
136
+ .pathToFileURL(path.resolve(process.cwd(), '..', 'opencode-deterministic-provider', 'src', 'index.ts'))
137
+ .toString();
138
+ const opencodeConfig = buildDeterministicOpencodeConfig({
139
+ providerName: 'deterministic-provider',
140
+ providerNpm,
141
+ model: 'deterministic-v2',
142
+ smallModel: 'deterministic-v2',
143
+ settings: {
144
+ strict: false,
145
+ matchers: createDeterministicMatchers(),
146
+ },
147
+ });
148
+ fs.writeFileSync(path.join(directories.projectDirectory, 'opencode.json'), JSON.stringify(opencodeConfig, null, 2));
149
+ // Initialize git repo after writing opencode.json so the initial commit
150
+ // includes it. Worktrees require at least one commit.
151
+ await initGitRepo(directories.projectDirectory);
152
+ const dbPath = path.join(directories.dataDir, 'discord-sessions.db');
153
+ const hranaResult = await startHranaServer({ dbPath });
154
+ if (hranaResult instanceof Error) {
155
+ throw hranaResult;
156
+ }
157
+ process.env['KIMAKI_DB_URL'] = hranaResult;
158
+ await initDatabase();
159
+ await setBotToken(discord.botUserId, discord.botToken);
160
+ await setChannelDirectory({
161
+ channelId: TEXT_CHANNEL_ID,
162
+ directory: directories.projectDirectory,
163
+ channelType: 'text',
164
+ });
165
+ await setChannelVerbosity(TEXT_CHANNEL_ID, 'tools_and_text');
166
+ botClient = createDiscordJsClient({ restUrl: discord.restUrl });
167
+ await startDiscordBot({
168
+ token: discord.botToken,
169
+ appId: discord.botUserId,
170
+ discordClient: botClient,
171
+ });
172
+ // Pre-warm the opencode server
173
+ const warmup = await initializeOpencodeForDirectory(directories.projectDirectory);
174
+ if (warmup instanceof Error) {
175
+ throw warmup;
176
+ }
177
+ }, 60_000);
178
+ afterAll(async () => {
179
+ if (directories) {
180
+ await cleanupTestSessions({
181
+ projectDirectory: directories.projectDirectory,
182
+ testStartTime,
183
+ });
184
+ }
185
+ if (botClient) {
186
+ botClient.destroy();
187
+ }
188
+ await stopOpencodeServer();
189
+ await Promise.all([
190
+ closeDatabase().catch(() => { return; }),
191
+ stopHranaServer().catch(() => { return; }),
192
+ discord?.stop().catch(() => { return; }),
193
+ ]);
194
+ delete process.env['KIMAKI_LOCK_PORT'];
195
+ delete process.env['KIMAKI_DB_URL'];
196
+ if (previousDefaultVerbosity) {
197
+ store.setState({ defaultVerbosity: previousDefaultVerbosity });
198
+ }
199
+ // Clean up the git worktree created during the test
200
+ if (directories) {
201
+ const worktreeBranch = `opencode/kimaki-${WORKTREE_NAME}`;
202
+ await execAsync(`git worktree list --porcelain`, { cwd: directories.projectDirectory }).then(({ stdout }) => {
203
+ // Find and remove any worktree for our test branch
204
+ const lines = stdout.split('\n');
205
+ let currentPath = '';
206
+ for (const line of lines) {
207
+ if (line.startsWith('worktree ')) {
208
+ currentPath = line.slice('worktree '.length);
209
+ }
210
+ if (line.startsWith('branch ') && line.includes(worktreeBranch) && currentPath) {
211
+ return execAsync(`git worktree remove --force ${JSON.stringify(currentPath)}`, { cwd: directories.projectDirectory });
212
+ }
213
+ }
214
+ }).catch(() => { return; });
215
+ await execAsync(`git branch -D ${JSON.stringify(`opencode/kimaki-${WORKTREE_NAME}`)}`, { cwd: directories.projectDirectory }).catch(() => { return; });
216
+ fs.rmSync(directories.dataDir, { recursive: true, force: true });
217
+ }
218
+ }, 10_000);
219
+ test('session responds after /new-worktree switches sdkDirectory in existing thread', async () => {
220
+ // 1. Send a message to create a thread and establish a session
221
+ await discord.channel(TEXT_CHANNEL_ID).user(TEST_USER_ID).sendMessage({
222
+ content: 'Reply with exactly: before-worktree',
223
+ });
224
+ const thread = await discord.channel(TEXT_CHANNEL_ID).waitForThread({
225
+ timeout: 4_000,
226
+ predicate: (t) => {
227
+ return t.name === 'Reply with exactly: before-worktree';
228
+ },
229
+ });
230
+ const th = discord.thread(thread.id);
231
+ // Wait for first run to fully complete (footer appears)
232
+ await waitForBotMessageContaining({
233
+ discord,
234
+ threadId: thread.id,
235
+ userId: TEST_USER_ID,
236
+ text: '*project',
237
+ timeout: 4_000,
238
+ });
239
+ // Capture runtime — should survive the directory switch
240
+ const runtimeBefore = getRuntime(thread.id);
241
+ expect(runtimeBefore).toBeDefined();
242
+ expect(runtimeBefore.sdkDirectory).toBe(directories.projectDirectory);
243
+ // 2. Run /new-worktree inside the thread (in-thread flow).
244
+ // This creates a pending worktree, then background creates the git worktree,
245
+ // then marks it ready. Next message will pick up the worktree directory.
246
+ const { id: interactionId } = await th
247
+ .user(TEST_USER_ID)
248
+ .runSlashCommand({
249
+ name: 'new-worktree',
250
+ options: [{ name: 'name', type: 3, value: WORKTREE_NAME }],
251
+ });
252
+ // Wait for the slash command ack
253
+ await discord
254
+ .channel(thread.id)
255
+ .waitForInteractionAck({ interactionId, timeout: 4_000 });
256
+ // 3. Wait for worktree to become ready — the background creation
257
+ // edits the starter message to include the branch name.
258
+ // Git worktree creation involves real git operations, so allow more time.
259
+ await waitForBotMessageContaining({
260
+ discord,
261
+ threadId: thread.id,
262
+ userId: TEST_USER_ID,
263
+ text: 'Branch:',
264
+ timeout: 10_000,
265
+ });
266
+ // 4. Send a message after the worktree is ready.
267
+ // Without handleDirectoryChanged (PR #75), the event listener is still
268
+ // subscribed to the old project directory's Instance, so this message
269
+ // gets processed but the response events never reach the runtime.
270
+ await th.user(TEST_USER_ID).sendMessage({
271
+ content: 'Reply with exactly: after-worktree',
272
+ });
273
+ // 5. Verify the bot actually responds — this is the core assertion.
274
+ // If the listener wasn't reconnected, this will time out.
275
+ await waitForBotReplyAfterUserMessage({
276
+ discord,
277
+ threadId: thread.id,
278
+ userId: TEST_USER_ID,
279
+ userMessageIncludes: 'after-worktree',
280
+ timeout: 4_000,
281
+ });
282
+ // Wait for the footer to confirm full completion
283
+ await waitForBotMessageContaining({
284
+ discord,
285
+ threadId: thread.id,
286
+ userId: TEST_USER_ID,
287
+ text: 'deterministic-v2',
288
+ afterUserMessageIncludes: 'after-worktree',
289
+ timeout: 4_000,
290
+ });
291
+ // Runtime instance should be the same (not recreated)
292
+ const runtimeAfter = getRuntime(thread.id);
293
+ expect(runtimeAfter).toBe(runtimeBefore);
294
+ // sdkDirectory should now point to the worktree path
295
+ expect(runtimeAfter.sdkDirectory).not.toBe(directories.projectDirectory);
296
+ expect(runtimeAfter.sdkDirectory).toContain(`kimaki-${WORKTREE_NAME}`);
297
+ // Snapshot uses dynamic worktree name so we verify structure, not exact text
298
+ const text = await th.text();
299
+ expect(text).toContain('Reply with exactly: before-worktree');
300
+ expect(text).toContain('⬥ ok');
301
+ expect(text).toContain('Worktree:');
302
+ expect(text).toContain('Branch:');
303
+ expect(text).toContain('Reply with exactly: after-worktree');
304
+ // The second "⬥ ok" proves the bot responded after the worktree switch
305
+ const okCount = (text.match(/⬥ ok/g) || []).length;
306
+ expect(okCount).toBe(2);
307
+ }, 30_000);
308
+ });
package/package.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "name": "kimaki",
3
3
  "module": "index.ts",
4
4
  "type": "module",
5
- "version": "0.4.76",
5
+ "version": "0.4.78",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -42,6 +42,7 @@
42
42
  "@prisma/client": "7.4.2",
43
43
  "@purinton/resampler": "^1.0.4",
44
44
  "@sentry/node": "^10.40.0",
45
+ "@types/ws": "^8.18.1",
45
46
  "cron-parser": "^5.5.0",
46
47
  "discord.js": "^14.25.1",
47
48
  "domhandler": "^5.0.3",
@@ -55,6 +56,7 @@
55
56
  "pretty-ms": "^9.3.0",
56
57
  "string-dedent": "^3.0.2",
57
58
  "undici": "^7.16.0",
59
+ "ws": "^8.19.0",
58
60
  "xdg-basedir": "^5.1.0",
59
61
  "zod": "^4.3.6",
60
62
  "zustand": "^5.0.11",
@@ -69,7 +71,7 @@
69
71
  "sharp": "^0.34.5"
70
72
  },
71
73
  "scripts": {
72
- "dev": "tsx --env-file .env src/cli.ts",
74
+ "dev": "tsx src/cli.ts",
73
75
  "dev:bun": "DEBUG=1 bun --env-file .env src/cli.ts",
74
76
  "watch": "tsx scripts/watch-session.ts",
75
77
  "generate": "prisma generate && pnpm generate:sql",
@@ -122,6 +122,23 @@ critique review --web --agent opencode --session <session_id> --filter "src/**/*
122
122
 
123
123
  The command prints a preview URL when done — share that URL with the user.
124
124
 
125
+ ## Raw patch access
126
+
127
+ Every `--web` upload also stores the raw unified diff. Append `.patch` to any critique URL to get it:
128
+
129
+ ```bash
130
+ # View the raw patch
131
+ curl https://critique.work/v/<id>.patch
132
+
133
+ # Apply the patch to current repo
134
+ curl -s https://critique.work/v/<id>.patch | git apply
135
+
136
+ # Reverse the patch (undo the changes)
137
+ curl -s https://critique.work/v/<id>.patch | git apply --reverse
138
+ ```
139
+
140
+ Useful when an agent shares a critique URL and you want to programmatically apply or revert those changes.
141
+
125
142
  ## Notes
126
143
 
127
144
  - Requires **Bun** — use `bunx critique` or global `critique`
@@ -0,0 +1,35 @@
1
+ ---
2
+ name: egaki
3
+ description: >
4
+ AI image generation CLI. Generates images from text prompts using Google Imagen
5
+ and Gemini multimodal models via the Vercel AI SDK. Supports image editing,
6
+ inpainting, and multiple output formats.
7
+ ---
8
+
9
+ # egaki
10
+
11
+ AI image generation from the terminal. Text-to-image, image editing, and inpainting
12
+ with Google Imagen and Gemini models.
13
+
14
+ Run `egaki --help` before using this CLI. The help output has all commands,
15
+ options, defaults, and usage examples.
16
+
17
+ For subcommand details: `egaki <command> --help` (e.g. `egaki image --help`, `egaki login --help`)
18
+
19
+ ## Quick start
20
+
21
+ ```bash
22
+ # configure an API key
23
+ egaki login
24
+
25
+ # generate an image
26
+ egaki image "a sunset over mars"
27
+
28
+ # edit an existing image (local file or URL)
29
+ egaki image "add a wizard hat" --input photo.jpg
30
+ egaki image "make it pop art" --input https://example.com/photo.jpg
31
+
32
+ # pipe to another tool
33
+ egaki image "logo" --stdout | convert - -resize 512x512 logo.png
34
+ ```
35
+
@@ -0,0 +1,252 @@
1
+ ---
2
+ name: event-sourcing-state
3
+ description: >
4
+ Event-sourced application state pattern for TypeScript apps. Prefer bounded
5
+ event logs plus pure derivation functions over mirrored mutable lifecycle
6
+ flags. Use when state transitions are driven by events and bugs can be
7
+ reproduced from a saved event stream.
8
+ version: 0.2.0
9
+ ---
10
+
11
+ <!-- Skill for event-sourced state and fixture-driven debugging. -->
12
+
13
+ # Event-Sourcing State
14
+
15
+ Use this skill when an app keeps adding mutable fields to track lifecycle,
16
+ phase, status, or UI state that could instead be derived from an event log.
17
+
18
+ ## Core idea
19
+
20
+ Do not store the answer when you can store the evidence.
21
+
22
+ Coding agents overproduce state. Every bug looks like it wants one more flag,
23
+ one more cached answer, one more special case. Every field feels locally
24
+ justified. Globally you are building a machine nobody can hold in their head.
25
+
26
+ Every boolean you add:
27
+
28
+ 1. doubles your app's possible states
29
+ 2. doubles your bugs
30
+ 3. doubles the coverage you need in the worst case
31
+
32
+ The fix is not a better set of flags. The fix is deleting the flags.
33
+
34
+ Stop storing conclusions and store evidence instead. If a decision depends on
35
+ what actually happened, keep the events and derive the answer from them.
36
+
37
+ ## Anti-pattern: mirrored flags
38
+
39
+ To answer one yes/no UI question ("should the footer show?"), an agent will
40
+ mirror facts into state:
41
+
42
+ ```ts
43
+ type ThreadState = {
44
+ wasInterrupted: boolean
45
+ didAssistantFinish: boolean
46
+ didAssistantError: boolean
47
+ wasToolCallOnly: boolean
48
+ }
49
+
50
+ function shouldShowFooter(state: ThreadState): boolean {
51
+ return state.didAssistantFinish
52
+ && !state.wasInterrupted
53
+ && !state.didAssistantError
54
+ && !state.wasToolCallOnly
55
+ }
56
+ ```
57
+
58
+ Four flags to answer one question. Each flag caches a fact already present in
59
+ the event that produced it. Then a function recombines them back into one
60
+ boolean. None of these fields looks insane on its own — that is the trap.
61
+
62
+ ## Pattern: derive from events
63
+
64
+ Keep the raw events and compute the answer when needed:
65
+
66
+ ```ts
67
+ type SessionEvent =
68
+ | { type: 'session.status'; status: 'busy' | 'idle' }
69
+ | { type: 'session.aborted' }
70
+ | {
71
+ type: 'message.updated'
72
+ role: 'assistant'
73
+ completed: boolean
74
+ error: boolean
75
+ finish: 'stop' | 'tool-calls'
76
+ }
77
+
78
+ function getLatestAssistantMessage(events: SessionEvent[]) {
79
+ for (let i = events.length - 1; i >= 0; i--) {
80
+ const event = events[i]
81
+ if (event?.type === 'message.updated' && event.role === 'assistant') {
82
+ return event
83
+ }
84
+ }
85
+ return undefined
86
+ }
87
+
88
+ function isNaturalCompletion(message: {
89
+ completed: boolean
90
+ error: boolean
91
+ finish: 'stop' | 'tool-calls'
92
+ }): boolean {
93
+ if (!message.completed) {
94
+ return false
95
+ }
96
+ if (message.error) {
97
+ return false
98
+ }
99
+ return message.finish !== 'tool-calls'
100
+ }
101
+
102
+ function shouldShowFooter(events: SessionEvent[]): boolean {
103
+ const msg = getLatestAssistantMessage(events)
104
+ if (!msg) {
105
+ return false
106
+ }
107
+ return isNaturalCompletion(msg)
108
+ }
109
+ ```
110
+
111
+ Notice what disappeared:
112
+
113
+ 1. no interruption flag
114
+ 2. no finished flag
115
+ 3. no special footer state
116
+ 4. no extra state machine to explain another state machine
117
+
118
+ You keep the raw thing that happened, then compute the answer when needed.
119
+
120
+ ## Rules
121
+
122
+ 1. Keep events immutable and versioned.
123
+ 2. Prefer one bounded event buffer over many mirrored flags.
124
+ 3. Derive lifecycle state with pure functions.
125
+ 4. Persist the event stream when it helps reproduce bugs.
126
+ 5. Write tests against fixtures, not against live mutable runtime state.
127
+
128
+ ## Good fit
129
+
130
+ - session lifecycle state
131
+ - workflow engines
132
+ - chat or agent runtimes
133
+ - typing/idle/footer decisions
134
+ - retry and interruption logic
135
+
136
+ ## Bad fit
137
+
138
+ - raw high-volume telemetry that is never read back
139
+ - tiny local state better kept inside a closure
140
+ - data that is already a stable source of truth elsewhere
141
+
142
+ ## Testing workflow
143
+
144
+ 1. Export a failing event stream from production or local runtime.
145
+ 2. Save it as a fixture (jsonl file).
146
+ 3. Write a pure test around the derivation function.
147
+ 4. Fix the derivation code.
148
+ 5. Keep the fixture so the bug stays dead.
149
+
150
+ Any model can one-shot these problems because the feedback loop is obvious:
151
+ events in, answer out.
152
+
153
+ ```ts
154
+ import fs from 'node:fs'
155
+
156
+ function loadEvents(file: string): SessionEvent[] {
157
+ return fs
158
+ .readFileSync(file, 'utf8')
159
+ .split('\n')
160
+ .filter(Boolean)
161
+ .map((line) => {
162
+ return JSON.parse(line) as SessionEvent
163
+ })
164
+ }
165
+
166
+ test('footer is hidden for aborted runs', () => {
167
+ const events = loadEvents('./fixtures/aborted-session.jsonl')
168
+ expect(shouldShowFooter(events)).toBe(false)
169
+ })
170
+ ```
171
+
172
+ The reproduction artifact is just data:
173
+
174
+ 1. no mocking the runtime
175
+ 2. no mocking timers
176
+ 3. no begging the runtime to reproduce the exact bad interleaving again
177
+ 4. just events in, answer out
178
+
179
+ ## Persistency
180
+
181
+ If you want persistence you just store the events. Events are easily versioned
182
+ and type-safe.
183
+
184
+ The trade is this:
185
+
186
+ - **Storing cached state**: if a user hits a broken state and you persist it,
187
+ the project is gone. Opening it crashes the app. To fix it you need migration
188
+ code that patches the corrupted state. Tedious and fragile.
189
+ - **Storing the event stream**: you fix the derivation functions, release a new
190
+ version, the user opens the project, and it works again. What matters is
191
+ keeping events immutable and versioned so derivation functions are guaranteed
192
+ to process events from older app versions and return valid state.
193
+
194
+ State is cached conclusions. Events are stored evidence. Evidence ages better.
195
+
196
+ If you can derive it, don't store it.
197
+
198
+ ## State encapsulation
199
+
200
+ The next best thing after no state is state you don't care about because it is
201
+ encapsulated.
202
+
203
+ Not everything needs event sourcing. The second-best option is state you
204
+ successfully hide. A good example is React `useState`: state can only be
205
+ written in event handlers within the component subtree and can only be read in
206
+ the current component. It is local and easy to reason about.
207
+
208
+ The same applies to backend code. Instead of promoting a timer or counter into
209
+ a class field visible to all methods, encapsulate it in a closure:
210
+
211
+ ```ts
212
+ // bad: timer is a class field, visible to all methods, agents will touch it
213
+ class MessageWriter {
214
+ private debounceTimeout: ReturnType<typeof setTimeout> | null = null
215
+
216
+ queueSend(text: string): void {
217
+ if (this.debounceTimeout) {
218
+ clearTimeout(this.debounceTimeout)
219
+ }
220
+ this.debounceTimeout = setTimeout(() => {
221
+ this.write(text)
222
+ }, 300)
223
+ }
224
+ }
225
+
226
+ // good: timer is trapped in a tiny box, no other consumer can touch it
227
+ function createDebouncedAction(callback: () => void, delayMs = 300) {
228
+ let timeout: ReturnType<typeof setTimeout> | null = null
229
+
230
+ function clear(): void {
231
+ if (!timeout) {
232
+ return
233
+ }
234
+ clearTimeout(timeout)
235
+ timeout = null
236
+ }
237
+
238
+ function trigger(): void {
239
+ clear()
240
+ timeout = setTimeout(() => {
241
+ timeout = null
242
+ callback()
243
+ }, delayMs)
244
+ }
245
+
246
+ return { trigger, clear }
247
+ }
248
+ ```
249
+
250
+ A global variable has the potential of doubling your app state. An encapsulated
251
+ closure can only double the states of that tiny function. Given it is so small
252
+ you don't care — spotting a bug inside it is easy for you and agents.
@@ -430,6 +430,7 @@ cli
430
430
  return
431
431
  }
432
432
  // Interactive path (humans)
433
+ // NEVER use hint in clack select options. looks ugly
433
434
  const provider = await select({ message: 'Select provider', options: [...] })
434
435
  if (isCancel(provider)) { cancel(); process.exit(0) }
435
436
  const key = await password({ message: 'Paste API key' })