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.
- package/bin/cli.js +4 -216
- package/bin/cli.ts +536 -0
- package/package.json +33 -14
- package/src/__tests__/support.ts +186 -0
- package/src/{attach.js → attach.ts} +7 -4
- package/src/cli.test.ts +314 -0
- package/src/command-center/discovery.test.ts +265 -0
- package/src/command-center/discovery.ts +296 -0
- package/src/command-center/index.ts +31 -0
- package/src/command-center/schemas.ts +57 -0
- package/src/command-center/server.test.ts +818 -0
- package/src/command-center/server.ts +1426 -0
- package/src/config-cli.test.ts +86 -0
- package/src/config.test.ts +205 -0
- package/src/config.ts +313 -0
- package/src/detect.test.ts +142 -0
- package/src/{detect.js → detect.ts} +31 -15
- package/src/dispatch.ts +102 -0
- package/src/doctor.test.ts +119 -0
- package/src/doctor.ts +167 -0
- package/src/init.test.ts +101 -0
- package/src/init.ts +144 -0
- package/src/inspect.test.ts +67 -0
- package/src/{inspect.js → inspect.ts} +95 -10
- package/src/integration.test.ts.skip +276 -0
- package/src/js-yaml.d.ts +10 -0
- package/src/launch.test.ts +222 -0
- package/src/launch.ts +494 -0
- package/src/lib/auth/auth-service.test.ts +178 -0
- package/src/lib/auth/auth-service.ts +227 -0
- package/src/lib/auth/middleware.test.ts +119 -0
- package/src/lib/auth/middleware.ts +56 -0
- package/src/lib/auth/types.ts +13 -0
- package/src/lib/authorship.test.ts +242 -0
- package/src/lib/authorship.ts +280 -0
- package/src/lib/cast/recorder.test.ts +127 -0
- package/src/lib/cast/recorder.ts +148 -0
- package/src/lib/daemon-watchdog.ts +102 -0
- package/src/lib/daemon.ts +417 -0
- package/src/lib/dot-path.test.ts +66 -0
- package/src/lib/dot-path.ts +17 -0
- package/src/lib/errors.ts +44 -0
- package/src/lib/event-log.test.ts +189 -0
- package/src/lib/event-log.ts +237 -0
- package/src/lib/github-pr.ts +182 -0
- package/src/lib/hq/client.test.ts +154 -0
- package/src/lib/hq/client.ts +142 -0
- package/src/lib/hq/mdns.test.ts +54 -0
- package/src/lib/hq/mdns.ts +88 -0
- package/src/lib/hq/registry.test.ts +237 -0
- package/src/lib/hq/registry.ts +145 -0
- package/src/lib/hq/types.ts +44 -0
- package/src/lib/ipc/socket-protocol.test.ts +126 -0
- package/src/lib/ipc/socket-protocol.ts +324 -0
- package/src/lib/launch-plan.test.ts +119 -0
- package/src/lib/launch-plan.ts +90 -0
- package/src/lib/log.test.ts +39 -0
- package/src/lib/log.ts +71 -0
- package/src/lib/metrics.test.ts +213 -0
- package/src/lib/metrics.ts +345 -0
- package/src/lib/orchestrator.test.ts +2358 -0
- package/src/lib/orchestrator.ts +1792 -0
- package/src/lib/{output.js → output.ts} +18 -10
- package/src/lib/plan-store.test.ts +148 -0
- package/src/lib/plan-store.ts +131 -0
- package/src/lib/research.test.ts +208 -0
- package/src/lib/research.ts +475 -0
- package/src/lib/round-trip.test.ts +341 -0
- package/src/lib/session-monitor.test.ts +152 -0
- package/src/lib/session-monitor.ts +282 -0
- package/src/lib/session-options.test.ts +146 -0
- package/src/lib/{session-options.js → session-options.ts} +11 -6
- package/src/lib/shell.test.ts +20 -0
- package/src/lib/shell.ts +8 -0
- package/src/lib/sizes.test.ts +73 -0
- package/src/lib/{sizes.js → sizes.ts} +4 -4
- package/src/lib/skill-registry.test.ts +134 -0
- package/src/lib/skill-registry.ts +86 -0
- package/src/lib/slugify.test.ts +35 -0
- package/src/lib/slugify.ts +10 -0
- package/src/lib/task-store.ts +352 -0
- package/src/lib/tmux.test.ts +500 -0
- package/src/lib/tmux.ts +372 -0
- package/src/lib/token-tracker.test.ts +102 -0
- package/src/lib/token-tracker.ts +70 -0
- package/src/lib/tunnels/cloudflare.test.ts +73 -0
- package/src/lib/tunnels/cloudflare.ts +254 -0
- package/src/lib/tunnels/manager.test.ts +63 -0
- package/src/lib/tunnels/manager.ts +132 -0
- package/src/lib/tunnels/ngrok.test.ts +94 -0
- package/src/lib/tunnels/ngrok.ts +261 -0
- package/src/lib/tunnels/tailscale.test.ts +103 -0
- package/src/lib/tunnels/tailscale.ts +1048 -0
- package/src/lib/tunnels/types.ts +44 -0
- package/src/lib/validation.test.ts +289 -0
- package/src/lib/validation.ts +123 -0
- package/src/lib/webhook.ts +51 -0
- package/src/lib/workflow-store.ts +209 -0
- package/src/lib/ws-v3/protocol.test.ts +127 -0
- package/src/lib/ws-v3/protocol.ts +160 -0
- package/src/lib/yaml-io.test.ts +125 -0
- package/src/lib/{yaml-io.js → yaml-io.ts} +5 -4
- package/src/ls.test.ts +76 -0
- package/src/{ls.js → ls.ts} +4 -4
- package/src/metrics-cli.ts +177 -0
- package/src/notify.ts +85 -0
- package/src/orchestrator-status.ts +228 -0
- package/src/plan.ts +117 -0
- package/src/postinstall.test.ts +79 -0
- package/src/remote.test.ts +115 -0
- package/src/remote.ts +148 -0
- package/src/restart.ts +24 -0
- package/src/schemas/domain.ts +422 -0
- package/src/schemas/ide-config.ts +148 -0
- package/src/schemas/index.ts +53 -0
- package/src/send.test.ts +70 -0
- package/src/send.ts +181 -0
- package/src/skill.test.ts +114 -0
- package/src/skill.ts +138 -0
- package/src/status.ts +66 -0
- package/src/stop.test.ts +176 -0
- package/src/stop.ts +47 -0
- package/src/task.test.ts +1466 -0
- package/src/task.ts +1143 -0
- package/src/tunnel.test.ts +80 -0
- package/src/tunnel.ts +133 -0
- package/src/types.ts +15 -0
- package/src/ui/index.ts +32 -0
- package/src/ui/terminal/index.ts +9 -0
- package/src/ui/types.ts +91 -0
- package/src/ui/web/base.css +80 -0
- package/src/ui/web/components/Box.tsx +59 -0
- package/src/ui/web/components/Input.tsx +32 -0
- package/src/ui/web/components/ScrollBox.tsx +60 -0
- package/src/ui/web/components/Text.tsx +28 -0
- package/src/ui/web/hooks.ts +106 -0
- package/src/ui/web/index.ts +27 -0
- package/src/ui/web/render.ts +77 -0
- package/src/ui/web/utils/color.test.ts +93 -0
- package/src/ui/web/utils/color.ts +27 -0
- package/src/validate.test.ts +385 -0
- package/src/validate.ts +217 -0
- package/src/widgets/changes/index.tsx +576 -0
- package/src/widgets/config/index.tsx +440 -0
- package/src/widgets/costs/index.tsx +216 -0
- package/src/widgets/explorer/breadcrumbs.tsx +77 -0
- package/src/widgets/explorer/footer.tsx +20 -0
- package/src/widgets/explorer/header.tsx +23 -0
- package/src/widgets/explorer/index.tsx +408 -0
- package/src/widgets/explorer/tree-model.test.ts +214 -0
- package/src/widgets/explorer/tree-model.ts +103 -0
- package/src/widgets/explorer/tree.tsx +156 -0
- package/src/widgets/lib/config-model.ts +116 -0
- package/src/widgets/lib/files.test.ts +103 -0
- package/src/widgets/lib/files.ts +88 -0
- package/src/widgets/lib/git.test.ts +151 -0
- package/src/widgets/lib/git.ts +88 -0
- package/src/widgets/lib/pane-comms.test.ts +178 -0
- package/src/widgets/lib/pane-comms.ts +192 -0
- package/src/widgets/lib/theme.ts +151 -0
- package/src/widgets/lib/watcher.ts +50 -0
- package/src/widgets/mission-control/activity-feed.tsx +59 -0
- package/src/widgets/mission-control/agent-panel.tsx +132 -0
- package/src/widgets/mission-control/command-bar.tsx +63 -0
- package/src/widgets/mission-control/index.tsx +751 -0
- package/src/widgets/mission-control/task-panel.tsx +238 -0
- package/src/widgets/preview/index.tsx +415 -0
- package/src/widgets/resolve.ts +40 -0
- package/src/widgets/setup/agent-naming.tsx +112 -0
- package/src/widgets/setup/config-tree.tsx +238 -0
- package/src/widgets/setup/detect-panel.tsx +72 -0
- package/src/widgets/setup/field-editor.tsx +265 -0
- package/src/widgets/setup/footer.tsx +107 -0
- package/src/widgets/setup/index.tsx +340 -0
- package/src/widgets/setup/layout-picker.tsx +96 -0
- package/src/widgets/setup/orchestrator-panel.tsx +200 -0
- package/src/widgets/setup/review-panel.tsx +140 -0
- package/src/widgets/setup/setup-model.test.ts +303 -0
- package/src/widgets/setup/setup-model.ts +188 -0
- package/src/widgets/tasks/index.tsx +129 -0
- package/src/widgets/tasks/task-detail.tsx +309 -0
- package/src/widgets/tasks/task-form.tsx +193 -0
- package/src/widgets/tasks/task-list.tsx +205 -0
- package/src/widgets/tasks/task-model.test.ts +473 -0
- package/src/widgets/tasks/task-model.ts +157 -0
- package/templates/AGENTS.md +20 -0
- package/templates/missions.yml +41 -0
- package/templates/skills/backend.md +30 -0
- package/templates/skills/frontend.md +30 -0
- package/templates/skills/general-worker.md +30 -0
- package/templates/skills/researcher.md +69 -0
- package/templates/skills/reviewer.md +30 -0
- package/src/config.js +0 -341
- package/src/doctor.js +0 -91
- package/src/init.js +0 -73
- package/src/launch.js +0 -245
- package/src/lib/dot-path.js +0 -16
- package/src/lib/errors.js +0 -35
- package/src/lib/launch-plan.js +0 -49
- package/src/lib/session-monitor.js +0 -179
- package/src/lib/tmux.js +0 -237
- package/src/restart.js +0 -16
- package/src/status.js +0 -35
- package/src/stop.js +0 -25
- 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();
|