@fml-inc/fml 0.7.0 → 0.8.0-rc.2

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 (45) hide show
  1. package/.claude-plugin/plugin.json +2 -4
  2. package/README.md +323 -79
  3. package/agent-assets/fml/SKILL.md +180 -0
  4. package/bin/fml +4 -18
  5. package/bin/hook-handler +7 -0
  6. package/bin/mcp-server +4 -18
  7. package/bin/observer +6 -0
  8. package/dist/chunk-3KISRT57.js +140 -0
  9. package/dist/chunk-56TPYMNK.js +4657 -0
  10. package/dist/chunk-7V7G7NVF.js +142 -0
  11. package/dist/chunk-A6HAEVJX.js +1606 -0
  12. package/dist/chunk-DZKYW327.js +1037 -0
  13. package/dist/chunk-ENW2GJNO.js +109 -0
  14. package/dist/{chunk-PZ5AY32C.js → chunk-MLKGABMK.js} +0 -1
  15. package/dist/{chunk-PTUZMKSN.js → chunk-R45CWWZO.js} +34 -23
  16. package/dist/chunk-WZLLO563.js +953 -0
  17. package/dist/chunk-YSU5CS2V.js +3331 -0
  18. package/dist/cli.js +7742 -1072
  19. package/dist/{esm-2EUDFZWP.js → esm-4YKLKFRX.js} +546 -741
  20. package/dist/hook-handler.js +440 -0
  21. package/dist/mcp/server.js +112 -98
  22. package/dist/observer-server.js +1566 -0
  23. package/dist/sync-cycle.js +31 -0
  24. package/dist/{token-store-3IQSLOTQ.js → token-store-BT5THIAJ.js} +10 -3
  25. package/hooks/hooks.json +290 -0
  26. package/package.json +24 -27
  27. package/dist/chunk-PTUZMKSN.js.map +0 -1
  28. package/dist/chunk-PZ5AY32C.js.map +0 -1
  29. package/dist/chunk-S4TJNLEN.js +0 -61
  30. package/dist/chunk-S4TJNLEN.js.map +0 -1
  31. package/dist/chunk-VV4JHH4H.js +0 -437
  32. package/dist/chunk-VV4JHH4H.js.map +0 -1
  33. package/dist/chunk-YH5ROR7G.js +0 -69
  34. package/dist/chunk-YH5ROR7G.js.map +0 -1
  35. package/dist/cli.js.map +0 -1
  36. package/dist/daemon-MSLDHLMH.js +0 -21
  37. package/dist/daemon-MSLDHLMH.js.map +0 -1
  38. package/dist/daemon-utils-OODBIYB7.js +0 -10
  39. package/dist/daemon-utils-OODBIYB7.js.map +0 -1
  40. package/dist/esm-2EUDFZWP.js.map +0 -1
  41. package/dist/mcp/server.js.map +0 -1
  42. package/dist/postinstall.js +0 -16
  43. package/dist/postinstall.js.map +0 -1
  44. package/dist/token-store-3IQSLOTQ.js.map +0 -1
  45. package/scripts/postinstall.cjs +0 -25
@@ -0,0 +1,440 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ hookCaptureIsDisabled
4
+ } from "./chunk-ENW2GJNO.js";
5
+ import {
6
+ getActiveHookDestinationBinding
7
+ } from "./chunk-DZKYW327.js";
8
+ import {
9
+ HOOK_TARGETS,
10
+ SHELL_TOOL_NAMES,
11
+ createHookInboxEntry,
12
+ hookInboxDeliveryPayload,
13
+ normalizeObserverHookCapture,
14
+ persistHookInboxEntry,
15
+ readObserverAuthToken
16
+ } from "./chunk-56TPYMNK.js";
17
+ import {
18
+ resolveRepositoryRootFromCwd,
19
+ resolveRepositorySlugFromCwd
20
+ } from "./chunk-7V7G7NVF.js";
21
+ import {
22
+ FML_HOOK_CONTEXT_STATE_DIR,
23
+ FML_HOOK_INBOX_DIR,
24
+ FML_OBSERVER_AUTH_TOKEN_FILE,
25
+ FML_OBSERVER_ENDPOINT_FILE,
26
+ getValidToken
27
+ } from "./chunk-A6HAEVJX.js";
28
+ import "./chunk-MLKGABMK.js";
29
+
30
+ // src/hooks/handler.ts
31
+ import fs2 from "fs";
32
+
33
+ // src/hooks/context.ts
34
+ import { createHash } from "crypto";
35
+ import fs from "fs";
36
+ import path from "path";
37
+ var HOOK_CONTEXT_ENDPOINT = "/v1/panopticon/hook-context";
38
+ var HOOK_CONTEXT_TIMEOUT_MS = 2e3;
39
+ var MAX_ADDITIONAL_CONTEXT_CHARS = 4e3;
40
+ var MAX_PROMPT_CHARS = 2e4;
41
+ var STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
42
+ var EDIT_TOOL_NAMES = /* @__PURE__ */ new Set([
43
+ "apply_patch",
44
+ "edit",
45
+ "multiedit",
46
+ "notebookedit",
47
+ "write",
48
+ "write_file"
49
+ ]);
50
+ var READ_TOOL_NAMES = /* @__PURE__ */ new Set(["read", "read_file"]);
51
+ function sameDestination(left, right) {
52
+ return left.environment === right.environment && left.targetUrl === right.targetUrl && left.destinationIdentity === right.destinationIdentity;
53
+ }
54
+ function markerPath(stateDir, kind, value) {
55
+ const digest = createHash("sha256").update(value).digest("hex");
56
+ return path.join(stateDir, kind, digest);
57
+ }
58
+ function ensureMarkerDirectory(filePath) {
59
+ try {
60
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 448 });
61
+ return true;
62
+ } catch {
63
+ return false;
64
+ }
65
+ }
66
+ function isSubsequentUserPrompt(sessionId, eventSyncId, stateDir = FML_HOOK_CONTEXT_STATE_DIR) {
67
+ const filePath = markerPath(stateDir, "prompt", sessionId);
68
+ if (!ensureMarkerDirectory(filePath)) return false;
69
+ try {
70
+ fs.writeFileSync(filePath, eventSyncId, { flag: "wx", mode: 384 });
71
+ return false;
72
+ } catch (error) {
73
+ if (error.code !== "EEXIST") return false;
74
+ try {
75
+ return fs.readFileSync(filePath, "utf8") !== eventSyncId;
76
+ } catch {
77
+ return false;
78
+ }
79
+ }
80
+ }
81
+ function pathContextWasEmitted(sessionId, filePath, stateDir) {
82
+ try {
83
+ return fs.existsSync(
84
+ markerPath(stateDir, "path", `${sessionId}:${filePath}`)
85
+ );
86
+ } catch {
87
+ return true;
88
+ }
89
+ }
90
+ function claimPathContext(sessionId, filePath, stateDir) {
91
+ const claim = markerPath(stateDir, "path", `${sessionId}:${filePath}`);
92
+ if (!ensureMarkerDirectory(claim)) return false;
93
+ try {
94
+ fs.writeFileSync(claim, "", { flag: "wx", mode: 384 });
95
+ return true;
96
+ } catch {
97
+ return false;
98
+ }
99
+ }
100
+ function pruneOldMarkers(directory, nowMs) {
101
+ if ((nowMs + process.pid) % 256 !== 0) return;
102
+ for (const kind of ["prompt", "path"]) {
103
+ const markerDirectory = path.join(directory, kind);
104
+ let names;
105
+ try {
106
+ names = fs.readdirSync(markerDirectory).slice(0, 1e3);
107
+ } catch {
108
+ continue;
109
+ }
110
+ for (const name of names) {
111
+ const filePath = path.join(markerDirectory, name);
112
+ try {
113
+ if (nowMs - fs.statSync(filePath).mtimeMs > STATE_MAX_AGE_MS) {
114
+ fs.rmSync(filePath, { force: true });
115
+ }
116
+ } catch {
117
+ }
118
+ }
119
+ }
120
+ }
121
+ function canonicalFilePath(filePath, cwd, repositoryRoot) {
122
+ const windows = [filePath, cwd, repositoryRoot].some(
123
+ (value) => value !== void 0 && value !== null && /^[A-Za-z]:[\\/]/.test(value)
124
+ );
125
+ const pathApi = windows ? path.win32 : path.posix;
126
+ const normalized = pathApi.normalize(filePath);
127
+ const resolved = pathApi.isAbsolute(normalized) ? normalized : cwd && pathApi.isAbsolute(cwd) ? pathApi.resolve(cwd, normalized) : normalized;
128
+ if (repositoryRoot && pathApi.isAbsolute(resolved)) {
129
+ const relative2 = pathApi.relative(
130
+ pathApi.normalize(repositoryRoot),
131
+ resolved
132
+ );
133
+ if (relative2 && relative2 !== ".." && !relative2.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative2)) {
134
+ return relative2.replace(/\\/g, "/");
135
+ }
136
+ }
137
+ if (!pathApi.isAbsolute(resolved)) {
138
+ return resolved.replace(/^[.][\\/]/, "").replace(/\\/g, "/");
139
+ }
140
+ if (!cwd || pathApi.isAbsolute(cwd) !== pathApi.isAbsolute(resolved)) {
141
+ return resolved.replace(/\\/g, "/");
142
+ }
143
+ const relative = pathApi.relative(pathApi.normalize(cwd), resolved);
144
+ if (relative && relative !== ".." && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative)) {
145
+ return relative.replace(/\\/g, "/");
146
+ }
147
+ return resolved.replace(/\\/g, "/");
148
+ }
149
+ function buildServerHookContextRequest(target2, event, stateDir = FML_HOOK_CONTEXT_STATE_DIR, repositoryRoot = null) {
150
+ if (target2 !== "claude" && target2 !== "codex") return null;
151
+ if (!event.repository) return null;
152
+ const markerSessionId = `${target2}:${event.sessionId}`;
153
+ const base = {
154
+ repository: event.repository,
155
+ sessionId: event.sessionId,
156
+ nowMs: event.timestampMs
157
+ };
158
+ if (event.eventType === "SessionStart") {
159
+ return { ...base, eventType: "SessionStart" };
160
+ }
161
+ if (event.eventType === "UserPromptSubmit" && event.userPrompt) {
162
+ if (!isSubsequentUserPrompt(markerSessionId, event.syncId, stateDir)) {
163
+ return null;
164
+ }
165
+ return {
166
+ ...base,
167
+ eventType: "UserPromptSubmit",
168
+ prompt: event.userPrompt.slice(0, MAX_PROMPT_CHARS)
169
+ };
170
+ }
171
+ if (event.eventType !== "PreToolUse" || !event.toolName) {
172
+ return null;
173
+ }
174
+ const toolName = event.toolName.toLowerCase();
175
+ const fileOperation = EDIT_TOOL_NAMES.has(toolName) ? "edit" : READ_TOOL_NAMES.has(toolName) || SHELL_TOOL_NAMES.has(toolName) ? "read" : null;
176
+ if (!fileOperation) return null;
177
+ if (!event.filePath) return null;
178
+ const filePath = canonicalFilePath(event.filePath, event.cwd, repositoryRoot);
179
+ if (pathContextWasEmitted(
180
+ markerSessionId,
181
+ `${event.repository}:${filePath}`,
182
+ stateDir
183
+ )) {
184
+ return null;
185
+ }
186
+ return { ...base, eventType: "PreToolUse", filePath, fileOperation };
187
+ }
188
+ function hookOutput(request, additionalContext) {
189
+ return {
190
+ hookSpecificOutput: {
191
+ hookEventName: request.eventType,
192
+ additionalContext
193
+ }
194
+ };
195
+ }
196
+ async function requestServerHookContext(target2, event, destination, opts = {}) {
197
+ const deadlineSignal = AbortSignal.timeout(
198
+ opts.timeoutMs ?? HOOK_CONTEXT_TIMEOUT_MS
199
+ );
200
+ const stateDir = opts.stateDir ?? FML_HOOK_CONTEXT_STATE_DIR;
201
+ pruneOldMarkers(stateDir, Date.now());
202
+ const repositoryRoot = event.eventType === "PreToolUse" && event.cwd ? (opts.resolveRepositoryRoot ?? ((cwd) => resolveRepositoryRootFromCwd(cwd, 250)))(event.cwd) : null;
203
+ const request = buildServerHookContextRequest(
204
+ target2,
205
+ event,
206
+ stateDir,
207
+ repositoryRoot
208
+ );
209
+ if (!request) return {};
210
+ try {
211
+ const token = await getValidToken({
212
+ env: destination.environment,
213
+ signal: deadlineSignal
214
+ });
215
+ if (!token) return {};
216
+ const currentDestination = getActiveHookDestinationBinding();
217
+ if (!currentDestination || !sameDestination(destination, currentDestination)) {
218
+ return {};
219
+ }
220
+ const response = await (opts.fetch ?? fetch)(
221
+ `${destination.targetUrl.replace(/\/+$/, "")}${HOOK_CONTEXT_ENDPOINT}`,
222
+ {
223
+ method: "POST",
224
+ headers: {
225
+ Authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}`,
226
+ "Content-Type": "application/json"
227
+ },
228
+ body: JSON.stringify(request),
229
+ signal: deadlineSignal
230
+ }
231
+ );
232
+ if (!response.ok) return {};
233
+ const body = await response.json();
234
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
235
+ const additionalContext = body.additionalContext;
236
+ if (request.eventType === "PreToolUse" && request.filePath && additionalContext === null) {
237
+ claimPathContext(
238
+ `${target2}:${request.sessionId}`,
239
+ `${request.repository}:${request.filePath}`,
240
+ stateDir
241
+ );
242
+ return {};
243
+ }
244
+ if (typeof additionalContext !== "string" || additionalContext.length === 0 || additionalContext.length > MAX_ADDITIONAL_CONTEXT_CHARS) {
245
+ return {};
246
+ }
247
+ if (request.eventType === "PreToolUse" && request.filePath && !claimPathContext(
248
+ `${target2}:${request.sessionId}`,
249
+ `${request.repository}:${request.filePath}`,
250
+ stateDir
251
+ )) {
252
+ return {};
253
+ }
254
+ return hookOutput(request, additionalContext);
255
+ } catch {
256
+ return {};
257
+ }
258
+ }
259
+
260
+ // src/hooks/handler.ts
261
+ var MAX_HOOK_STDIN_BYTES = 4 * 1024 * 1024;
262
+ var HOOK_REQUEST_TIMEOUT_MS = 500;
263
+ var HOOK_STDIN_TIMEOUT_MS = 5e3;
264
+ var HOOK_REPO_RESOLUTION_TIMEOUT_MS = 250;
265
+ function parseHookTarget(value) {
266
+ return HOOK_TARGETS.includes(value) ? value : null;
267
+ }
268
+ function observerEndpoint(endpointFile) {
269
+ try {
270
+ const parsed = JSON.parse(fs2.readFileSync(endpointFile, "utf8"));
271
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
272
+ return null;
273
+ }
274
+ const record = parsed;
275
+ if (record.host !== "127.0.0.1" && record.host !== "::1" && record.host !== "localhost" || typeof record.port !== "number" || !Number.isInteger(record.port) || record.port <= 0 || record.port > 65535) {
276
+ return null;
277
+ }
278
+ return { host: record.host, port: record.port };
279
+ } catch {
280
+ return null;
281
+ }
282
+ }
283
+ async function readBoundedInput(input, timeoutMs) {
284
+ return await new Promise((resolve) => {
285
+ const chunks = [];
286
+ let totalBytes = 0;
287
+ let oversized = false;
288
+ let settled = false;
289
+ const swallowLateError = () => {
290
+ };
291
+ const cleanup = () => {
292
+ clearTimeout(timer);
293
+ input.off("data", onData);
294
+ input.off("end", onEnd);
295
+ input.off("close", onClose);
296
+ input.off("error", onError);
297
+ };
298
+ const finish = (value, drain = false) => {
299
+ if (settled) return;
300
+ settled = true;
301
+ cleanup();
302
+ if (drain) {
303
+ input.on("error", swallowLateError);
304
+ input.resume();
305
+ const unref = input.unref;
306
+ unref?.call(input);
307
+ }
308
+ resolve(value);
309
+ };
310
+ const onData = (chunk) => {
311
+ const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
312
+ totalBytes += bytes.byteLength;
313
+ if (totalBytes > MAX_HOOK_STDIN_BYTES) {
314
+ oversized = true;
315
+ chunks.length = 0;
316
+ return;
317
+ }
318
+ if (!oversized) chunks.push(bytes);
319
+ };
320
+ const onEnd = () => {
321
+ finish(oversized ? null : Buffer.concat(chunks, totalBytes));
322
+ };
323
+ const onClose = () => finish(null);
324
+ const onError = () => finish(null);
325
+ const timer = setTimeout(() => finish(null, true), timeoutMs);
326
+ input.on("data", onData);
327
+ input.once("end", onEnd);
328
+ input.once("close", onClose);
329
+ input.once("error", onError);
330
+ input.resume();
331
+ });
332
+ }
333
+ async function processHookHandler(target2, opts, includeContext) {
334
+ const input = opts.input ?? process.stdin;
335
+ const bytes = await readBoundedInput(
336
+ input,
337
+ opts.inputTimeoutMs ?? HOOK_STDIN_TIMEOUT_MS
338
+ );
339
+ if (!bytes || bytes.byteLength === 0) return { captured: false, output: {} };
340
+ let payload;
341
+ try {
342
+ payload = JSON.parse(bytes.toString("utf8"));
343
+ } catch {
344
+ return { captured: false, output: {} };
345
+ }
346
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
347
+ return { captured: false, output: {} };
348
+ }
349
+ if (hookCaptureIsDisabled(opts.captureDisabledFile)) {
350
+ return { captured: false, output: {} };
351
+ }
352
+ const cwd = opts.cwd ?? process.cwd();
353
+ const hookRequest = {
354
+ target: target2,
355
+ payload,
356
+ cwd
357
+ };
358
+ let event = normalizeObserverHookCapture(hookRequest);
359
+ if (event?.cwd) {
360
+ try {
361
+ const repository = (opts.resolveRepository ?? ((directory) => resolveRepositorySlugFromCwd(
362
+ directory,
363
+ HOOK_REPO_RESOLUTION_TIMEOUT_MS
364
+ )))(event.cwd);
365
+ if (repository) event = { ...event, repository };
366
+ } catch {
367
+ }
368
+ }
369
+ const inboxDir = opts.inboxDir ?? FML_HOOK_INBOX_DIR;
370
+ let inboxEntry;
371
+ let persisted = false;
372
+ let destination = null;
373
+ if (event) {
374
+ try {
375
+ destination = getActiveHookDestinationBinding();
376
+ } catch {
377
+ }
378
+ inboxEntry = createHookInboxEntry(
379
+ hookRequest,
380
+ event,
381
+ destination ?? void 0
382
+ );
383
+ try {
384
+ persistHookInboxEntry(inboxEntry, inboxDir, opts.inboxLimits);
385
+ persisted = true;
386
+ } catch {
387
+ }
388
+ }
389
+ const contextPromise = includeContext && event && destination ? opts.requestContext ? opts.requestContext(target2, event, destination) : requestServerHookContext(target2, event, destination, {
390
+ ...opts.contextFetch ? { fetch: opts.contextFetch } : {},
391
+ ...opts.contextTimeoutMs === void 0 ? {} : { timeoutMs: opts.contextTimeoutMs },
392
+ ...opts.contextStateDir ? { stateDir: opts.contextStateDir } : {}
393
+ }) : Promise.resolve({});
394
+ const endpoint = observerEndpoint(
395
+ opts.endpointFile ?? FML_OBSERVER_ENDPOINT_FILE
396
+ );
397
+ const authToken = readObserverAuthToken(
398
+ opts.authTokenFile ?? FML_OBSERVER_AUTH_TOKEN_FILE
399
+ );
400
+ const deliveryPromise = (async () => {
401
+ if (!endpoint || !authToken) return persisted;
402
+ const host = endpoint.host === "::1" ? "[::1]" : endpoint.host;
403
+ const fetchRequest = opts.fetch ?? fetch;
404
+ try {
405
+ const response = await fetchRequest(
406
+ `http://${host}:${endpoint.port}/hooks`,
407
+ {
408
+ method: "POST",
409
+ headers: {
410
+ Authorization: `Bearer ${authToken}`,
411
+ "Content-Type": "application/json"
412
+ },
413
+ body: JSON.stringify(
414
+ inboxEntry ? hookInboxDeliveryPayload(inboxEntry) : hookRequest
415
+ ),
416
+ signal: AbortSignal.timeout(
417
+ opts.timeoutMs ?? HOOK_REQUEST_TIMEOUT_MS
418
+ )
419
+ }
420
+ );
421
+ await response.arrayBuffer();
422
+ return response.ok || persisted;
423
+ } catch {
424
+ return persisted;
425
+ }
426
+ })();
427
+ const [captured, output2] = await Promise.all([
428
+ deliveryPromise,
429
+ contextPromise.catch(() => ({}))
430
+ ]);
431
+ return { captured, output: output2 };
432
+ }
433
+ async function runHookHandlerForOutput(target2, opts = {}) {
434
+ return (await processHookHandler(target2, opts, true)).output;
435
+ }
436
+
437
+ // src/hook-handler.ts
438
+ var target = parseHookTarget(process.argv[2]);
439
+ var output = target ? await runHookHandlerForOutput(target).catch(() => ({})) : {};
440
+ process.stdout.write(JSON.stringify(output));