kimaki 0.26.0 → 0.27.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 (214) hide show
  1. package/dist/agent-model.e2e.test.js +25 -122
  2. package/dist/anthropic-account-identity.js +1 -0
  3. package/dist/anthropic-auth-plugin.js +1 -0
  4. package/dist/anthropic-auth-state.js +1 -0
  5. package/dist/bin.js +73 -19
  6. package/dist/cli-commands/maintenance.js +46 -7
  7. package/dist/cli-commands/send.js +24 -0
  8. package/dist/cli-commands/session.js +183 -8
  9. package/dist/cli-commands/task.js +13 -1
  10. package/dist/cli-parsing.test.js +59 -3
  11. package/dist/cli-runner.js +28 -4
  12. package/dist/cli-runner.test.js +27 -0
  13. package/dist/cli-send-thread.e2e.test.js +1 -1
  14. package/dist/cli.js +32 -0
  15. package/dist/commands/abort.js +5 -1
  16. package/dist/commands/agent.js +6 -8
  17. package/dist/commands/ask-question.js +21 -32
  18. package/dist/commands/ask-question.test.js +43 -1
  19. package/dist/commands/login.js +6 -0
  20. package/dist/commands/merge-worktree.js +36 -10
  21. package/dist/commands/model-variant.js +7 -1
  22. package/dist/commands/model.js +14 -8
  23. package/dist/commands/multioauth.js +1 -0
  24. package/dist/commands/new-worktree.js +170 -102
  25. package/dist/commands/user-command.js +6 -8
  26. package/dist/commands/worktrees.js +5 -13
  27. package/dist/config.js +16 -1
  28. package/dist/database.js +124 -3
  29. package/dist/db.js +22 -0
  30. package/dist/db.test.js +167 -1
  31. package/dist/discord-bot.js +34 -32
  32. package/dist/discord-command-registration.js +9 -1
  33. package/dist/discord-utils.js +13 -5
  34. package/dist/discord-utils.test.js +17 -1
  35. package/dist/errors.js +5 -0
  36. package/dist/exec-async.js +17 -5
  37. package/dist/external-directory-permissions.e2e.test.js +2 -2
  38. package/dist/file-edit-log.js +197 -0
  39. package/dist/file-edit-log.test.js +258 -0
  40. package/dist/format-tables.js +52 -0
  41. package/dist/format-tables.test.js +93 -0
  42. package/dist/gateway-proxy-reconnect.e2e.test.js +28 -8
  43. package/dist/gateway-proxy.e2e.test.js +5 -5
  44. package/dist/git-worktree-core.js +182 -17
  45. package/dist/ipc-tools-plugin.js +63 -3
  46. package/dist/kimaki-opencode-plugin-loading.e2e.test.js +122 -58
  47. package/dist/kimaki-opencode-plugin.js +7 -3
  48. package/dist/kimaki-workspace-adaptor.js +33 -10
  49. package/dist/logger.js +10 -1
  50. package/dist/markdown.js +3 -1
  51. package/dist/markdown.test.js +11 -3
  52. package/dist/message-formatting.js +18 -7
  53. package/dist/message-formatting.test.js +56 -9
  54. package/dist/oauth-rotation-shared.js +10 -0
  55. package/dist/openai-auth-plugin.js +1 -0
  56. package/dist/openai-auth-state.js +1 -0
  57. package/dist/opencode-command.test.js +60 -0
  58. package/dist/opencode.js +53 -12
  59. package/dist/question-abort-clears-dropdown.e2e.test.js +89 -0
  60. package/dist/question-text-before-dropdown.e2e.test.js +52 -0
  61. package/dist/queue-advanced-abort.e2e.test.js +1 -1
  62. package/dist/queue-advanced-action-buttons.e2e.test.js +3 -3
  63. package/dist/queue-advanced-e2e-setup.js +78 -5
  64. package/dist/queue-advanced-footer.e2e.test.js +40 -246
  65. package/dist/queue-advanced-model-switch.e2e.test.js +2 -2
  66. package/dist/queue-advanced-permissions-typing.e2e.test.js +7 -9
  67. package/dist/queue-advanced-question.e2e.test.js +31 -26
  68. package/dist/queue-advanced-typing-interrupt.e2e.test.js +2 -2
  69. package/dist/queue-delete-message.e2e.test.js +1 -1
  70. package/dist/queue-drain-after-interactive-ui.e2e.test.js +2 -2
  71. package/dist/queue-interrupt-drain.e2e.test.js +112 -2
  72. package/dist/queue-question-select-drain.e2e.test.js +30 -128
  73. package/dist/runtime-lifecycle.e2e.test.js +10 -37
  74. package/dist/schema.js +34 -0
  75. package/dist/session-handler/event-stream-state.js +267 -50
  76. package/dist/session-handler/event-stream-state.test.js +415 -8
  77. package/dist/session-handler/model-utils.js +159 -5
  78. package/dist/session-handler/model-utils.test.js +345 -0
  79. package/dist/session-handler/thread-session-runtime.js +252 -182
  80. package/dist/session-sleep.test.js +224 -0
  81. package/dist/session-title-rename.test.js +7 -39
  82. package/dist/sleep.e2e.test.js +491 -0
  83. package/dist/startup-time.e2e.test.js +2 -1
  84. package/dist/store.js +3 -0
  85. package/dist/system-message.js +59 -6
  86. package/dist/system-message.test.js +76 -6
  87. package/dist/task-runner.js +94 -4
  88. package/dist/task-schedule.js +56 -0
  89. package/dist/task-schedule.test.js +71 -1
  90. package/dist/thread-message-queue.e2e.test.js +10 -169
  91. package/dist/undo-redo.e2e.test.js +2 -2
  92. package/dist/voice-attachment.js +23 -1
  93. package/dist/voice-message.e2e.test.js +10 -10
  94. package/dist/voice.test.js +55 -29
  95. package/dist/worktree-clone-isolation.e2e.test.js +188 -0
  96. package/dist/worktree-lifecycle.e2e.test.js +29 -10
  97. package/dist/worktrees.js +226 -443
  98. package/dist/worktrees.test.js +435 -58
  99. package/dist/xai-auth-plugin.js +1 -0
  100. package/dist/xai-auth-state.js +1 -0
  101. package/package.json +8 -6
  102. package/skills/errore/SKILL.md +18 -0
  103. package/skills/goke/SKILL.md +150 -10
  104. package/skills/holocron/SKILL.md +37 -0
  105. package/skills/npm-package/SKILL.md +55 -35
  106. package/skills/profano/SKILL.md +17 -2
  107. package/skills/spiceflow/SKILL.md +39 -3
  108. package/skills/strada/SKILL.md +39 -0
  109. package/skills/zele/SKILL.md +19 -0
  110. package/src/agent-model.e2e.test.ts +25 -151
  111. package/src/anthropic-account-identity.ts +1 -0
  112. package/src/anthropic-auth-plugin.ts +1 -0
  113. package/src/anthropic-auth-state.ts +1 -0
  114. package/src/bin.ts +78 -26
  115. package/src/cli-commands/maintenance.ts +59 -13
  116. package/src/cli-commands/send.ts +25 -0
  117. package/src/cli-commands/session.ts +203 -9
  118. package/src/cli-commands/task.ts +13 -1
  119. package/src/cli-parsing.test.ts +69 -3
  120. package/src/cli-runner.test.ts +49 -0
  121. package/src/cli-runner.ts +28 -4
  122. package/src/cli-send-thread.e2e.test.ts +1 -1
  123. package/src/cli.ts +53 -0
  124. package/src/commands/abort.ts +6 -1
  125. package/src/commands/agent.ts +6 -6
  126. package/src/commands/ask-question.test.ts +48 -1
  127. package/src/commands/ask-question.ts +21 -32
  128. package/src/commands/login.ts +6 -0
  129. package/src/commands/merge-worktree.ts +43 -12
  130. package/src/commands/model-variant.ts +8 -1
  131. package/src/commands/model.ts +18 -8
  132. package/src/commands/multioauth.ts +1 -0
  133. package/src/commands/new-worktree.ts +138 -55
  134. package/src/commands/user-command.ts +6 -6
  135. package/src/commands/worktrees.ts +5 -12
  136. package/src/config.ts +18 -1
  137. package/src/database.ts +195 -3
  138. package/src/db.test.ts +188 -0
  139. package/src/db.ts +26 -0
  140. package/src/discord-bot.ts +35 -29
  141. package/src/discord-command-registration.ts +15 -1
  142. package/src/discord-utils.test.ts +19 -0
  143. package/src/discord-utils.ts +21 -6
  144. package/src/errors.ts +5 -0
  145. package/src/exec-async.ts +20 -11
  146. package/src/external-directory-permissions.e2e.test.ts +2 -2
  147. package/src/file-edit-log.test.ts +287 -0
  148. package/src/file-edit-log.ts +283 -0
  149. package/src/format-tables.test.ts +101 -0
  150. package/src/format-tables.ts +75 -0
  151. package/src/gateway-proxy-reconnect.e2e.test.ts +38 -8
  152. package/src/gateway-proxy.e2e.test.ts +5 -5
  153. package/src/git-worktree-core.ts +247 -20
  154. package/src/ipc-tools-plugin.ts +75 -5
  155. package/src/kimaki-opencode-plugin-loading.e2e.test.ts +188 -97
  156. package/src/kimaki-opencode-plugin.ts +7 -3
  157. package/src/kimaki-workspace-adaptor.ts +40 -11
  158. package/src/logger.ts +13 -4
  159. package/src/markdown.test.ts +13 -3
  160. package/src/markdown.ts +4 -1
  161. package/src/message-formatting.test.ts +86 -12
  162. package/src/message-formatting.ts +19 -7
  163. package/src/oauth-rotation-shared.ts +10 -0
  164. package/src/openai-auth-plugin.ts +1 -0
  165. package/src/openai-auth-state.ts +1 -0
  166. package/src/opencode-command.test.ts +81 -0
  167. package/src/opencode.ts +71 -11
  168. package/src/question-abort-clears-dropdown.e2e.test.ts +109 -0
  169. package/src/question-text-before-dropdown.e2e.test.ts +67 -0
  170. package/src/queue-advanced-abort.e2e.test.ts +1 -1
  171. package/src/queue-advanced-action-buttons.e2e.test.ts +3 -3
  172. package/src/queue-advanced-e2e-setup.ts +83 -4
  173. package/src/queue-advanced-footer.e2e.test.ts +47 -294
  174. package/src/queue-advanced-model-switch.e2e.test.ts +2 -2
  175. package/src/queue-advanced-permissions-typing.e2e.test.ts +7 -9
  176. package/src/queue-advanced-question.e2e.test.ts +35 -29
  177. package/src/queue-advanced-typing-interrupt.e2e.test.ts +2 -2
  178. package/src/queue-delete-message.e2e.test.ts +1 -1
  179. package/src/queue-drain-after-interactive-ui.e2e.test.ts +2 -2
  180. package/src/queue-interrupt-drain.e2e.test.ts +123 -2
  181. package/src/queue-question-select-drain.e2e.test.ts +32 -157
  182. package/src/runtime-lifecycle.e2e.test.ts +10 -41
  183. package/src/schema.sql +14 -1
  184. package/src/schema.ts +36 -0
  185. package/src/session-handler/event-stream-state.test.ts +437 -7
  186. package/src/session-handler/event-stream-state.ts +389 -53
  187. package/src/session-handler/model-utils.test.ts +415 -0
  188. package/src/session-handler/model-utils.ts +249 -5
  189. package/src/session-handler/thread-session-runtime.ts +300 -215
  190. package/src/session-sleep.test.ts +294 -0
  191. package/src/session-title-rename.test.ts +9 -48
  192. package/src/sleep.e2e.test.ts +562 -0
  193. package/src/startup-time.e2e.test.ts +5 -1
  194. package/src/store.ts +21 -0
  195. package/src/system-message.test.ts +86 -6
  196. package/src/system-message.ts +66 -7
  197. package/src/task-runner.ts +136 -2
  198. package/src/task-schedule.test.ts +76 -0
  199. package/src/task-schedule.ts +95 -0
  200. package/src/thread-message-queue.e2e.test.ts +10 -202
  201. package/src/undo-redo.e2e.test.ts +2 -2
  202. package/src/voice-attachment.ts +29 -1
  203. package/src/voice-message.e2e.test.ts +10 -10
  204. package/src/voice.test.ts +63 -29
  205. package/src/worktree-clone-isolation.e2e.test.ts +213 -0
  206. package/src/worktree-lifecycle.e2e.test.ts +29 -9
  207. package/src/worktrees.test.ts +505 -64
  208. package/src/worktrees.ts +257 -603
  209. package/src/xai-auth-plugin.ts +1 -0
  210. package/src/xai-auth-state.ts +1 -0
  211. package/dist/message-finish-field.e2e.test.js +0 -169
  212. package/dist/subagent-rate-limit-plugin.js +0 -184
  213. package/src/message-finish-field.e2e.test.ts +0 -196
  214. package/src/subagent-rate-limit-plugin.ts +0 -228
@@ -254,12 +254,6 @@ export async function archiveThread({
254
254
  if (updateResult instanceof Error) {
255
255
  discordLogger.warn(`[archive-thread] ${updateResult.message}`)
256
256
  }
257
-
258
- const abortResult = await client.session.abort({ sessionID: sessionId })
259
- .catch((e) => new Error('Failed to abort session', { cause: e }))
260
- if (abortResult instanceof Error) {
261
- discordLogger.warn(`[archive-thread] ${abortResult.message}`)
262
- }
263
257
  }
264
258
 
265
259
  if (archiveDelay > 0) {
@@ -275,6 +269,27 @@ export async function archiveThread({
275
269
  })
276
270
  }
277
271
 
272
+ export const DISCORD_THREAD_RENAME_TIMEOUT_MS = 3000
273
+
274
+ // Discord rename can hang on the 3rd call (~2 per 10 min). Race so it never blocks.
275
+ export async function raceDiscordRename<T>({
276
+ rename,
277
+ timeoutMs = DISCORD_THREAD_RENAME_TIMEOUT_MS,
278
+ }: {
279
+ rename: Promise<T>
280
+ timeoutMs?: number
281
+ }): Promise<T | 'timeout'> {
282
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
283
+ return Promise.race([
284
+ rename,
285
+ new Promise<'timeout'>((resolve) => {
286
+ timeoutSignal.addEventListener('abort', () => {
287
+ resolve('timeout')
288
+ })
289
+ }),
290
+ ])
291
+ }
292
+
278
293
  /**
279
294
  * Add a user as a thread member so the thread shows up in their Discord left
280
295
  * sidebar. Discord only lists threads you are a member of, so a scheduled
package/src/errors.ts CHANGED
@@ -117,6 +117,11 @@ export class OpenCodeSdkError extends errore.createTaggedError({
117
117
  message: 'OpenCode SDK call failed: $operation',
118
118
  }) {}
119
119
 
120
+ export class InvalidModelError extends errore.createTaggedError({
121
+ name: 'InvalidModelError',
122
+ message: 'Invalid model "$model": $reason',
123
+ }) {}
124
+
120
125
  export class FilesystemOperationError extends errore.createTaggedError({
121
126
  name: 'FilesystemOperationError',
122
127
  message: 'Filesystem operation failed: $operation',
package/src/exec-async.ts CHANGED
@@ -1,32 +1,41 @@
1
- import { exec } from 'node:child_process'
1
+ import { exec, execFile } from 'node:child_process'
2
2
  import { promisify } from 'node:util'
3
3
 
4
4
  const DEFAULT_EXEC_TIMEOUT_MS = 10_000
5
5
 
6
6
  const _execAsync = promisify(exec)
7
+ const _execFileAsync = promisify(execFile)
7
8
 
8
9
  export function execAsync(
9
- command: string,
10
+ command: string | { command: string; args: string[] },
10
11
  options?: Parameters<typeof _execAsync>[1],
11
12
  ): Promise<{ stdout: string; stderr: string }> {
12
- const timeoutMs =
13
- (options as { timeout?: number })?.timeout || DEFAULT_EXEC_TIMEOUT_MS
14
- const execPromise = _execAsync(command, options) as Promise<{
15
- stdout: string
16
- stderr: string
17
- }> & { child?: import('node:child_process').ChildProcess }
13
+ const timeoutMs = options?.timeout || DEFAULT_EXEC_TIMEOUT_MS
14
+ const childPromise =
15
+ typeof command === 'string'
16
+ ? _execAsync(command, options)
17
+ : _execFileAsync(command.command, command.args, options)
18
+ const execPromise = childPromise.then(({ stdout, stderr }) => {
19
+ return {
20
+ stdout: typeof stdout === 'string' ? stdout : stdout.toString(),
21
+ stderr: typeof stderr === 'string' ? stderr : stderr.toString(),
22
+ }
23
+ })
24
+ const commandLabel = typeof command === 'string'
25
+ ? command
26
+ : [command.command, ...command.args].join(' ')
18
27
  let timer: ReturnType<typeof setTimeout> | undefined
19
28
  const timeoutPromise = new Promise<never>((_, reject) => {
20
29
  timer = setTimeout(() => {
21
- const pid = execPromise.child?.pid
30
+ const pid = childPromise.child?.pid
22
31
  if (pid) {
23
32
  try {
24
33
  process.kill(-pid, 'SIGTERM')
25
34
  } catch {
26
- execPromise.child?.kill('SIGTERM')
35
+ childPromise.child?.kill('SIGTERM')
27
36
  }
28
37
  }
29
- reject(new Error(`Command timed out after ${timeoutMs}ms: ${command}`))
38
+ reject(new Error(`Command timed out after ${timeoutMs}ms: ${commandLabel}`))
30
39
  }, timeoutMs)
31
40
  })
32
41
  return Promise.race([execPromise, timeoutPromise]).finally(() => {
@@ -85,14 +85,14 @@ describe('external directory permissions', () => {
85
85
  ⬥ reading external directory
86
86
  ┣ read *probe.txt*
87
87
  ⬥ external-directory-probe-done
88
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*
88
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>
89
89
  --- from: user (external-directory-tester)
90
90
  EXTERNAL_DIRECTORY_PROBE_MARKER followup
91
91
  --- from: assistant (TestBot)
92
92
  ⬥ reading external directory
93
93
  ┣ read *probe.txt*
94
94
  ⬥ external-directory-probe-done
95
- *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2*"
95
+ *project ⋅ main ⋅ Ns ⋅ N% ⋅ deterministic-v2* <@200000000000000991>"
96
96
  `)
97
97
  expect(text).not.toContain('Permission Required')
98
98
  })
@@ -0,0 +1,287 @@
1
+ // Tests for file-edit event extraction, derivation, and JSONL persistence.
2
+
3
+ import fs from 'node:fs'
4
+ import os from 'node:os'
5
+ import path from 'node:path'
6
+ import { afterEach, describe, expect, test } from 'vitest'
7
+ import {
8
+ appendFileEditEvents,
9
+ createFileEditHooks,
10
+ editorsForFile,
11
+ extractEditedFiles,
12
+ loadFileEditEvents,
13
+ type FileEditEvent,
14
+ } from './file-edit-log.js'
15
+
16
+ const tempDirs: string[] = []
17
+
18
+ function makeDataDir() {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-file-edit-log-'))
20
+ tempDirs.push(dir)
21
+ return dir
22
+ }
23
+
24
+ afterEach(() => {
25
+ for (const dir of tempDirs.splice(0)) {
26
+ fs.rmSync(dir, { recursive: true, force: true })
27
+ }
28
+ })
29
+
30
+ function event(partial: Partial<FileEditEvent> & Pick<FileEditEvent, 'sessionId' | 'file'>): FileEditEvent {
31
+ return {
32
+ v: 1,
33
+ at: 1,
34
+ tool: 'edit',
35
+ ...partial,
36
+ }
37
+ }
38
+
39
+ describe('extractEditedFiles', () => {
40
+ test('extracts edit and write filePath and ignores other tools', () => {
41
+ expect(
42
+ extractEditedFiles({
43
+ tool: 'edit',
44
+ args: { filePath: 'src/foo.ts' },
45
+ directory: '/repo',
46
+ }),
47
+ ).toMatchInlineSnapshot(`
48
+ [
49
+ "/repo/src/foo.ts",
50
+ ]
51
+ `)
52
+ expect(
53
+ extractEditedFiles({
54
+ tool: 'write',
55
+ args: { filePath: '/abs/bar.ts' },
56
+ directory: '/repo',
57
+ }),
58
+ ).toMatchInlineSnapshot(`
59
+ [
60
+ "/abs/bar.ts",
61
+ ]
62
+ `)
63
+ expect(
64
+ extractEditedFiles({
65
+ tool: 'read',
66
+ args: { filePath: 'src/foo.ts' },
67
+ directory: '/repo',
68
+ }),
69
+ ).toMatchInlineSnapshot(`
70
+ []
71
+ `)
72
+ expect(
73
+ extractEditedFiles({
74
+ tool: 'bash',
75
+ args: {},
76
+ directory: '/repo',
77
+ }),
78
+ ).toMatchInlineSnapshot(`
79
+ []
80
+ `)
81
+ })
82
+
83
+ test('extracts apply_patch paths and resolves them against directory', () => {
84
+ const patchText = [
85
+ '*** Begin Patch',
86
+ '*** Update File: src/foo.ts',
87
+ '@@',
88
+ '-a',
89
+ '+b',
90
+ '*** Add File: src/bar.ts',
91
+ '+hello',
92
+ '*** Delete File: src/old.ts',
93
+ '*** Move to: src/renamed.ts',
94
+ '*** End Patch',
95
+ ].join('\n')
96
+
97
+ expect(
98
+ extractEditedFiles({
99
+ tool: 'apply_patch',
100
+ args: { patchText },
101
+ directory: '/repo',
102
+ }),
103
+ ).toMatchInlineSnapshot(`
104
+ [
105
+ "/repo/src/foo.ts",
106
+ "/repo/src/bar.ts",
107
+ "/repo/src/old.ts",
108
+ "/repo/src/renamed.ts",
109
+ ]
110
+ `)
111
+ })
112
+ })
113
+
114
+ describe('editorsForFile', () => {
115
+ test('orders unique sessions by last edit and matches relative paths', () => {
116
+ const events: FileEditEvent[] = [
117
+ event({ sessionId: 'ses_old', file: '/repo/src/foo.ts', at: 10 }),
118
+ event({ sessionId: 'ses_other', file: '/repo/src/other.ts', at: 50 }),
119
+ event({ sessionId: 'ses_new', file: '/repo/src/foo.ts', at: 20 }),
120
+ event({ sessionId: 'ses_old', file: '/repo/src/foo.ts', at: 40, tool: 'write' }),
121
+ ]
122
+
123
+ expect(
124
+ editorsForFile({
125
+ events,
126
+ filePath: 'src/foo.ts',
127
+ cwd: '/repo',
128
+ }),
129
+ ).toMatchInlineSnapshot(`
130
+ [
131
+ {
132
+ "at": 40,
133
+ "sessionId": "ses_old",
134
+ },
135
+ {
136
+ "at": 20,
137
+ "sessionId": "ses_new",
138
+ },
139
+ ]
140
+ `)
141
+ })
142
+ })
143
+
144
+ describe('file edit jsonl log', () => {
145
+ test('appends events and loads them back', () => {
146
+ const dataDir = makeDataDir()
147
+ const first: FileEditEvent[] = [
148
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 1 }),
149
+ ]
150
+ const second: FileEditEvent[] = [
151
+ event({ sessionId: 'ses_b', file: '/repo/b.ts', at: 2, tool: 'write' }),
152
+ ]
153
+
154
+ const appended = appendFileEditEvents({ dataDir, events: first })
155
+ if (appended instanceof Error) throw appended
156
+ const appendedAgain = appendFileEditEvents({ dataDir, events: second })
157
+ if (appendedAgain instanceof Error) throw appendedAgain
158
+
159
+ const loaded = loadFileEditEvents({ dataDir })
160
+ if (loaded instanceof Error) throw loaded
161
+ expect(loaded).toMatchInlineSnapshot(`
162
+ [
163
+ {
164
+ "at": 1,
165
+ "file": "/repo/a.ts",
166
+ "sessionId": "ses_a",
167
+ "tool": "edit",
168
+ "v": 1,
169
+ },
170
+ {
171
+ "at": 2,
172
+ "file": "/repo/b.ts",
173
+ "sessionId": "ses_b",
174
+ "tool": "write",
175
+ "v": 1,
176
+ },
177
+ ]
178
+ `)
179
+ })
180
+
181
+ test('compacts to the newest events when the log is too large', () => {
182
+ const dataDir = makeDataDir()
183
+ const events: FileEditEvent[] = [
184
+ event({ sessionId: 'ses_1', file: '/repo/a.ts', at: 1 }),
185
+ event({ sessionId: 'ses_2', file: '/repo/b.ts', at: 2 }),
186
+ event({ sessionId: 'ses_3', file: '/repo/c.ts', at: 3 }),
187
+ ]
188
+ const appended = appendFileEditEvents({
189
+ dataDir,
190
+ events,
191
+ maxEvents: 2,
192
+ compactAfterBytes: 1,
193
+ })
194
+ if (appended instanceof Error) throw appended
195
+
196
+ const loaded = loadFileEditEvents({ dataDir })
197
+ if (loaded instanceof Error) throw loaded
198
+ expect(loaded).toMatchInlineSnapshot(`
199
+ [
200
+ {
201
+ "at": 2,
202
+ "file": "/repo/b.ts",
203
+ "sessionId": "ses_2",
204
+ "tool": "edit",
205
+ "v": 1,
206
+ },
207
+ {
208
+ "at": 3,
209
+ "file": "/repo/c.ts",
210
+ "sessionId": "ses_3",
211
+ "tool": "edit",
212
+ "v": 1,
213
+ },
214
+ ]
215
+ `)
216
+ })
217
+
218
+ test('compacts repeated edits of the same session and file to one row', () => {
219
+ const dataDir = makeDataDir()
220
+ const events: FileEditEvent[] = [
221
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 1 }),
222
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 2, tool: 'write' }),
223
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 3 }),
224
+ ]
225
+ const appended = appendFileEditEvents({
226
+ dataDir,
227
+ events,
228
+ maxEvents: 10,
229
+ compactAfterBytes: 1,
230
+ })
231
+ if (appended instanceof Error) throw appended
232
+
233
+ const loaded = loadFileEditEvents({ dataDir })
234
+ if (loaded instanceof Error) throw loaded
235
+ expect(loaded).toMatchInlineSnapshot(`
236
+ [
237
+ {
238
+ "at": 3,
239
+ "file": "/repo/a.ts",
240
+ "sessionId": "ses_a",
241
+ "tool": "edit",
242
+ "v": 1,
243
+ },
244
+ ]
245
+ `)
246
+ })
247
+ })
248
+
249
+ describe('fileEditTrackerPlugin', () => {
250
+ test('records edit and apply_patch and ignores bash', async () => {
251
+ const dataDir = makeDataDir()
252
+ const hooks = createFileEditHooks({ dataDir, directory: '/repo' })
253
+ await hooks['tool.execute.after']({
254
+ tool: 'edit',
255
+ sessionID: 'ses_x',
256
+ args: { filePath: 'src/a.ts' },
257
+ })
258
+ await hooks['tool.execute.after']({
259
+ tool: 'apply_patch',
260
+ sessionID: 'ses_x',
261
+ args: { patchText: '*** Update File: src/b.ts\n' },
262
+ })
263
+ await hooks['tool.execute.after']({
264
+ tool: 'bash',
265
+ sessionID: 'ses_x',
266
+ args: {},
267
+ })
268
+ const loaded = loadFileEditEvents({ dataDir })
269
+ if (loaded instanceof Error) throw loaded
270
+ expect(loaded.map((entry) => {
271
+ return { sessionId: entry.sessionId, file: entry.file, tool: entry.tool }
272
+ })).toMatchInlineSnapshot(`
273
+ [
274
+ {
275
+ "file": "/repo/src/a.ts",
276
+ "sessionId": "ses_x",
277
+ "tool": "edit",
278
+ },
279
+ {
280
+ "file": "/repo/src/b.ts",
281
+ "sessionId": "ses_x",
282
+ "tool": "apply_patch",
283
+ },
284
+ ]
285
+ `)
286
+ })
287
+ })
@@ -0,0 +1,283 @@
1
+ // Tracks which OpenCode sessions last edited each file.
2
+ // Plugin appends JSONL events; CLI derives the per-file session list on read.
3
+
4
+ import type { Plugin } from '@opencode-ai/plugin'
5
+ import fs from 'node:fs'
6
+ import path from 'node:path'
7
+ import * as errore from 'errore'
8
+ import { FilesystemOperationError } from './errors.js'
9
+ import { extractPatchFilePaths } from './patch-text-parser.js'
10
+ import { createPluginLogger, setPluginLogFilePath } from './plugin-logger.js'
11
+
12
+ const logger = createPluginLogger('FILEEDIT')
13
+
14
+ export const FILE_EDIT_EVENTS_FILENAME = 'file-edit-events.jsonl'
15
+ const DEFAULT_MAX_EVENTS = 10_000
16
+ const DEFAULT_COMPACT_AFTER_BYTES = 5 * 1024 * 1024
17
+
18
+ export type FileEditTool = 'edit' | 'write' | 'apply_patch'
19
+
20
+ export type FileEditEvent = {
21
+ v: 1
22
+ at: number
23
+ sessionId: string
24
+ file: string
25
+ tool: FileEditTool
26
+ }
27
+
28
+ function isFileEditTool(tool: string): tool is FileEditTool {
29
+ return tool === 'edit' || tool === 'write' || tool === 'apply_patch'
30
+ }
31
+
32
+ type ToolArgs = {
33
+ filePath?: string
34
+ patchText?: string
35
+ }
36
+
37
+ function getStringField({ args, key }: { args: ToolArgs; key: keyof ToolArgs }) {
38
+ const value = args[key]
39
+ return typeof value === 'string' ? value : ''
40
+ }
41
+
42
+ export function extractEditedFiles({
43
+ tool,
44
+ args,
45
+ directory,
46
+ }: {
47
+ tool: string
48
+ args: ToolArgs
49
+ directory: string
50
+ }) {
51
+ const name = tool.toLowerCase()
52
+ if (name === 'edit' || name === 'write') {
53
+ const filePath = getStringField({ args, key: 'filePath' })
54
+ if (!filePath) return []
55
+ return [path.resolve(directory, filePath)]
56
+ }
57
+ if (name !== 'apply_patch') return []
58
+ const patchText = getStringField({ args, key: 'patchText' })
59
+ if (!patchText) return []
60
+ return extractPatchFilePaths(patchText).map((filePath) => {
61
+ return path.resolve(directory, filePath)
62
+ })
63
+ }
64
+
65
+ export function editorsForFile({
66
+ events,
67
+ filePath,
68
+ cwd,
69
+ }: {
70
+ events: FileEditEvent[]
71
+ filePath: string
72
+ cwd: string
73
+ }) {
74
+ const resolved = path.resolve(cwd, filePath)
75
+ const latestBySession = new Map<string, number>()
76
+ for (const event of events) {
77
+ if (path.resolve(event.file) !== resolved) continue
78
+ const previous = latestBySession.get(event.sessionId)
79
+ if (previous === undefined || event.at > previous) {
80
+ latestBySession.set(event.sessionId, event.at)
81
+ }
82
+ }
83
+ return [...latestBySession.entries()]
84
+ .map(([sessionId, at]) => {
85
+ return { sessionId, at }
86
+ })
87
+ .sort((left, right) => {
88
+ return right.at - left.at
89
+ })
90
+ }
91
+
92
+ function parseFileEditEvent(line: string) {
93
+ const parsed = errore.try(
94
+ () => JSON.parse(line) as {
95
+ v: number
96
+ at: number
97
+ sessionId: string
98
+ file: string
99
+ tool: string
100
+ },
101
+ (cause) => new FilesystemOperationError({ operation: 'parse file edit event', cause }),
102
+ )
103
+ if (parsed instanceof Error) return null
104
+ if (!parsed || typeof parsed !== 'object') return null
105
+ if (parsed.v !== 1) return null
106
+ if (typeof parsed.at !== 'number' || !Number.isFinite(parsed.at)) return null
107
+ if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0) return null
108
+ if (typeof parsed.file !== 'string' || parsed.file.length === 0) return null
109
+ if (typeof parsed.tool !== 'string' || !isFileEditTool(parsed.tool)) return null
110
+ return {
111
+ v: 1 as const,
112
+ at: parsed.at,
113
+ sessionId: parsed.sessionId,
114
+ file: parsed.file,
115
+ tool: parsed.tool,
116
+ }
117
+ }
118
+
119
+ function nodeErrorCode(error: Error) {
120
+ const cause = error.cause
121
+ if (!cause || typeof cause !== 'object') return ''
122
+ const code = Reflect.get(cause, 'code')
123
+ return typeof code === 'string' ? code : ''
124
+ }
125
+
126
+ export function loadFileEditEvents({ dataDir }: { dataDir: string }) {
127
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME)
128
+ const raw = errore.try(
129
+ () => fs.readFileSync(logPath, 'utf8'),
130
+ (cause) => new FilesystemOperationError({ operation: 'read file edit log', cause }),
131
+ )
132
+ if (raw instanceof Error) {
133
+ if (nodeErrorCode(raw) === 'ENOENT') return []
134
+ return raw
135
+ }
136
+ const events: FileEditEvent[] = []
137
+ for (const line of raw.split('\n')) {
138
+ if (!line.trim()) continue
139
+ const event = parseFileEditEvent(line)
140
+ if (event) events.push(event)
141
+ }
142
+ return events
143
+ }
144
+
145
+ function collapseFileEditEvents(events: FileEditEvent[]) {
146
+ const latest = new Map<string, FileEditEvent>()
147
+ for (const event of events) {
148
+ const key = `${event.sessionId}\0${event.file}`
149
+ const previous = latest.get(key)
150
+ if (!previous || event.at >= previous.at) latest.set(key, event)
151
+ }
152
+ return [...latest.values()].sort((left, right) => {
153
+ return left.at - right.at
154
+ })
155
+ }
156
+
157
+ function compactFileEditLog({
158
+ dataDir,
159
+ maxEvents,
160
+ }: {
161
+ dataDir: string
162
+ maxEvents: number
163
+ }) {
164
+ const loaded = loadFileEditEvents({ dataDir })
165
+ if (loaded instanceof Error) return loaded
166
+ const collapsed = collapseFileEditEvents(loaded)
167
+ const kept = collapsed.length > maxEvents ? collapsed.slice(-maxEvents) : collapsed
168
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME)
169
+ const tempPath = `${logPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`
170
+ const body = kept.map((event) => `${JSON.stringify(event)}\n`).join('')
171
+ const written = errore.try(
172
+ () => {
173
+ fs.writeFileSync(tempPath, body)
174
+ fs.renameSync(tempPath, logPath)
175
+ },
176
+ (cause) => new FilesystemOperationError({ operation: 'compact file edit log', cause }),
177
+ )
178
+ if (written instanceof Error) {
179
+ fs.rmSync(tempPath, { force: true })
180
+ return written
181
+ }
182
+ }
183
+
184
+ export function appendFileEditEvents({
185
+ dataDir,
186
+ events,
187
+ maxEvents = DEFAULT_MAX_EVENTS,
188
+ compactAfterBytes = DEFAULT_COMPACT_AFTER_BYTES,
189
+ }: {
190
+ dataDir: string
191
+ events: FileEditEvent[]
192
+ maxEvents?: number
193
+ compactAfterBytes?: number
194
+ }) {
195
+ if (events.length === 0) return
196
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME)
197
+ const prepared = errore.try(
198
+ () => {
199
+ fs.mkdirSync(dataDir, { recursive: true })
200
+ fs.appendFileSync(logPath, events.map((event) => `${JSON.stringify(event)}\n`).join(''))
201
+ },
202
+ (cause) => new FilesystemOperationError({ operation: 'append file edit log', cause }),
203
+ )
204
+ if (prepared instanceof Error) return prepared
205
+ const size = errore.try(
206
+ () => fs.statSync(logPath).size,
207
+ (cause) => new FilesystemOperationError({ operation: 'stat file edit log', cause }),
208
+ )
209
+ if (size instanceof Error) return size
210
+ if (size < compactAfterBytes) return
211
+ return compactFileEditLog({ dataDir, maxEvents })
212
+ }
213
+
214
+ function recordToolEdits({
215
+ dataDir,
216
+ directory,
217
+ sessionId,
218
+ tool,
219
+ args,
220
+ }: {
221
+ dataDir: string
222
+ directory: string
223
+ sessionId: string
224
+ tool: string
225
+ args: ToolArgs
226
+ }) {
227
+ const name = tool.toLowerCase()
228
+ if (!isFileEditTool(name)) return
229
+ const files = extractEditedFiles({ tool: name, args, directory })
230
+ if (files.length === 0) return
231
+ const at = Date.now()
232
+ const events = files.map((file) => {
233
+ return {
234
+ v: 1 as const,
235
+ at,
236
+ sessionId,
237
+ file,
238
+ tool: name,
239
+ }
240
+ })
241
+ const result = appendFileEditEvents({ dataDir, events })
242
+ if (result instanceof Error) {
243
+ logger.warn('Failed to record file edit', result.message)
244
+ }
245
+ }
246
+
247
+ export function createFileEditHooks({
248
+ dataDir,
249
+ directory,
250
+ }: {
251
+ dataDir: string
252
+ directory: string
253
+ }) {
254
+ return {
255
+ 'tool.execute.after': async (input: {
256
+ tool: string
257
+ sessionID: string
258
+ args: ToolArgs
259
+ }) => {
260
+ recordToolEdits({
261
+ dataDir,
262
+ directory,
263
+ sessionId: input.sessionID,
264
+ tool: input.tool,
265
+ args: input.args,
266
+ })
267
+ },
268
+ }
269
+ }
270
+
271
+ export const fileEditTrackerPlugin: Plugin = async ({ directory }) => {
272
+ const dataDir = process.env.KIMAKI_DATA_DIR
273
+ if (!dataDir) return {}
274
+ setPluginLogFilePath(dataDir)
275
+ const created = errore.try(
276
+ () => fs.mkdirSync(dataDir, { recursive: true }),
277
+ (cause) => new FilesystemOperationError({ operation: 'create file edit log dir', cause }),
278
+ )
279
+ if (created instanceof Error) {
280
+ logger.warn('Failed to create file edit log dir', created.message)
281
+ }
282
+ return createFileEditHooks({ dataDir, directory })
283
+ }