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
@@ -0,0 +1,197 @@
1
+ // Tracks which OpenCode sessions last edited each file.
2
+ // Plugin appends JSONL events; CLI derives the per-file session list on read.
3
+ import fs from 'node:fs';
4
+ import path from 'node:path';
5
+ import * as errore from 'errore';
6
+ import { FilesystemOperationError } from './errors.js';
7
+ import { extractPatchFilePaths } from './patch-text-parser.js';
8
+ import { createPluginLogger, setPluginLogFilePath } from './plugin-logger.js';
9
+ const logger = createPluginLogger('FILEEDIT');
10
+ export const FILE_EDIT_EVENTS_FILENAME = 'file-edit-events.jsonl';
11
+ const DEFAULT_MAX_EVENTS = 10_000;
12
+ const DEFAULT_COMPACT_AFTER_BYTES = 5 * 1024 * 1024;
13
+ function isFileEditTool(tool) {
14
+ return tool === 'edit' || tool === 'write' || tool === 'apply_patch';
15
+ }
16
+ function getStringField({ args, key }) {
17
+ const value = args[key];
18
+ return typeof value === 'string' ? value : '';
19
+ }
20
+ export function extractEditedFiles({ tool, args, directory, }) {
21
+ const name = tool.toLowerCase();
22
+ if (name === 'edit' || name === 'write') {
23
+ const filePath = getStringField({ args, key: 'filePath' });
24
+ if (!filePath)
25
+ return [];
26
+ return [path.resolve(directory, filePath)];
27
+ }
28
+ if (name !== 'apply_patch')
29
+ return [];
30
+ const patchText = getStringField({ args, key: 'patchText' });
31
+ if (!patchText)
32
+ return [];
33
+ return extractPatchFilePaths(patchText).map((filePath) => {
34
+ return path.resolve(directory, filePath);
35
+ });
36
+ }
37
+ export function editorsForFile({ events, filePath, cwd, }) {
38
+ const resolved = path.resolve(cwd, filePath);
39
+ const latestBySession = new Map();
40
+ for (const event of events) {
41
+ if (path.resolve(event.file) !== resolved)
42
+ continue;
43
+ const previous = latestBySession.get(event.sessionId);
44
+ if (previous === undefined || event.at > previous) {
45
+ latestBySession.set(event.sessionId, event.at);
46
+ }
47
+ }
48
+ return [...latestBySession.entries()]
49
+ .map(([sessionId, at]) => {
50
+ return { sessionId, at };
51
+ })
52
+ .sort((left, right) => {
53
+ return right.at - left.at;
54
+ });
55
+ }
56
+ function parseFileEditEvent(line) {
57
+ const parsed = errore.try(() => JSON.parse(line), (cause) => new FilesystemOperationError({ operation: 'parse file edit event', cause }));
58
+ if (parsed instanceof Error)
59
+ return null;
60
+ if (!parsed || typeof parsed !== 'object')
61
+ return null;
62
+ if (parsed.v !== 1)
63
+ return null;
64
+ if (typeof parsed.at !== 'number' || !Number.isFinite(parsed.at))
65
+ return null;
66
+ if (typeof parsed.sessionId !== 'string' || parsed.sessionId.length === 0)
67
+ return null;
68
+ if (typeof parsed.file !== 'string' || parsed.file.length === 0)
69
+ return null;
70
+ if (typeof parsed.tool !== 'string' || !isFileEditTool(parsed.tool))
71
+ return null;
72
+ return {
73
+ v: 1,
74
+ at: parsed.at,
75
+ sessionId: parsed.sessionId,
76
+ file: parsed.file,
77
+ tool: parsed.tool,
78
+ };
79
+ }
80
+ function nodeErrorCode(error) {
81
+ const cause = error.cause;
82
+ if (!cause || typeof cause !== 'object')
83
+ return '';
84
+ const code = Reflect.get(cause, 'code');
85
+ return typeof code === 'string' ? code : '';
86
+ }
87
+ export function loadFileEditEvents({ dataDir }) {
88
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME);
89
+ const raw = errore.try(() => fs.readFileSync(logPath, 'utf8'), (cause) => new FilesystemOperationError({ operation: 'read file edit log', cause }));
90
+ if (raw instanceof Error) {
91
+ if (nodeErrorCode(raw) === 'ENOENT')
92
+ return [];
93
+ return raw;
94
+ }
95
+ const events = [];
96
+ for (const line of raw.split('\n')) {
97
+ if (!line.trim())
98
+ continue;
99
+ const event = parseFileEditEvent(line);
100
+ if (event)
101
+ events.push(event);
102
+ }
103
+ return events;
104
+ }
105
+ function collapseFileEditEvents(events) {
106
+ const latest = new Map();
107
+ for (const event of events) {
108
+ const key = `${event.sessionId}\0${event.file}`;
109
+ const previous = latest.get(key);
110
+ if (!previous || event.at >= previous.at)
111
+ latest.set(key, event);
112
+ }
113
+ return [...latest.values()].sort((left, right) => {
114
+ return left.at - right.at;
115
+ });
116
+ }
117
+ function compactFileEditLog({ dataDir, maxEvents, }) {
118
+ const loaded = loadFileEditEvents({ dataDir });
119
+ if (loaded instanceof Error)
120
+ return loaded;
121
+ const collapsed = collapseFileEditEvents(loaded);
122
+ const kept = collapsed.length > maxEvents ? collapsed.slice(-maxEvents) : collapsed;
123
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME);
124
+ const tempPath = `${logPath}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
125
+ const body = kept.map((event) => `${JSON.stringify(event)}\n`).join('');
126
+ const written = errore.try(() => {
127
+ fs.writeFileSync(tempPath, body);
128
+ fs.renameSync(tempPath, logPath);
129
+ }, (cause) => new FilesystemOperationError({ operation: 'compact file edit log', cause }));
130
+ if (written instanceof Error) {
131
+ fs.rmSync(tempPath, { force: true });
132
+ return written;
133
+ }
134
+ }
135
+ export function appendFileEditEvents({ dataDir, events, maxEvents = DEFAULT_MAX_EVENTS, compactAfterBytes = DEFAULT_COMPACT_AFTER_BYTES, }) {
136
+ if (events.length === 0)
137
+ return;
138
+ const logPath = path.join(dataDir, FILE_EDIT_EVENTS_FILENAME);
139
+ const prepared = errore.try(() => {
140
+ fs.mkdirSync(dataDir, { recursive: true });
141
+ fs.appendFileSync(logPath, events.map((event) => `${JSON.stringify(event)}\n`).join(''));
142
+ }, (cause) => new FilesystemOperationError({ operation: 'append file edit log', cause }));
143
+ if (prepared instanceof Error)
144
+ return prepared;
145
+ const size = errore.try(() => fs.statSync(logPath).size, (cause) => new FilesystemOperationError({ operation: 'stat file edit log', cause }));
146
+ if (size instanceof Error)
147
+ return size;
148
+ if (size < compactAfterBytes)
149
+ return;
150
+ return compactFileEditLog({ dataDir, maxEvents });
151
+ }
152
+ function recordToolEdits({ dataDir, directory, sessionId, tool, args, }) {
153
+ const name = tool.toLowerCase();
154
+ if (!isFileEditTool(name))
155
+ return;
156
+ const files = extractEditedFiles({ tool: name, args, directory });
157
+ if (files.length === 0)
158
+ return;
159
+ const at = Date.now();
160
+ const events = files.map((file) => {
161
+ return {
162
+ v: 1,
163
+ at,
164
+ sessionId,
165
+ file,
166
+ tool: name,
167
+ };
168
+ });
169
+ const result = appendFileEditEvents({ dataDir, events });
170
+ if (result instanceof Error) {
171
+ logger.warn('Failed to record file edit', result.message);
172
+ }
173
+ }
174
+ export function createFileEditHooks({ dataDir, directory, }) {
175
+ return {
176
+ 'tool.execute.after': async (input) => {
177
+ recordToolEdits({
178
+ dataDir,
179
+ directory,
180
+ sessionId: input.sessionID,
181
+ tool: input.tool,
182
+ args: input.args,
183
+ });
184
+ },
185
+ };
186
+ }
187
+ export const fileEditTrackerPlugin = async ({ directory }) => {
188
+ const dataDir = process.env.KIMAKI_DATA_DIR;
189
+ if (!dataDir)
190
+ return {};
191
+ setPluginLogFilePath(dataDir);
192
+ const created = errore.try(() => fs.mkdirSync(dataDir, { recursive: true }), (cause) => new FilesystemOperationError({ operation: 'create file edit log dir', cause }));
193
+ if (created instanceof Error) {
194
+ logger.warn('Failed to create file edit log dir', created.message);
195
+ }
196
+ return createFileEditHooks({ dataDir, directory });
197
+ };
@@ -0,0 +1,258 @@
1
+ // Tests for file-edit event extraction, derivation, and JSONL persistence.
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { afterEach, describe, expect, test } from 'vitest';
6
+ import { appendFileEditEvents, createFileEditHooks, editorsForFile, extractEditedFiles, loadFileEditEvents, } from './file-edit-log.js';
7
+ const tempDirs = [];
8
+ function makeDataDir() {
9
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'kimaki-file-edit-log-'));
10
+ tempDirs.push(dir);
11
+ return dir;
12
+ }
13
+ afterEach(() => {
14
+ for (const dir of tempDirs.splice(0)) {
15
+ fs.rmSync(dir, { recursive: true, force: true });
16
+ }
17
+ });
18
+ function event(partial) {
19
+ return {
20
+ v: 1,
21
+ at: 1,
22
+ tool: 'edit',
23
+ ...partial,
24
+ };
25
+ }
26
+ describe('extractEditedFiles', () => {
27
+ test('extracts edit and write filePath and ignores other tools', () => {
28
+ expect(extractEditedFiles({
29
+ tool: 'edit',
30
+ args: { filePath: 'src/foo.ts' },
31
+ directory: '/repo',
32
+ })).toMatchInlineSnapshot(`
33
+ [
34
+ "/repo/src/foo.ts",
35
+ ]
36
+ `);
37
+ expect(extractEditedFiles({
38
+ tool: 'write',
39
+ args: { filePath: '/abs/bar.ts' },
40
+ directory: '/repo',
41
+ })).toMatchInlineSnapshot(`
42
+ [
43
+ "/abs/bar.ts",
44
+ ]
45
+ `);
46
+ expect(extractEditedFiles({
47
+ tool: 'read',
48
+ args: { filePath: 'src/foo.ts' },
49
+ directory: '/repo',
50
+ })).toMatchInlineSnapshot(`
51
+ []
52
+ `);
53
+ expect(extractEditedFiles({
54
+ tool: 'bash',
55
+ args: {},
56
+ directory: '/repo',
57
+ })).toMatchInlineSnapshot(`
58
+ []
59
+ `);
60
+ });
61
+ test('extracts apply_patch paths and resolves them against directory', () => {
62
+ const patchText = [
63
+ '*** Begin Patch',
64
+ '*** Update File: src/foo.ts',
65
+ '@@',
66
+ '-a',
67
+ '+b',
68
+ '*** Add File: src/bar.ts',
69
+ '+hello',
70
+ '*** Delete File: src/old.ts',
71
+ '*** Move to: src/renamed.ts',
72
+ '*** End Patch',
73
+ ].join('\n');
74
+ expect(extractEditedFiles({
75
+ tool: 'apply_patch',
76
+ args: { patchText },
77
+ directory: '/repo',
78
+ })).toMatchInlineSnapshot(`
79
+ [
80
+ "/repo/src/foo.ts",
81
+ "/repo/src/bar.ts",
82
+ "/repo/src/old.ts",
83
+ "/repo/src/renamed.ts",
84
+ ]
85
+ `);
86
+ });
87
+ });
88
+ describe('editorsForFile', () => {
89
+ test('orders unique sessions by last edit and matches relative paths', () => {
90
+ const events = [
91
+ event({ sessionId: 'ses_old', file: '/repo/src/foo.ts', at: 10 }),
92
+ event({ sessionId: 'ses_other', file: '/repo/src/other.ts', at: 50 }),
93
+ event({ sessionId: 'ses_new', file: '/repo/src/foo.ts', at: 20 }),
94
+ event({ sessionId: 'ses_old', file: '/repo/src/foo.ts', at: 40, tool: 'write' }),
95
+ ];
96
+ expect(editorsForFile({
97
+ events,
98
+ filePath: 'src/foo.ts',
99
+ cwd: '/repo',
100
+ })).toMatchInlineSnapshot(`
101
+ [
102
+ {
103
+ "at": 40,
104
+ "sessionId": "ses_old",
105
+ },
106
+ {
107
+ "at": 20,
108
+ "sessionId": "ses_new",
109
+ },
110
+ ]
111
+ `);
112
+ });
113
+ });
114
+ describe('file edit jsonl log', () => {
115
+ test('appends events and loads them back', () => {
116
+ const dataDir = makeDataDir();
117
+ const first = [
118
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 1 }),
119
+ ];
120
+ const second = [
121
+ event({ sessionId: 'ses_b', file: '/repo/b.ts', at: 2, tool: 'write' }),
122
+ ];
123
+ const appended = appendFileEditEvents({ dataDir, events: first });
124
+ if (appended instanceof Error)
125
+ throw appended;
126
+ const appendedAgain = appendFileEditEvents({ dataDir, events: second });
127
+ if (appendedAgain instanceof Error)
128
+ throw appendedAgain;
129
+ const loaded = loadFileEditEvents({ dataDir });
130
+ if (loaded instanceof Error)
131
+ throw loaded;
132
+ expect(loaded).toMatchInlineSnapshot(`
133
+ [
134
+ {
135
+ "at": 1,
136
+ "file": "/repo/a.ts",
137
+ "sessionId": "ses_a",
138
+ "tool": "edit",
139
+ "v": 1,
140
+ },
141
+ {
142
+ "at": 2,
143
+ "file": "/repo/b.ts",
144
+ "sessionId": "ses_b",
145
+ "tool": "write",
146
+ "v": 1,
147
+ },
148
+ ]
149
+ `);
150
+ });
151
+ test('compacts to the newest events when the log is too large', () => {
152
+ const dataDir = makeDataDir();
153
+ const events = [
154
+ event({ sessionId: 'ses_1', file: '/repo/a.ts', at: 1 }),
155
+ event({ sessionId: 'ses_2', file: '/repo/b.ts', at: 2 }),
156
+ event({ sessionId: 'ses_3', file: '/repo/c.ts', at: 3 }),
157
+ ];
158
+ const appended = appendFileEditEvents({
159
+ dataDir,
160
+ events,
161
+ maxEvents: 2,
162
+ compactAfterBytes: 1,
163
+ });
164
+ if (appended instanceof Error)
165
+ throw appended;
166
+ const loaded = loadFileEditEvents({ dataDir });
167
+ if (loaded instanceof Error)
168
+ throw loaded;
169
+ expect(loaded).toMatchInlineSnapshot(`
170
+ [
171
+ {
172
+ "at": 2,
173
+ "file": "/repo/b.ts",
174
+ "sessionId": "ses_2",
175
+ "tool": "edit",
176
+ "v": 1,
177
+ },
178
+ {
179
+ "at": 3,
180
+ "file": "/repo/c.ts",
181
+ "sessionId": "ses_3",
182
+ "tool": "edit",
183
+ "v": 1,
184
+ },
185
+ ]
186
+ `);
187
+ });
188
+ test('compacts repeated edits of the same session and file to one row', () => {
189
+ const dataDir = makeDataDir();
190
+ const events = [
191
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 1 }),
192
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 2, tool: 'write' }),
193
+ event({ sessionId: 'ses_a', file: '/repo/a.ts', at: 3 }),
194
+ ];
195
+ const appended = appendFileEditEvents({
196
+ dataDir,
197
+ events,
198
+ maxEvents: 10,
199
+ compactAfterBytes: 1,
200
+ });
201
+ if (appended instanceof Error)
202
+ throw appended;
203
+ const loaded = loadFileEditEvents({ dataDir });
204
+ if (loaded instanceof Error)
205
+ throw loaded;
206
+ expect(loaded).toMatchInlineSnapshot(`
207
+ [
208
+ {
209
+ "at": 3,
210
+ "file": "/repo/a.ts",
211
+ "sessionId": "ses_a",
212
+ "tool": "edit",
213
+ "v": 1,
214
+ },
215
+ ]
216
+ `);
217
+ });
218
+ });
219
+ describe('fileEditTrackerPlugin', () => {
220
+ test('records edit and apply_patch and ignores bash', async () => {
221
+ const dataDir = makeDataDir();
222
+ const hooks = createFileEditHooks({ dataDir, directory: '/repo' });
223
+ await hooks['tool.execute.after']({
224
+ tool: 'edit',
225
+ sessionID: 'ses_x',
226
+ args: { filePath: 'src/a.ts' },
227
+ });
228
+ await hooks['tool.execute.after']({
229
+ tool: 'apply_patch',
230
+ sessionID: 'ses_x',
231
+ args: { patchText: '*** Update File: src/b.ts\n' },
232
+ });
233
+ await hooks['tool.execute.after']({
234
+ tool: 'bash',
235
+ sessionID: 'ses_x',
236
+ args: {},
237
+ });
238
+ const loaded = loadFileEditEvents({ dataDir });
239
+ if (loaded instanceof Error)
240
+ throw loaded;
241
+ expect(loaded.map((entry) => {
242
+ return { sessionId: entry.sessionId, file: entry.file, tool: entry.tool };
243
+ })).toMatchInlineSnapshot(`
244
+ [
245
+ {
246
+ "file": "/repo/src/a.ts",
247
+ "sessionId": "ses_x",
248
+ "tool": "edit",
249
+ },
250
+ {
251
+ "file": "/repo/src/b.ts",
252
+ "sessionId": "ses_x",
253
+ "tool": "apply_patch",
254
+ },
255
+ ]
256
+ `);
257
+ });
258
+ });
@@ -173,8 +173,30 @@ function splitMarkdownByCallouts({ markdown, }) {
173
173
  }) ?? [markdown];
174
174
  const blocks = [];
175
175
  let textBuffer = '';
176
+ let fence = null;
176
177
  for (let index = 0; index < lines.length; index++) {
177
178
  const line = lines[index];
179
+ if (fence) {
180
+ textBuffer += line;
181
+ if (isMarkdownFenceClose({ line, fence }))
182
+ fence = null;
183
+ continue;
184
+ }
185
+ const openingFence = parseMarkdownFenceOpen({ line });
186
+ if (openingFence) {
187
+ fence = openingFence;
188
+ textBuffer += line;
189
+ continue;
190
+ }
191
+ const singleLineCallout = parseSingleLineCallout({ line });
192
+ if (singleLineCallout) {
193
+ if (textBuffer.length > 0) {
194
+ blocks.push({ type: 'text', text: textBuffer });
195
+ textBuffer = '';
196
+ }
197
+ blocks.push(singleLineCallout);
198
+ continue;
199
+ }
178
200
  const callout = parseCalloutOpenLine({ line });
179
201
  if (!callout) {
180
202
  textBuffer += line;
@@ -338,6 +360,36 @@ function collectCalloutBodyFromLines({ lines, startIndex, }) {
338
360
  }
339
361
  return new Error('Unclosed <callout> block');
340
362
  }
363
+ function parseMarkdownFenceOpen({ line, }) {
364
+ const marker = line.match(/^ {0,3}(`{3,}|~{3,})/)?.[1];
365
+ if (!marker)
366
+ return null;
367
+ return {
368
+ marker: marker.startsWith('`') ? '`' : '~',
369
+ length: marker.length,
370
+ };
371
+ }
372
+ function isMarkdownFenceClose({ line, fence, }) {
373
+ const marker = line.trim();
374
+ return marker.length >= fence.length &&
375
+ [...marker].every((character) => character === fence.marker);
376
+ }
377
+ function parseSingleLineCallout({ line, }) {
378
+ const match = line.trim().match(/^(<callout(?:\s+[^>]*)?>)(.*?)<\/callout>$/i);
379
+ if (!match)
380
+ return null;
381
+ const content = match[2];
382
+ if (/<\/?callout(?:\s|>)/i.test(content))
383
+ return null;
384
+ const callout = parseCalloutOpenLine({ line: match[1] });
385
+ if (!callout)
386
+ return null;
387
+ return {
388
+ type: 'callout',
389
+ content,
390
+ callout,
391
+ };
392
+ }
341
393
  function parseCalloutOpenLine({ line, }) {
342
394
  const match = line.trim().match(/^<callout(?:\s+[^>]*)?>$/i);
343
395
  if (!match) {
@@ -409,6 +409,99 @@ Done.`);
409
409
  ]
410
410
  `);
411
411
  });
412
+ test('renders a single-line callout inside an accented container', () => {
413
+ const result = splitTablesFromMarkdown(`<callout accent="#f59e0b">Full suite: **622 passed, 6 unrelated existing failures, 8 skipped**.</callout>`);
414
+ expect(result.map((segment) => segment.type)).toEqual(['components']);
415
+ expect(result).toMatchInlineSnapshot(`
416
+ [
417
+ {
418
+ "components": [
419
+ {
420
+ "accent_color": 16096779,
421
+ "components": [
422
+ {
423
+ "content": "Full suite: **622 passed, 6 unrelated existing failures, 8 skipped**.",
424
+ "type": 10,
425
+ },
426
+ ],
427
+ "type": 17,
428
+ },
429
+ ],
430
+ "type": "components",
431
+ },
432
+ ]
433
+ `);
434
+ });
435
+ test('splits text around a single-line callout into separate segments', () => {
436
+ const result = splitTablesFromMarkdown(`Done.
437
+
438
+ <callout accent="#f59e0b">Confidence: high.</callout>
439
+
440
+ Diff: https://critique.work/v/example`);
441
+ expect(result.map((segment) => segment.type)).toEqual([
442
+ 'text',
443
+ 'components',
444
+ 'text',
445
+ ]);
446
+ expect(result).toMatchInlineSnapshot(`
447
+ [
448
+ {
449
+ "text": "Done.
450
+
451
+ ",
452
+ "type": "text",
453
+ },
454
+ {
455
+ "components": [
456
+ {
457
+ "accent_color": 16096779,
458
+ "components": [
459
+ {
460
+ "content": "Confidence: high.",
461
+ "type": 10,
462
+ },
463
+ ],
464
+ "type": 17,
465
+ },
466
+ ],
467
+ "type": "components",
468
+ },
469
+ {
470
+ "text": "
471
+ Diff: https://critique.work/v/example",
472
+ "type": "text",
473
+ },
474
+ ]
475
+ `);
476
+ });
477
+ test('preserves single-line callout syntax inside fenced code', () => {
478
+ const result = splitTablesFromMarkdown(`\`\`\`html
479
+ <callout accent="#f59e0b">inside</callout>
480
+ \`\`\``);
481
+ expect(result.map((segment) => segment.type)).toEqual(['text']);
482
+ expect(result).toMatchInlineSnapshot(`
483
+ [
484
+ {
485
+ "text": "\`\`\`html
486
+ <callout accent="#f59e0b">inside</callout>
487
+ \`\`\`",
488
+ "type": "text",
489
+ },
490
+ ]
491
+ `);
492
+ });
493
+ test('keeps malformed single-line callouts as plain text', () => {
494
+ const result = splitTablesFromMarkdown(`<callout accent="#f59e0b">content</callout></callout>`);
495
+ expect(result.map((segment) => segment.type)).toEqual(['text']);
496
+ expect(result).toMatchInlineSnapshot(`
497
+ [
498
+ {
499
+ "text": "<callout accent="#f59e0b">content</callout></callout>",
500
+ "type": "text",
501
+ },
502
+ ]
503
+ `);
504
+ });
412
505
  test('renders callout text inside an accented container', () => {
413
506
  const result = splitTablesFromMarkdown(`<callout accent="#2b7fff">
414
507
  ## Important
@@ -238,6 +238,24 @@ function waitForClientReady({ client, token, timeoutMs = 30_000, }) {
238
238
  client.login(token).catch(reject);
239
239
  });
240
240
  }
241
+ function waitForGatewayUnavailable({ client, timeoutMs = 10_000, }) {
242
+ return new Promise((resolve, reject) => {
243
+ let reconnectAttempts = 0;
244
+ const timeout = setTimeout(() => {
245
+ client.off(Events.ShardReconnecting, onReconnecting);
246
+ reject(new Error(`Client did not observe gateway unavailability within ${timeoutMs}ms`));
247
+ }, timeoutMs);
248
+ const onReconnecting = () => {
249
+ reconnectAttempts += 1;
250
+ if (reconnectAttempts < 2)
251
+ return;
252
+ clearTimeout(timeout);
253
+ client.off(Events.ShardReconnecting, onReconnecting);
254
+ resolve();
255
+ };
256
+ client.on(Events.ShardReconnecting, onReconnecting);
257
+ });
258
+ }
241
259
  function waitForReconnection({ client, events, label, timeoutMs = 30_000, }) {
242
260
  return new Promise((resolve) => {
243
261
  const timeout = setTimeout(() => {
@@ -245,14 +263,14 @@ function waitForReconnection({ client, events, label, timeoutMs = 30_000, }) {
245
263
  console.log(`[${label}] Events so far:`, events);
246
264
  resolve(false);
247
265
  }, timeoutMs);
248
- client.on(Events.ShardReady, () => {
266
+ const finish = () => {
249
267
  clearTimeout(timeout);
268
+ client.off(Events.ShardReady, finish);
269
+ client.off(Events.ShardResume, finish);
250
270
  resolve(true);
251
- });
252
- client.on(Events.ShardResume, () => {
253
- clearTimeout(timeout);
254
- resolve(true);
255
- });
271
+ };
272
+ client.on(Events.ShardReady, finish);
273
+ client.on(Events.ShardResume, finish);
256
274
  });
257
275
  }
258
276
  // ============================================================================
@@ -315,13 +333,15 @@ describeLocal('gateway-proxy reconnection (local binary)', () => {
315
333
  console.log('[local] Client ready');
316
334
  // Kill proxy
317
335
  const firstProxy = proxyProcess;
336
+ const gatewayUnavailable = waitForGatewayUnavailable({ client });
318
337
  await killProxy(proxyProcess);
319
- await new Promise((r) => { setTimeout(r, 1000); });
338
+ await gatewayUnavailable;
320
339
  // Restart proxy on same port
340
+ const reconnection = waitForReconnection({ client, events, label: 'local' });
321
341
  proxyProcess = startProxy(proxyOpts);
322
342
  await waitForProxyReady({ port: proxyPort });
323
343
  console.log('[local] Proxy restarted');
324
- const reconnected = await waitForReconnection({ client, events, label: 'local' });
344
+ const reconnected = await reconnection;
325
345
  console.log('[local] All events:', events);
326
346
  if (!reconnected) {
327
347
  dumpProxyLogs(firstProxy);