kimaki 0.4.77 → 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.
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.77",
5
+ "version": "0.4.78",
6
6
  "repository": "https://github.com/remorses/kimaki",
7
7
  "bin": "bin.js",
8
8
  "files": [
@@ -22,9 +22,9 @@
22
22
  "eventsource-parser": "^3.0.6",
23
23
  "prisma": "7.4.2",
24
24
  "tsx": "^4.20.5",
25
+ "discord-digital-twin": "^0.1.0",
25
26
  "opencode-cached-provider": "^0.0.1",
26
27
  "opencode-deterministic-provider": "^0.0.1",
27
- "discord-digital-twin": "^0.1.0",
28
28
  "db": "^0.0.0"
29
29
  },
30
30
  "dependencies": {
@@ -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,11 +56,12 @@
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",
61
- "traforo": "^0.0.9",
62
- "errore": "^0.14.0"
63
+ "errore": "^0.14.0",
64
+ "traforo": "^0.0.9"
63
65
  },
64
66
  "optionalDependencies": {
65
67
  "@discordjs/opus": "^0.10.0",
@@ -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",
@@ -5,7 +5,7 @@ description: >
5
5
  event logs plus pure derivation functions over mirrored mutable lifecycle
6
6
  flags. Use when state transitions are driven by events and bugs can be
7
7
  reproduced from a saved event stream.
8
- version: 0.1.0
8
+ version: 0.2.0
9
9
  ---
10
10
 
11
11
  <!-- Skill for event-sourced state and fixture-driven debugging. -->
@@ -19,25 +19,103 @@ phase, status, or UI state that could instead be derived from an event log.
19
19
 
20
20
  Do not store the answer when you can store the evidence.
21
21
 
22
- Instead of fields like:
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:
23
41
 
24
42
  ```ts
25
- let isRunning = false
26
- let lastModel: string | undefined
27
- let footerVisible = false
28
- let wasAborted = false
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
+ }
29
56
  ```
30
57
 
31
- store a bounded event stream and derive what you need:
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:
32
65
 
33
66
  ```ts
34
- type Event =
67
+ type SessionEvent =
35
68
  | { type: 'session.status'; status: 'busy' | 'idle' }
36
- | { type: 'message.completed'; model: string; tokensUsed: number }
37
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
+ }
38
109
  ```
39
110
 
40
- Then compute state with pure functions.
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.
41
119
 
42
120
  ## Rules
43
121
 
@@ -61,38 +139,114 @@ Then compute state with pure functions.
61
139
  - tiny local state better kept inside a closure
62
140
  - data that is already a stable source of truth elsewhere
63
141
 
64
- ## Pattern
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.
65
152
 
66
153
  ```ts
67
- type EventBufferEntry<TEvent> = {
68
- event: TEvent
69
- timestamp: number
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
+ })
70
164
  }
71
165
 
72
- function isBusy(events: EventBufferEntry<Event>[]): boolean {
73
- for (let i = events.length - 1; i >= 0; i--) {
74
- const event = events[i]?.event
75
- if (!event) {
76
- continue
77
- }
78
- if (event.type === 'session.status') {
79
- return event.status === 'busy'
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)
80
219
  }
220
+ this.debounceTimeout = setTimeout(() => {
221
+ this.write(text)
222
+ }, 300)
81
223
  }
82
- return false
83
224
  }
84
- ```
85
225
 
86
- ## Testing workflow
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
87
229
 
88
- 1. export a failing event stream from production or local runtime
89
- 2. save it as a fixture
90
- 3. write a pure test around the derivation function
91
- 4. fix the derivation code
92
- 5. keep the fixture so the bug stays dead
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
+ }
93
245
 
94
- ## State minimization rule
246
+ return { trigger, clear }
247
+ }
248
+ ```
95
249
 
96
- The next best thing after no state is state you do not care about because it
97
- is encapsulated. If something must stay mutable, hide it inside a tiny closure
98
- or helper instead of exposing it to the whole app.
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.
@@ -8,7 +8,7 @@ description: Control the user own Chrome browser via Playwriter extension with P
8
8
  **Before using playwriter, you MUST run this command:**
9
9
 
10
10
  ```bash
11
- playwriter skill
11
+ playwriter skill # IMPORTANT! do not use | head here. read in full!
12
12
  ```
13
13
 
14
14
  This outputs the complete documentation including:
package/src/cli.ts CHANGED
@@ -1089,6 +1089,16 @@ async function registerCommands({
1089
1089
  .setDescription('List and manage MCP servers for this project')
1090
1090
  .setDMPermission(false)
1091
1091
  .toJSON(),
1092
+ new SlashCommandBuilder()
1093
+ .setName('screenshare')
1094
+ .setDescription('Start screen sharing via VNC tunnel (auto-stops after 1 hour)')
1095
+ .setDMPermission(false)
1096
+ .toJSON(),
1097
+ new SlashCommandBuilder()
1098
+ .setName('screenshare-stop')
1099
+ .setDescription('Stop screen sharing')
1100
+ .setDMPermission(false)
1101
+ .toJSON(),
1092
1102
  ]
1093
1103
 
1094
1104
  // Add user-defined commands with source-based suffixes (-cmd / -skill)
@@ -4138,6 +4148,31 @@ cli
4138
4148
  },
4139
4149
  )
4140
4150
 
4151
+ cli
4152
+ .command(
4153
+ 'screenshare',
4154
+ 'Share your screen via VNC tunnel. Auto-stops after 1 hour. Runs until Ctrl+C. Use tmux to run in background.',
4155
+ )
4156
+ .action(async () => {
4157
+ const { startScreenshare } = await import(
4158
+ './commands/screenshare.js'
4159
+ )
4160
+ try {
4161
+ const session = await startScreenshare({
4162
+ sessionKey: 'cli',
4163
+ startedBy: 'cli',
4164
+ })
4165
+ cliLogger.log(`Screen sharing started: ${session.noVncUrl}`)
4166
+ cliLogger.log('Press Ctrl+C to stop')
4167
+ } catch (err) {
4168
+ cliLogger.error(
4169
+ 'Failed to start screen share:',
4170
+ err instanceof Error ? err.message : String(err),
4171
+ )
4172
+ process.exit(EXIT_NO_RESTART)
4173
+ }
4174
+ })
4175
+
4141
4176
  cli
4142
4177
  .command('sqlitedb', 'Show the location of the SQLite database file')
4143
4178
  .action(() => {
@@ -14,7 +14,7 @@ import {
14
14
  SILENT_MESSAGE_FLAGS,
15
15
  } from '../discord-utils.js'
16
16
  import { createLogger, LogPrefix } from '../logger.js'
17
- import { execAsync } from '../worktrees.js'
17
+ import { uploadGitDiffViaCritique } from '../critique-utils.js'
18
18
 
19
19
  const logger = createLogger(LogPrefix.DIFF)
20
20
 
@@ -63,103 +63,29 @@ export async function handleDiffCommand({
63
63
 
64
64
  await command.deferReply({ flags: SILENT_MESSAGE_FLAGS })
65
65
 
66
- try {
67
- const projectName = path.basename(workingDirectory)
68
- const title = `${projectName}: Discord /diff`
69
- const { stdout, stderr } = await execAsync(
70
- `bunx critique --web "${title}" --json`,
71
- {
72
- cwd: workingDirectory,
73
- timeout: 30000,
74
- },
75
- )
76
-
77
- // critique --json outputs JSON on the last line: {"url":"...","id":"..."} or {"error":"..."}
78
- const output = stdout || stderr
79
- const lines = output.trim().split('\n')
80
- const jsonLine = lines[lines.length - 1]
81
- if (!jsonLine) {
82
- await command.editReply({
83
- content: 'No changes to show',
84
- })
85
- return
86
- }
87
-
88
- let result: { url?: string; id?: string; error?: string }
89
- try {
90
- result = JSON.parse(jsonLine)
91
- } catch {
92
- // Fallback: try to find URL in output
93
- const urlMatch = output.match(/https?:\/\/critique\.work\/[^\s]+/)
94
- if (urlMatch) {
95
- await command.editReply({
96
- content: `[diff](${urlMatch[0]})`,
97
- })
98
- logger.log(`Diff shared: ${urlMatch[0]}`)
99
- return
100
- }
101
- await command.editReply({
102
- content: 'No changes to show',
103
- })
104
- return
105
- }
106
-
107
- if (result.error || !result.url || !result.id) {
108
- await command.editReply({
109
- content: result.error || 'No changes to show',
110
- })
111
- return
112
- }
113
-
114
- const imageUrl = `https://critique.work/og/${result.id}.png`
115
- const embed = new EmbedBuilder()
116
- .setTitle(title)
117
- .setURL(result.url)
118
- .setImage(imageUrl)
119
-
120
- await command.editReply({
121
- embeds: [embed],
122
- })
123
- logger.log(`Diff shared: ${result.url}`)
124
- } catch (error) {
125
- logger.error('[DIFF] Error:', error)
126
-
127
- // exec error includes stdout/stderr - try to parse JSON from it
128
- const execError = error as {
129
- stdout?: string
130
- stderr?: string
131
- message?: string
132
- }
133
- const output = execError.stdout || execError.stderr || ''
134
-
135
- // Check if critique output JSON even on error
136
- const lines = output.trim().split('\n')
137
- const jsonLine = lines[lines.length - 1]
138
- if (jsonLine) {
139
- try {
140
- const result = JSON.parse(jsonLine) as { error?: string }
141
- if (result.error) {
142
- await command.editReply({
143
- content: result.error,
144
- })
145
- return
146
- }
147
- } catch {
148
- // not JSON, continue to generic error
149
- }
150
- }
151
-
152
- // Check for common errors
153
- const message = execError.message || 'Unknown error'
154
- if (message.includes('command not found') || message.includes('ENOENT')) {
155
- await command.editReply({
156
- content: 'bunx/critique not available',
157
- })
158
- return
159
- }
160
-
161
- await command.editReply({
162
- content: `Failed to generate diff: ${message.slice(0, 200)}`,
163
- })
66
+ const projectName = path.basename(workingDirectory)
67
+ const title = `${projectName}: Discord /diff`
68
+ const result = await uploadGitDiffViaCritique({
69
+ title,
70
+ cwd: workingDirectory,
71
+ })
72
+
73
+ if (!result) {
74
+ await command.editReply({ content: 'No changes to show' })
75
+ return
164
76
  }
77
+
78
+ if (result.error || !result.url) {
79
+ await command.editReply({ content: result.error || 'No changes to show' })
80
+ return
81
+ }
82
+
83
+ const imageUrl = `https://critique.work/og/${result.id}.png`
84
+ const embed = new EmbedBuilder()
85
+ .setTitle(title)
86
+ .setURL(result.url)
87
+ .setImage(imageUrl)
88
+
89
+ await command.editReply({ embeds: [embed] })
90
+ logger.log(`Diff shared: ${result.url}`)
165
91
  }