tmux-ide 1.2.1 → 2.0.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 (205) hide show
  1. package/bin/cli.js +4 -216
  2. package/bin/cli.ts +536 -0
  3. package/package.json +33 -14
  4. package/src/__tests__/support.ts +186 -0
  5. package/src/{attach.js → attach.ts} +7 -4
  6. package/src/cli.test.ts +314 -0
  7. package/src/command-center/discovery.test.ts +265 -0
  8. package/src/command-center/discovery.ts +296 -0
  9. package/src/command-center/index.ts +31 -0
  10. package/src/command-center/schemas.ts +57 -0
  11. package/src/command-center/server.test.ts +818 -0
  12. package/src/command-center/server.ts +1426 -0
  13. package/src/config-cli.test.ts +86 -0
  14. package/src/config.test.ts +205 -0
  15. package/src/config.ts +313 -0
  16. package/src/detect.test.ts +142 -0
  17. package/src/{detect.js → detect.ts} +31 -15
  18. package/src/dispatch.ts +102 -0
  19. package/src/doctor.test.ts +119 -0
  20. package/src/doctor.ts +167 -0
  21. package/src/init.test.ts +101 -0
  22. package/src/init.ts +144 -0
  23. package/src/inspect.test.ts +67 -0
  24. package/src/{inspect.js → inspect.ts} +95 -10
  25. package/src/integration.test.ts.skip +276 -0
  26. package/src/js-yaml.d.ts +10 -0
  27. package/src/launch.test.ts +222 -0
  28. package/src/launch.ts +494 -0
  29. package/src/lib/auth/auth-service.test.ts +178 -0
  30. package/src/lib/auth/auth-service.ts +227 -0
  31. package/src/lib/auth/middleware.test.ts +119 -0
  32. package/src/lib/auth/middleware.ts +56 -0
  33. package/src/lib/auth/types.ts +13 -0
  34. package/src/lib/authorship.test.ts +242 -0
  35. package/src/lib/authorship.ts +280 -0
  36. package/src/lib/cast/recorder.test.ts +127 -0
  37. package/src/lib/cast/recorder.ts +148 -0
  38. package/src/lib/daemon-watchdog.ts +102 -0
  39. package/src/lib/daemon.ts +417 -0
  40. package/src/lib/dot-path.test.ts +66 -0
  41. package/src/lib/dot-path.ts +17 -0
  42. package/src/lib/errors.ts +44 -0
  43. package/src/lib/event-log.test.ts +189 -0
  44. package/src/lib/event-log.ts +237 -0
  45. package/src/lib/github-pr.ts +182 -0
  46. package/src/lib/hq/client.test.ts +154 -0
  47. package/src/lib/hq/client.ts +142 -0
  48. package/src/lib/hq/mdns.test.ts +54 -0
  49. package/src/lib/hq/mdns.ts +88 -0
  50. package/src/lib/hq/registry.test.ts +237 -0
  51. package/src/lib/hq/registry.ts +145 -0
  52. package/src/lib/hq/types.ts +44 -0
  53. package/src/lib/ipc/socket-protocol.test.ts +126 -0
  54. package/src/lib/ipc/socket-protocol.ts +324 -0
  55. package/src/lib/launch-plan.test.ts +119 -0
  56. package/src/lib/launch-plan.ts +90 -0
  57. package/src/lib/log.test.ts +39 -0
  58. package/src/lib/log.ts +71 -0
  59. package/src/lib/metrics.test.ts +213 -0
  60. package/src/lib/metrics.ts +345 -0
  61. package/src/lib/orchestrator.test.ts +2358 -0
  62. package/src/lib/orchestrator.ts +1792 -0
  63. package/src/lib/{output.js → output.ts} +18 -10
  64. package/src/lib/plan-store.test.ts +148 -0
  65. package/src/lib/plan-store.ts +131 -0
  66. package/src/lib/research.test.ts +208 -0
  67. package/src/lib/research.ts +475 -0
  68. package/src/lib/round-trip.test.ts +341 -0
  69. package/src/lib/session-monitor.test.ts +152 -0
  70. package/src/lib/session-monitor.ts +282 -0
  71. package/src/lib/session-options.test.ts +146 -0
  72. package/src/lib/{session-options.js → session-options.ts} +11 -6
  73. package/src/lib/shell.test.ts +20 -0
  74. package/src/lib/shell.ts +8 -0
  75. package/src/lib/sizes.test.ts +73 -0
  76. package/src/lib/{sizes.js → sizes.ts} +4 -4
  77. package/src/lib/skill-registry.test.ts +134 -0
  78. package/src/lib/skill-registry.ts +86 -0
  79. package/src/lib/slugify.test.ts +35 -0
  80. package/src/lib/slugify.ts +10 -0
  81. package/src/lib/task-store.ts +352 -0
  82. package/src/lib/tmux.test.ts +500 -0
  83. package/src/lib/tmux.ts +372 -0
  84. package/src/lib/token-tracker.test.ts +102 -0
  85. package/src/lib/token-tracker.ts +70 -0
  86. package/src/lib/tunnels/cloudflare.test.ts +73 -0
  87. package/src/lib/tunnels/cloudflare.ts +254 -0
  88. package/src/lib/tunnels/manager.test.ts +63 -0
  89. package/src/lib/tunnels/manager.ts +132 -0
  90. package/src/lib/tunnels/ngrok.test.ts +94 -0
  91. package/src/lib/tunnels/ngrok.ts +261 -0
  92. package/src/lib/tunnels/tailscale.test.ts +103 -0
  93. package/src/lib/tunnels/tailscale.ts +1048 -0
  94. package/src/lib/tunnels/types.ts +44 -0
  95. package/src/lib/validation.test.ts +289 -0
  96. package/src/lib/validation.ts +123 -0
  97. package/src/lib/webhook.ts +51 -0
  98. package/src/lib/workflow-store.ts +209 -0
  99. package/src/lib/ws-v3/protocol.test.ts +127 -0
  100. package/src/lib/ws-v3/protocol.ts +160 -0
  101. package/src/lib/yaml-io.test.ts +125 -0
  102. package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
  103. package/src/ls.test.ts +76 -0
  104. package/src/{ls.js → ls.ts} +4 -4
  105. package/src/metrics-cli.ts +177 -0
  106. package/src/notify.ts +85 -0
  107. package/src/orchestrator-status.ts +228 -0
  108. package/src/plan.ts +117 -0
  109. package/src/postinstall.test.ts +79 -0
  110. package/src/remote.test.ts +115 -0
  111. package/src/remote.ts +148 -0
  112. package/src/restart.ts +24 -0
  113. package/src/schemas/domain.ts +422 -0
  114. package/src/schemas/ide-config.ts +148 -0
  115. package/src/schemas/index.ts +53 -0
  116. package/src/send.test.ts +70 -0
  117. package/src/send.ts +181 -0
  118. package/src/skill.test.ts +114 -0
  119. package/src/skill.ts +138 -0
  120. package/src/status.ts +66 -0
  121. package/src/stop.test.ts +176 -0
  122. package/src/stop.ts +47 -0
  123. package/src/task.test.ts +1466 -0
  124. package/src/task.ts +1143 -0
  125. package/src/tunnel.test.ts +80 -0
  126. package/src/tunnel.ts +133 -0
  127. package/src/types.ts +15 -0
  128. package/src/ui/index.ts +32 -0
  129. package/src/ui/terminal/index.ts +9 -0
  130. package/src/ui/types.ts +91 -0
  131. package/src/ui/web/base.css +80 -0
  132. package/src/ui/web/components/Box.tsx +59 -0
  133. package/src/ui/web/components/Input.tsx +32 -0
  134. package/src/ui/web/components/ScrollBox.tsx +60 -0
  135. package/src/ui/web/components/Text.tsx +28 -0
  136. package/src/ui/web/hooks.ts +106 -0
  137. package/src/ui/web/index.ts +27 -0
  138. package/src/ui/web/render.ts +77 -0
  139. package/src/ui/web/utils/color.test.ts +93 -0
  140. package/src/ui/web/utils/color.ts +27 -0
  141. package/src/validate.test.ts +385 -0
  142. package/src/validate.ts +217 -0
  143. package/src/widgets/changes/index.tsx +576 -0
  144. package/src/widgets/config/index.tsx +440 -0
  145. package/src/widgets/costs/index.tsx +216 -0
  146. package/src/widgets/explorer/breadcrumbs.tsx +77 -0
  147. package/src/widgets/explorer/footer.tsx +20 -0
  148. package/src/widgets/explorer/header.tsx +23 -0
  149. package/src/widgets/explorer/index.tsx +408 -0
  150. package/src/widgets/explorer/tree-model.test.ts +214 -0
  151. package/src/widgets/explorer/tree-model.ts +103 -0
  152. package/src/widgets/explorer/tree.tsx +156 -0
  153. package/src/widgets/lib/config-model.ts +116 -0
  154. package/src/widgets/lib/files.test.ts +103 -0
  155. package/src/widgets/lib/files.ts +88 -0
  156. package/src/widgets/lib/git.test.ts +151 -0
  157. package/src/widgets/lib/git.ts +88 -0
  158. package/src/widgets/lib/pane-comms.test.ts +178 -0
  159. package/src/widgets/lib/pane-comms.ts +192 -0
  160. package/src/widgets/lib/theme.ts +151 -0
  161. package/src/widgets/lib/watcher.ts +50 -0
  162. package/src/widgets/mission-control/activity-feed.tsx +59 -0
  163. package/src/widgets/mission-control/agent-panel.tsx +132 -0
  164. package/src/widgets/mission-control/command-bar.tsx +63 -0
  165. package/src/widgets/mission-control/index.tsx +751 -0
  166. package/src/widgets/mission-control/task-panel.tsx +238 -0
  167. package/src/widgets/preview/index.tsx +415 -0
  168. package/src/widgets/resolve.ts +40 -0
  169. package/src/widgets/setup/agent-naming.tsx +112 -0
  170. package/src/widgets/setup/config-tree.tsx +238 -0
  171. package/src/widgets/setup/detect-panel.tsx +72 -0
  172. package/src/widgets/setup/field-editor.tsx +265 -0
  173. package/src/widgets/setup/footer.tsx +107 -0
  174. package/src/widgets/setup/index.tsx +340 -0
  175. package/src/widgets/setup/layout-picker.tsx +96 -0
  176. package/src/widgets/setup/orchestrator-panel.tsx +200 -0
  177. package/src/widgets/setup/review-panel.tsx +140 -0
  178. package/src/widgets/setup/setup-model.test.ts +303 -0
  179. package/src/widgets/setup/setup-model.ts +188 -0
  180. package/src/widgets/tasks/index.tsx +129 -0
  181. package/src/widgets/tasks/task-detail.tsx +309 -0
  182. package/src/widgets/tasks/task-form.tsx +193 -0
  183. package/src/widgets/tasks/task-list.tsx +205 -0
  184. package/src/widgets/tasks/task-model.test.ts +473 -0
  185. package/src/widgets/tasks/task-model.ts +157 -0
  186. package/templates/AGENTS.md +20 -0
  187. package/templates/missions.yml +41 -0
  188. package/templates/skills/backend.md +30 -0
  189. package/templates/skills/frontend.md +30 -0
  190. package/templates/skills/general-worker.md +30 -0
  191. package/templates/skills/researcher.md +69 -0
  192. package/templates/skills/reviewer.md +30 -0
  193. package/src/config.js +0 -341
  194. package/src/doctor.js +0 -91
  195. package/src/init.js +0 -73
  196. package/src/launch.js +0 -245
  197. package/src/lib/dot-path.js +0 -16
  198. package/src/lib/errors.js +0 -35
  199. package/src/lib/launch-plan.js +0 -49
  200. package/src/lib/session-monitor.js +0 -179
  201. package/src/lib/tmux.js +0 -237
  202. package/src/restart.js +0 -16
  203. package/src/status.js +0 -35
  204. package/src/stop.js +0 -25
  205. package/src/validate.js +0 -154
@@ -0,0 +1,280 @@
1
+ /**
2
+ * Character-range authorship tracking for plan files.
3
+ *
4
+ * Follows the @proof/core marks model: each mark tracks authorship at the
5
+ * character range level with quote-based re-anchoring when positions shift.
6
+ *
7
+ * @module authorship
8
+ */
9
+
10
+ // ============================================================================
11
+ // Types (following @proof/core marks model)
12
+ // ============================================================================
13
+
14
+ export type MarkKind =
15
+ | "authored"
16
+ | "approved"
17
+ | "flagged"
18
+ | "comment"
19
+ | "insert"
20
+ | "delete"
21
+ | "replace";
22
+
23
+ export interface MarkRange {
24
+ from: number;
25
+ to: number;
26
+ }
27
+
28
+ export interface Mark {
29
+ id: string;
30
+ kind: MarkKind;
31
+ by: string; // "ai:François" or "human:thijs"
32
+ at: string; // ISO timestamp
33
+ range: MarkRange; // character positions in the clean content
34
+ quote: string; // text content for re-anchoring
35
+ orphaned?: boolean;
36
+ }
37
+
38
+ export interface MarksDocument {
39
+ version: number;
40
+ marks: Record<string, Mark>;
41
+ }
42
+
43
+ export interface AuthorshipStats {
44
+ aiPercent: number;
45
+ humanPercent: number;
46
+ totalChars: number;
47
+ }
48
+
49
+ // ============================================================================
50
+ // Comment block format
51
+ // ============================================================================
52
+
53
+ const MARKS_START = "<!-- TMUX-IDE:MARKS";
54
+ const MARKS_END = "-->";
55
+
56
+ // ============================================================================
57
+ // ID generation
58
+ // ============================================================================
59
+
60
+ let markIdCounter = 0;
61
+
62
+ export function generateMarkId(): string {
63
+ return `m${Date.now()}_${++markIdCounter}`;
64
+ }
65
+
66
+ // ============================================================================
67
+ // Quote normalization (matches @proof/core)
68
+ // ============================================================================
69
+
70
+ export function normalizeQuote(text: string): string {
71
+ return text.replace(/\s+/g, " ").trim();
72
+ }
73
+
74
+ // ============================================================================
75
+ // Extract / Embed
76
+ // ============================================================================
77
+
78
+ /**
79
+ * Extract marks from a markdown file.
80
+ * Returns clean content (without the comment block) and parsed marks.
81
+ */
82
+ export function extractMarks(markdown: string): {
83
+ content: string;
84
+ marks: MarksDocument | null;
85
+ } {
86
+ const startIdx = markdown.lastIndexOf(MARKS_START);
87
+ if (startIdx === -1) {
88
+ return { content: markdown, marks: null };
89
+ }
90
+
91
+ const endIdx = markdown.indexOf(MARKS_END, startIdx + MARKS_START.length);
92
+ if (endIdx === -1) {
93
+ return { content: markdown, marks: null };
94
+ }
95
+
96
+ const jsonStr = markdown.slice(startIdx + MARKS_START.length, endIdx).trim();
97
+ const content = (
98
+ markdown.slice(0, startIdx) + markdown.slice(endIdx + MARKS_END.length)
99
+ ).trimEnd();
100
+
101
+ try {
102
+ const data = JSON.parse(jsonStr) as MarksDocument;
103
+ return { content, marks: data };
104
+ } catch {
105
+ return { content, marks: null };
106
+ }
107
+ }
108
+
109
+ /**
110
+ * Embed marks into a markdown file.
111
+ * Strips any existing marks comment and appends a new one at the end.
112
+ */
113
+ export function embedMarks(markdown: string, doc: MarksDocument): string {
114
+ const { content } = extractMarks(markdown);
115
+ const json = JSON.stringify(doc, null, 2);
116
+ return `${content}\n\n${MARKS_START}\n${json}\n${MARKS_END}\n`;
117
+ }
118
+
119
+ // ============================================================================
120
+ // Mark creation helpers
121
+ // ============================================================================
122
+
123
+ export function createAuthored(by: string, range: MarkRange, quote: string): Mark {
124
+ return {
125
+ id: generateMarkId(),
126
+ kind: "authored",
127
+ by,
128
+ at: new Date().toISOString(),
129
+ range,
130
+ quote: normalizeQuote(quote),
131
+ };
132
+ }
133
+
134
+ // ============================================================================
135
+ // Stats calculation
136
+ // ============================================================================
137
+
138
+ /**
139
+ * Calculate AI vs human authorship percentages from marks.
140
+ * Only counts 'authored' marks. Uses character range lengths.
141
+ */
142
+ export function calculateStats(marks: Record<string, Mark>): AuthorshipStats {
143
+ let aiChars = 0;
144
+ let humanChars = 0;
145
+
146
+ for (const mark of Object.values(marks)) {
147
+ if (mark.kind !== "authored") continue;
148
+ if (mark.orphaned) continue;
149
+ const chars = mark.range.to - mark.range.from;
150
+ if (mark.by.startsWith("ai:") || mark.by === "ai") {
151
+ aiChars += chars;
152
+ } else {
153
+ humanChars += chars;
154
+ }
155
+ }
156
+
157
+ const totalChars = aiChars + humanChars;
158
+ if (totalChars === 0) {
159
+ return { aiPercent: 0, humanPercent: 0, totalChars: 0 };
160
+ }
161
+
162
+ return {
163
+ aiPercent: Math.round((aiChars / totalChars) * 100),
164
+ humanPercent: Math.round((humanChars / totalChars) * 100),
165
+ totalChars,
166
+ };
167
+ }
168
+
169
+ // ============================================================================
170
+ // Re-anchoring: recover positions when content shifts
171
+ // ============================================================================
172
+
173
+ /**
174
+ * Find the position of a quote in content text.
175
+ * Returns { from, to } or null if not found.
176
+ */
177
+ function findQuotePosition(content: string, quote: string): MarkRange | null {
178
+ if (!quote) return null;
179
+ const normalized = normalizeQuote(quote);
180
+ const normalizedContent = normalizeQuote(content);
181
+ const idx = normalizedContent.indexOf(normalized);
182
+ if (idx === -1) return null;
183
+
184
+ // Map back to original content positions (approximate — whitespace may differ)
185
+ // Walk through original content counting normalized chars
186
+ let origFrom = -1;
187
+ let normalizedPos = 0;
188
+ for (let i = 0; i < content.length; i++) {
189
+ if (/\s/.test(content[i]!) && (i === 0 || /\s/.test(content[i - 1]!))) continue;
190
+ if (normalizedPos === idx && origFrom === -1) origFrom = i;
191
+ if (normalizedPos === idx + normalized.length) {
192
+ return { from: origFrom, to: i };
193
+ }
194
+ normalizedPos++;
195
+ }
196
+
197
+ // Fallback: simple indexOf on original
198
+ const simpleIdx = content.indexOf(quote);
199
+ if (simpleIdx !== -1) {
200
+ return { from: simpleIdx, to: simpleIdx + quote.length };
201
+ }
202
+
203
+ return null;
204
+ }
205
+
206
+ /**
207
+ * Re-anchor marks whose ranges may have shifted due to content edits.
208
+ * Uses quote text to find new positions. Marks orphaned if quote not found.
209
+ */
210
+ export function reanchorMarks(content: string, marks: Record<string, Mark>): Record<string, Mark> {
211
+ const result: Record<string, Mark> = {};
212
+
213
+ for (const [id, mark] of Object.entries(marks)) {
214
+ // Check if current range still matches quote
215
+ const currentText = content.slice(mark.range.from, mark.range.to);
216
+ if (normalizeQuote(currentText) === normalizeQuote(mark.quote)) {
217
+ result[id] = mark;
218
+ continue;
219
+ }
220
+
221
+ // Try to re-anchor using quote
222
+ const newRange = findQuotePosition(content, mark.quote);
223
+ if (newRange) {
224
+ result[id] = { ...mark, range: newRange, orphaned: false };
225
+ } else {
226
+ result[id] = { ...mark, orphaned: true };
227
+ }
228
+ }
229
+
230
+ return result;
231
+ }
232
+
233
+ // ============================================================================
234
+ // Tagging: tag entire content as authored by a given actor
235
+ // ============================================================================
236
+
237
+ /**
238
+ * Tag the full content as authored by the given actor.
239
+ * Preserves existing marks and only adds a mark for uncovered ranges.
240
+ */
241
+ export function tagContent(markdown: string, by: string): string {
242
+ const { content, marks: existingDoc } = extractMarks(markdown);
243
+ const existingMarks = existingDoc?.marks ?? {};
244
+
245
+ // Re-anchor existing marks against current content
246
+ const anchored =
247
+ Object.keys(existingMarks).length > 0 ? reanchorMarks(content, existingMarks) : {};
248
+
249
+ // Find uncovered character ranges
250
+ const covered = new Set<number>();
251
+ for (const mark of Object.values(anchored)) {
252
+ if (mark.kind !== "authored" || mark.orphaned) continue;
253
+ for (let i = mark.range.from; i < mark.range.to && i < content.length; i++) {
254
+ covered.add(i);
255
+ }
256
+ }
257
+
258
+ // Build marks for uncovered ranges (coalesce adjacent)
259
+ const newMarks: Record<string, Mark> = { ...anchored };
260
+ let rangeStart = -1;
261
+
262
+ for (let i = 0; i <= content.length; i++) {
263
+ const isCovered = covered.has(i);
264
+ const atEnd = i === content.length;
265
+
266
+ if (!isCovered && !atEnd && rangeStart === -1) {
267
+ rangeStart = i;
268
+ } else if ((isCovered || atEnd) && rangeStart !== -1) {
269
+ const quote = content.slice(rangeStart, i);
270
+ if (quote.trim().length > 0) {
271
+ const mark = createAuthored(by, { from: rangeStart, to: i }, quote);
272
+ newMarks[mark.id] = mark;
273
+ }
274
+ rangeStart = -1;
275
+ }
276
+ }
277
+
278
+ const doc: MarksDocument = { version: 2, marks: newMarks };
279
+ return embedMarks(content, doc);
280
+ }
@@ -0,0 +1,127 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from "bun:test";
2
+ import { existsSync, mkdtempSync, readFileSync, rmSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { tmpdir } from "node:os";
5
+ import { AsciicastRecorder } from "./recorder.ts";
6
+
7
+ describe("AsciicastRecorder", () => {
8
+ let dir: string;
9
+
10
+ beforeEach(() => {
11
+ dir = mkdtempSync(join(tmpdir(), "cast-test-"));
12
+ });
13
+
14
+ afterEach(() => {
15
+ rmSync(dir, { recursive: true, force: true });
16
+ });
17
+
18
+ it("creates recording directory", () => {
19
+ const rec = new AsciicastRecorder({
20
+ dir,
21
+ session: "test-sess",
22
+ paneId: "%0",
23
+ });
24
+ // Starting will fail since no tmux is running, but the file should be created
25
+ try {
26
+ rec.start();
27
+ rec.stop();
28
+ } catch {
29
+ // expected in test environment
30
+ }
31
+
32
+ // The .tasks/recordings/ directory should have been created
33
+ // existsSync imported at top
34
+ expect(existsSync(join(dir, ".tasks", "recordings"))).toBe(true);
35
+ });
36
+
37
+ it("writes asciicast v2 header on start", () => {
38
+ const rec = new AsciicastRecorder({
39
+ dir,
40
+ session: "test-sess",
41
+ paneId: "%0",
42
+ intervalMs: 60000, // long interval so timer doesn't fire
43
+ });
44
+
45
+ const filePath = rec.start();
46
+ rec.stop();
47
+
48
+ const content = readFileSync(filePath, "utf-8");
49
+ const lines = content.trim().split("\n");
50
+ expect(lines.length).toBeGreaterThanOrEqual(1);
51
+
52
+ const header = JSON.parse(lines[0]!);
53
+ expect(header.version).toBe(2);
54
+ expect(typeof header.width).toBe("number");
55
+ expect(typeof header.height).toBe("number");
56
+ expect(typeof header.timestamp).toBe("number");
57
+ expect(header.width).toBeGreaterThan(0);
58
+ expect(header.height).toBeGreaterThan(0);
59
+ });
60
+
61
+ it("getFilePath returns the .cast file path", () => {
62
+ const rec = new AsciicastRecorder({
63
+ dir,
64
+ session: "my-session",
65
+ paneId: "%5",
66
+ });
67
+ const path = rec.start();
68
+ rec.stop();
69
+ expect(path).toBe(rec.getFilePath());
70
+ expect(path).toContain(".cast");
71
+ expect(path).toContain("my-session");
72
+ expect(path).toContain("5"); // %5 with % stripped
73
+ });
74
+
75
+ it("file is in .tasks/recordings/", () => {
76
+ const rec = new AsciicastRecorder({
77
+ dir,
78
+ session: "s",
79
+ paneId: "%0",
80
+ });
81
+ const path = rec.start();
82
+ rec.stop();
83
+ expect(path).toContain(join(".tasks", "recordings"));
84
+ });
85
+
86
+ it("event lines follow asciicast v2 format", () => {
87
+ // We can't easily capture from a real tmux pane in tests, but we can
88
+ // verify the header format is correct and that stop doesn't throw.
89
+ const rec = new AsciicastRecorder({
90
+ dir,
91
+ session: "fmt-test",
92
+ paneId: "%0",
93
+ intervalMs: 60000,
94
+ });
95
+ rec.start();
96
+ rec.stop();
97
+
98
+ const content = readFileSync(rec.getFilePath(), "utf-8");
99
+ const lines = content.trim().split("\n").filter(Boolean);
100
+
101
+ // First line is header
102
+ const header = JSON.parse(lines[0]!);
103
+ expect(header.version).toBe(2);
104
+
105
+ // Any subsequent lines should be [number, "o", string] arrays
106
+ for (const line of lines.slice(1)) {
107
+ const event = JSON.parse(line);
108
+ expect(Array.isArray(event)).toBe(true);
109
+ expect(event.length).toBe(3);
110
+ expect(typeof event[0]).toBe("number"); // elapsed seconds
111
+ expect(event[1]).toBe("o"); // output event type
112
+ expect(typeof event[2]).toBe("string"); // data
113
+ }
114
+ });
115
+
116
+ it("stop is idempotent", () => {
117
+ const rec = new AsciicastRecorder({
118
+ dir,
119
+ session: "s",
120
+ paneId: "%0",
121
+ intervalMs: 60000,
122
+ });
123
+ rec.start();
124
+ rec.stop();
125
+ rec.stop(); // should not throw
126
+ });
127
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Records tmux pane output in asciicast v2 format.
3
+ *
4
+ * Uses `tmux capture-pane -p -t <paneId>` to snapshot pane content and
5
+ * emits only the delta between snapshots as asciicast event lines.
6
+ *
7
+ * File format (one JSON object per line):
8
+ * Line 1 (header): {"version":2,"width":W,"height":H,"timestamp":T}
9
+ * Line 2+: [elapsed_seconds, "o", "data"]
10
+ *
11
+ * @see https://docs.asciinema.org/manual/asciicast/v2/
12
+ */
13
+
14
+ import { execFileSync } from "node:child_process";
15
+ import { appendFileSync, existsSync, mkdirSync, writeFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ export interface RecorderOptions {
19
+ /** Project directory (recordings go in .tasks/recordings/). */
20
+ dir: string;
21
+ /** tmux session name. */
22
+ session: string;
23
+ /** tmux pane ID (e.g. %0). */
24
+ paneId: string;
25
+ /** Capture interval in ms (default 1000). */
26
+ intervalMs?: number;
27
+ }
28
+
29
+ export class AsciicastRecorder {
30
+ private filePath: string;
31
+ private timer: ReturnType<typeof setInterval> | null = null;
32
+ private startTime: number = 0;
33
+ private lastSnapshot: string = "";
34
+ private session: string;
35
+ private paneId: string;
36
+ private intervalMs: number;
37
+
38
+ constructor(opts: RecorderOptions) {
39
+ this.session = opts.session;
40
+ this.paneId = opts.paneId;
41
+ this.intervalMs = opts.intervalMs ?? 1000;
42
+
43
+ const recDir = join(opts.dir, ".tasks", "recordings");
44
+ if (!existsSync(recDir)) mkdirSync(recDir, { recursive: true });
45
+
46
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
47
+ const safePane = this.paneId.replace(/%/g, "");
48
+ this.filePath = join(recDir, `${this.session}_${safePane}_${ts}.cast`);
49
+ }
50
+
51
+ /** Start recording. Writes the header and begins periodic captures. */
52
+ start(): string {
53
+ const { width, height } = this.getPaneSize();
54
+ this.startTime = Date.now();
55
+
56
+ const header = {
57
+ version: 2,
58
+ width,
59
+ height,
60
+ timestamp: Math.floor(this.startTime / 1000),
61
+ };
62
+ writeFileSync(this.filePath, JSON.stringify(header) + "\n");
63
+ this.lastSnapshot = "";
64
+
65
+ this.timer = setInterval(() => this.capture(), this.intervalMs);
66
+ this.capture(); // immediate first capture
67
+
68
+ return this.filePath;
69
+ }
70
+
71
+ /** Stop recording and flush. */
72
+ stop(): void {
73
+ if (this.timer) {
74
+ clearInterval(this.timer);
75
+ this.timer = null;
76
+ }
77
+ // Final capture
78
+ this.capture();
79
+ }
80
+
81
+ /** Path to the recording file. */
82
+ getFilePath(): string {
83
+ return this.filePath;
84
+ }
85
+
86
+ /** Single capture tick — computes delta and appends event. */
87
+ private capture(): void {
88
+ try {
89
+ const current = execFileSync("tmux", ["capture-pane", "-p", "-t", this.paneId], {
90
+ encoding: "utf-8",
91
+ stdio: ["ignore", "pipe", "ignore"],
92
+ });
93
+
94
+ if (current === this.lastSnapshot) return; // no change
95
+
96
+ const elapsed = (Date.now() - this.startTime) / 1000;
97
+ const delta = this.computeDelta(this.lastSnapshot, current);
98
+ this.lastSnapshot = current;
99
+
100
+ if (delta.length === 0) return;
101
+
102
+ const event = JSON.stringify([elapsed, "o", delta]);
103
+ appendFileSync(this.filePath, event + "\n");
104
+ } catch {
105
+ // pane gone or tmux not available — stop silently
106
+ }
107
+ }
108
+
109
+ /** Compute the new/changed content between two snapshots. */
110
+ private computeDelta(prev: string, current: string): string {
111
+ if (prev === "") return current;
112
+
113
+ const prevLines = prev.split("\n");
114
+ const curLines = current.split("\n");
115
+
116
+ // Find first differing line
117
+ let firstDiff = 0;
118
+ while (
119
+ firstDiff < prevLines.length &&
120
+ firstDiff < curLines.length &&
121
+ prevLines[firstDiff] === curLines[firstDiff]
122
+ ) {
123
+ firstDiff++;
124
+ }
125
+
126
+ if (firstDiff >= curLines.length) return "";
127
+
128
+ // Return the changed portion with ANSI cursor positioning
129
+ const changedLines = curLines.slice(firstDiff);
130
+ const moveToLine = `\x1b[${firstDiff + 1};1H`;
131
+ return moveToLine + changedLines.join("\n");
132
+ }
133
+
134
+ /** Get pane dimensions from tmux. */
135
+ private getPaneSize(): { width: number; height: number } {
136
+ try {
137
+ const raw = execFileSync(
138
+ "tmux",
139
+ ["display-message", "-p", "-t", this.paneId, "#{pane_width}\t#{pane_height}"],
140
+ { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] },
141
+ ).trim();
142
+ const [w, h] = raw.split("\t");
143
+ return { width: parseInt(w!, 10) || 80, height: parseInt(h!, 10) || 24 };
144
+ } catch {
145
+ return { width: 80, height: 24 };
146
+ }
147
+ }
148
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Minimal crash-recovery watchdog for the tmux-ide daemon.
3
+ * Respawns daemon.ts on unexpected exits with exponential backoff.
4
+ * Gives up after 5 crashes within 60 seconds.
5
+ *
6
+ * Entry: bun src/lib/daemon-watchdog.ts <session> [port]
7
+ *
8
+ * ZERO imports from business logic — this file must never crash
9
+ * due to broken application code.
10
+ */
11
+
12
+ import { spawn, type ChildProcess } from "node:child_process";
13
+ import { resolve, dirname } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ import { appendFileSync, mkdirSync, existsSync } from "node:fs";
16
+
17
+ const __dirname = dirname(fileURLToPath(import.meta.url));
18
+ const sessionArg = process.argv[2];
19
+ const port = process.argv[3] ?? "0";
20
+
21
+ if (!sessionArg) {
22
+ console.error("Usage: daemon-watchdog.ts <session> [port]");
23
+ process.exit(1);
24
+ }
25
+
26
+ const session: string = sessionArg;
27
+
28
+ const daemonScript = resolve(__dirname, "daemon.ts");
29
+
30
+ let backoffMs = 1000;
31
+ let crashCount = 0;
32
+ let lastCrashTime = 0;
33
+ let child: ChildProcess | null = null;
34
+
35
+ function logError(msg: string): void {
36
+ try {
37
+ const tasksDir = resolve(process.cwd(), ".tasks");
38
+ if (!existsSync(tasksDir)) mkdirSync(tasksDir, { recursive: true });
39
+ const entry = JSON.stringify({
40
+ timestamp: new Date().toISOString(),
41
+ type: "error",
42
+ source: "daemon-watchdog",
43
+ message: msg,
44
+ });
45
+ appendFileSync(resolve(tasksDir, "events.log"), entry + "\n");
46
+ } catch {
47
+ // If even logging fails, silently continue
48
+ }
49
+ }
50
+
51
+ function spawnDaemon(): void {
52
+ child = spawn("bun", [daemonScript, session, port], {
53
+ cwd: process.cwd(),
54
+ stdio: ["ignore", "pipe", "pipe"],
55
+ });
56
+
57
+ // Pipe child stdout/stderr to our own so logs are visible in the detached process
58
+ child.stdout?.pipe(process.stdout);
59
+ child.stderr?.pipe(process.stderr);
60
+
61
+ child.on("exit", (code) => {
62
+ child = null;
63
+
64
+ // Clean shutdown (code 0) — don't respawn
65
+ if (code === 0) {
66
+ process.exit(0);
67
+ }
68
+
69
+ const now = Date.now();
70
+
71
+ // Reset crash counter if the last crash was more than 60s ago
72
+ if (now - lastCrashTime > 60_000) {
73
+ crashCount = 0;
74
+ backoffMs = 1000;
75
+ }
76
+
77
+ crashCount++;
78
+ lastCrashTime = now;
79
+
80
+ if (crashCount > 5) {
81
+ logError(`Daemon crashed ${crashCount} times in 60s — giving up`);
82
+ process.exit(1);
83
+ }
84
+
85
+ logError(`Daemon exited with code ${code}, respawning in ${backoffMs}ms`);
86
+ setTimeout(spawnDaemon, backoffMs);
87
+ backoffMs = Math.min(backoffMs * 2, 30_000);
88
+ });
89
+ }
90
+
91
+ // Forward termination signals to the child daemon
92
+ process.on("SIGTERM", () => {
93
+ if (child) child.kill("SIGTERM");
94
+ else process.exit(0);
95
+ });
96
+
97
+ process.on("SIGINT", () => {
98
+ if (child) child.kill("SIGTERM");
99
+ else process.exit(0);
100
+ });
101
+
102
+ spawnDaemon();