@fml-inc/fml 0.8.0-rc.1 → 0.8.0-rc.3

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.
@@ -1,40 +1,280 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  hookCaptureIsDisabled
4
- } from "./chunk-I4GZPTBM.js";
4
+ } from "./chunk-ENW2GJNO.js";
5
5
  import {
6
6
  getActiveHookDestinationBinding
7
- } from "./chunk-F66IBM3Z.js";
7
+ } from "./chunk-DZKYW327.js";
8
8
  import {
9
- HOOK_TARGETS,
9
+ SHELL_TOOL_NAMES,
10
10
  createHookInboxEntry,
11
11
  hookInboxDeliveryPayload,
12
12
  normalizeObserverHookCapture,
13
13
  persistHookInboxEntry,
14
14
  readObserverAuthToken
15
- } from "./chunk-DEMVOVVY.js";
15
+ } from "./chunk-KBT5RMAU.js";
16
16
  import {
17
+ resolveRepositoryRootFromCwd,
17
18
  resolveRepositorySlugFromCwd
18
- } from "./chunk-T5YVLSUC.js";
19
+ } from "./chunk-7V7G7NVF.js";
19
20
  import {
21
+ FML_HOOK_CONTEXT_STATE_DIR,
20
22
  FML_HOOK_INBOX_DIR,
21
23
  FML_OBSERVER_AUTH_TOKEN_FILE,
22
- FML_OBSERVER_ENDPOINT_FILE
23
- } from "./chunk-7DR5X7XI.js";
24
+ FML_OBSERVER_ENDPOINT_FILE,
25
+ getValidToken
26
+ } from "./chunk-A6HAEVJX.js";
24
27
  import "./chunk-MLKGABMK.js";
25
28
 
26
29
  // src/hooks/handler.ts
30
+ import fs2 from "fs";
31
+
32
+ // src/hooks/context.ts
33
+ import { createHash } from "crypto";
27
34
  import fs from "fs";
35
+ import path from "path";
36
+ var HOOK_CONTEXT_ENDPOINT = "/v1/panopticon/hook-context";
37
+ var HOOK_CONTEXT_TIMEOUT_MS = 2e3;
38
+ var MAX_ADDITIONAL_CONTEXT_CHARS = 4e3;
39
+ var MAX_PROMPT_CHARS = 2e4;
40
+ var STATE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1e3;
41
+ var EDIT_TOOL_NAMES = /* @__PURE__ */ new Set([
42
+ "apply_patch",
43
+ "edit",
44
+ "multiedit",
45
+ "notebookedit",
46
+ "write",
47
+ "write_file"
48
+ ]);
49
+ var READ_TOOL_NAMES = /* @__PURE__ */ new Set(["read", "read_file"]);
50
+ function sameDestination(left, right) {
51
+ return left.environment === right.environment && left.targetUrl === right.targetUrl && left.destinationIdentity === right.destinationIdentity;
52
+ }
53
+ function markerPath(stateDir, kind, value) {
54
+ const digest = createHash("sha256").update(value).digest("hex");
55
+ return path.join(stateDir, kind, digest);
56
+ }
57
+ function ensureMarkerDirectory(filePath) {
58
+ try {
59
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 448 });
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+ function isSubsequentUserPrompt(sessionId, eventSyncId, stateDir = FML_HOOK_CONTEXT_STATE_DIR) {
66
+ const filePath = markerPath(stateDir, "prompt", sessionId);
67
+ if (!ensureMarkerDirectory(filePath)) return false;
68
+ try {
69
+ fs.writeFileSync(filePath, eventSyncId, { flag: "wx", mode: 384 });
70
+ return false;
71
+ } catch (error) {
72
+ if (error.code !== "EEXIST") return false;
73
+ try {
74
+ return fs.readFileSync(filePath, "utf8") !== eventSyncId;
75
+ } catch {
76
+ return false;
77
+ }
78
+ }
79
+ }
80
+ function pathContextWasEmitted(sessionId, filePath, stateDir) {
81
+ try {
82
+ return fs.existsSync(
83
+ markerPath(stateDir, "path", `${sessionId}:${filePath}`)
84
+ );
85
+ } catch {
86
+ return true;
87
+ }
88
+ }
89
+ function claimPathContext(sessionId, filePath, stateDir) {
90
+ const claim = markerPath(stateDir, "path", `${sessionId}:${filePath}`);
91
+ if (!ensureMarkerDirectory(claim)) return false;
92
+ try {
93
+ fs.writeFileSync(claim, "", { flag: "wx", mode: 384 });
94
+ return true;
95
+ } catch {
96
+ return false;
97
+ }
98
+ }
99
+ function pruneOldMarkers(directory, nowMs) {
100
+ if ((nowMs + process.pid) % 256 !== 0) return;
101
+ for (const kind of ["prompt", "path"]) {
102
+ const markerDirectory = path.join(directory, kind);
103
+ let names;
104
+ try {
105
+ names = fs.readdirSync(markerDirectory).slice(0, 1e3);
106
+ } catch {
107
+ continue;
108
+ }
109
+ for (const name of names) {
110
+ const filePath = path.join(markerDirectory, name);
111
+ try {
112
+ if (nowMs - fs.statSync(filePath).mtimeMs > STATE_MAX_AGE_MS) {
113
+ fs.rmSync(filePath, { force: true });
114
+ }
115
+ } catch {
116
+ }
117
+ }
118
+ }
119
+ }
120
+ function canonicalFilePath(filePath, cwd, repositoryRoot) {
121
+ const windows = [filePath, cwd, repositoryRoot].some(
122
+ (value) => value !== void 0 && value !== null && /^[A-Za-z]:[\\/]/.test(value)
123
+ );
124
+ const pathApi = windows ? path.win32 : path.posix;
125
+ const normalized = pathApi.normalize(filePath);
126
+ const resolved = pathApi.isAbsolute(normalized) ? normalized : cwd && pathApi.isAbsolute(cwd) ? pathApi.resolve(cwd, normalized) : normalized;
127
+ if (repositoryRoot && pathApi.isAbsolute(resolved)) {
128
+ const relative2 = pathApi.relative(
129
+ pathApi.normalize(repositoryRoot),
130
+ resolved
131
+ );
132
+ if (relative2 && relative2 !== ".." && !relative2.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative2)) {
133
+ return relative2.replace(/\\/g, "/");
134
+ }
135
+ }
136
+ if (!pathApi.isAbsolute(resolved)) {
137
+ return resolved.replace(/^[.][\\/]/, "").replace(/\\/g, "/");
138
+ }
139
+ if (!cwd || pathApi.isAbsolute(cwd) !== pathApi.isAbsolute(resolved)) {
140
+ return resolved.replace(/\\/g, "/");
141
+ }
142
+ const relative = pathApi.relative(pathApi.normalize(cwd), resolved);
143
+ if (relative && relative !== ".." && !relative.startsWith(`..${pathApi.sep}`) && !pathApi.isAbsolute(relative)) {
144
+ return relative.replace(/\\/g, "/");
145
+ }
146
+ return resolved.replace(/\\/g, "/");
147
+ }
148
+ function buildServerHookContextRequest(target2, event, stateDir = FML_HOOK_CONTEXT_STATE_DIR, repositoryRoot = null) {
149
+ if (target2 !== "claude" && target2 !== "codex") return null;
150
+ if (!event.repository) return null;
151
+ const markerSessionId = `${target2}:${event.sessionId}`;
152
+ const base = {
153
+ repository: event.repository,
154
+ sessionId: event.sessionId,
155
+ nowMs: event.timestampMs
156
+ };
157
+ if (event.eventType === "SessionStart") {
158
+ return { ...base, eventType: "SessionStart" };
159
+ }
160
+ if (event.eventType === "UserPromptSubmit" && event.userPrompt) {
161
+ if (!isSubsequentUserPrompt(markerSessionId, event.syncId, stateDir)) {
162
+ return null;
163
+ }
164
+ return {
165
+ ...base,
166
+ eventType: "UserPromptSubmit",
167
+ prompt: event.userPrompt.slice(0, MAX_PROMPT_CHARS)
168
+ };
169
+ }
170
+ if (event.eventType !== "PreToolUse" || !event.toolName) {
171
+ return null;
172
+ }
173
+ const toolName = event.toolName.toLowerCase();
174
+ const fileOperation = EDIT_TOOL_NAMES.has(toolName) ? "edit" : READ_TOOL_NAMES.has(toolName) || SHELL_TOOL_NAMES.has(toolName) ? "read" : null;
175
+ if (!fileOperation) return null;
176
+ if (!event.filePath) return null;
177
+ const filePath = canonicalFilePath(event.filePath, event.cwd, repositoryRoot);
178
+ if (pathContextWasEmitted(
179
+ markerSessionId,
180
+ `${event.repository}:${filePath}`,
181
+ stateDir
182
+ )) {
183
+ return null;
184
+ }
185
+ return { ...base, eventType: "PreToolUse", filePath, fileOperation };
186
+ }
187
+ function hookOutput(request, additionalContext) {
188
+ return {
189
+ hookSpecificOutput: {
190
+ hookEventName: request.eventType,
191
+ additionalContext
192
+ }
193
+ };
194
+ }
195
+ async function requestServerHookContext(target2, event, destination, opts = {}) {
196
+ const deadlineSignal = AbortSignal.timeout(
197
+ opts.timeoutMs ?? HOOK_CONTEXT_TIMEOUT_MS
198
+ );
199
+ const stateDir = opts.stateDir ?? FML_HOOK_CONTEXT_STATE_DIR;
200
+ pruneOldMarkers(stateDir, Date.now());
201
+ const repositoryRoot = event.eventType === "PreToolUse" && event.cwd ? (opts.resolveRepositoryRoot ?? ((cwd) => resolveRepositoryRootFromCwd(cwd, 250)))(event.cwd) : null;
202
+ const request = buildServerHookContextRequest(
203
+ target2,
204
+ event,
205
+ stateDir,
206
+ repositoryRoot
207
+ );
208
+ if (!request) return {};
209
+ try {
210
+ const token = await getValidToken({
211
+ env: destination.environment,
212
+ signal: deadlineSignal
213
+ });
214
+ if (!token) return {};
215
+ const currentDestination = getActiveHookDestinationBinding();
216
+ if (!currentDestination || !sameDestination(destination, currentDestination)) {
217
+ return {};
218
+ }
219
+ const response = await (opts.fetch ?? fetch)(
220
+ `${destination.targetUrl.replace(/\/+$/, "")}${HOOK_CONTEXT_ENDPOINT}`,
221
+ {
222
+ method: "POST",
223
+ headers: {
224
+ Authorization: token.startsWith("Bearer ") ? token : `Bearer ${token}`,
225
+ "Content-Type": "application/json"
226
+ },
227
+ body: JSON.stringify(request),
228
+ signal: deadlineSignal
229
+ }
230
+ );
231
+ if (!response.ok) return {};
232
+ const body = await response.json();
233
+ if (!body || typeof body !== "object" || Array.isArray(body)) return {};
234
+ const additionalContext = body.additionalContext;
235
+ if (request.eventType === "PreToolUse" && request.filePath && additionalContext === null) {
236
+ claimPathContext(
237
+ `${target2}:${request.sessionId}`,
238
+ `${request.repository}:${request.filePath}`,
239
+ stateDir
240
+ );
241
+ return {};
242
+ }
243
+ if (typeof additionalContext !== "string" || additionalContext.length === 0 || additionalContext.length > MAX_ADDITIONAL_CONTEXT_CHARS) {
244
+ return {};
245
+ }
246
+ if (request.eventType === "PreToolUse" && request.filePath && !claimPathContext(
247
+ `${target2}:${request.sessionId}`,
248
+ `${request.repository}:${request.filePath}`,
249
+ stateDir
250
+ )) {
251
+ return {};
252
+ }
253
+ return hookOutput(request, additionalContext);
254
+ } catch {
255
+ return {};
256
+ }
257
+ }
258
+
259
+ // src/hooks/handler.ts
28
260
  var MAX_HOOK_STDIN_BYTES = 4 * 1024 * 1024;
29
261
  var HOOK_REQUEST_TIMEOUT_MS = 500;
30
262
  var HOOK_STDIN_TIMEOUT_MS = 5e3;
31
263
  var HOOK_REPO_RESOLUTION_TIMEOUT_MS = 250;
264
+ var INSTALLED_HOOK_TARGETS = Object.freeze([
265
+ "claude",
266
+ "codex",
267
+ "gemini",
268
+ "grok"
269
+ ]);
32
270
  function parseHookTarget(value) {
33
- return HOOK_TARGETS.includes(value) ? value : null;
271
+ return INSTALLED_HOOK_TARGETS.includes(
272
+ value
273
+ ) ? value : null;
34
274
  }
35
275
  function observerEndpoint(endpointFile) {
36
276
  try {
37
- const parsed = JSON.parse(fs.readFileSync(endpointFile, "utf8"));
277
+ const parsed = JSON.parse(fs2.readFileSync(endpointFile, "utf8"));
38
278
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
39
279
  return null;
40
280
  }
@@ -97,23 +337,25 @@ async function readBoundedInput(input, timeoutMs) {
97
337
  input.resume();
98
338
  });
99
339
  }
100
- async function runHookHandler(target2, opts = {}) {
340
+ async function processHookHandler(target2, opts, includeContext) {
101
341
  const input = opts.input ?? process.stdin;
102
342
  const bytes = await readBoundedInput(
103
343
  input,
104
344
  opts.inputTimeoutMs ?? HOOK_STDIN_TIMEOUT_MS
105
345
  );
106
- if (!bytes || bytes.byteLength === 0) return false;
346
+ if (!bytes || bytes.byteLength === 0) return { captured: false, output: {} };
107
347
  let payload;
108
348
  try {
109
349
  payload = JSON.parse(bytes.toString("utf8"));
110
350
  } catch {
111
- return false;
351
+ return { captured: false, output: {} };
112
352
  }
113
353
  if (!payload || typeof payload !== "object" || Array.isArray(payload)) {
114
- return false;
354
+ return { captured: false, output: {} };
355
+ }
356
+ if (hookCaptureIsDisabled(opts.captureDisabledFile)) {
357
+ return { captured: false, output: {} };
115
358
  }
116
- if (hookCaptureIsDisabled(opts.captureDisabledFile)) return false;
117
359
  const cwd = opts.cwd ?? process.cwd();
118
360
  const hookRequest = {
119
361
  target: target2,
@@ -134,8 +376,8 @@ async function runHookHandler(target2, opts = {}) {
134
376
  const inboxDir = opts.inboxDir ?? FML_HOOK_INBOX_DIR;
135
377
  let inboxEntry;
136
378
  let persisted = false;
379
+ let destination = null;
137
380
  if (event) {
138
- let destination = null;
139
381
  try {
140
382
  destination = getActiveHookDestinationBinding();
141
383
  } catch {
@@ -151,40 +393,55 @@ async function runHookHandler(target2, opts = {}) {
151
393
  } catch {
152
394
  }
153
395
  }
396
+ const contextPromise = includeContext && event && destination ? opts.requestContext ? opts.requestContext(target2, event, destination) : requestServerHookContext(target2, event, destination, {
397
+ ...opts.contextFetch ? { fetch: opts.contextFetch } : {},
398
+ ...opts.contextTimeoutMs === void 0 ? {} : { timeoutMs: opts.contextTimeoutMs },
399
+ ...opts.contextStateDir ? { stateDir: opts.contextStateDir } : {}
400
+ }) : Promise.resolve({});
154
401
  const endpoint = observerEndpoint(
155
402
  opts.endpointFile ?? FML_OBSERVER_ENDPOINT_FILE
156
403
  );
157
404
  const authToken = readObserverAuthToken(
158
405
  opts.authTokenFile ?? FML_OBSERVER_AUTH_TOKEN_FILE
159
406
  );
160
- if (!endpoint || !authToken) return persisted;
161
- const host = endpoint.host === "::1" ? "[::1]" : endpoint.host;
162
- const fetchRequest = opts.fetch ?? fetch;
163
- try {
164
- const response = await fetchRequest(
165
- `http://${host}:${endpoint.port}/hooks`,
166
- {
167
- method: "POST",
168
- headers: {
169
- Authorization: `Bearer ${authToken}`,
170
- "Content-Type": "application/json"
171
- },
172
- body: JSON.stringify(
173
- inboxEntry ? hookInboxDeliveryPayload(inboxEntry) : hookRequest
174
- ),
175
- signal: AbortSignal.timeout(opts.timeoutMs ?? HOOK_REQUEST_TIMEOUT_MS)
176
- }
177
- );
178
- await response.arrayBuffer();
179
- return response.ok || persisted;
180
- } catch {
181
- return persisted;
182
- }
407
+ const deliveryPromise = (async () => {
408
+ if (!endpoint || !authToken) return persisted;
409
+ const host = endpoint.host === "::1" ? "[::1]" : endpoint.host;
410
+ const fetchRequest = opts.fetch ?? fetch;
411
+ try {
412
+ const response = await fetchRequest(
413
+ `http://${host}:${endpoint.port}/hooks`,
414
+ {
415
+ method: "POST",
416
+ headers: {
417
+ Authorization: `Bearer ${authToken}`,
418
+ "Content-Type": "application/json"
419
+ },
420
+ body: JSON.stringify(
421
+ inboxEntry ? hookInboxDeliveryPayload(inboxEntry) : hookRequest
422
+ ),
423
+ signal: AbortSignal.timeout(
424
+ opts.timeoutMs ?? HOOK_REQUEST_TIMEOUT_MS
425
+ )
426
+ }
427
+ );
428
+ await response.arrayBuffer();
429
+ return response.ok || persisted;
430
+ } catch {
431
+ return persisted;
432
+ }
433
+ })();
434
+ const [captured, output2] = await Promise.all([
435
+ deliveryPromise,
436
+ contextPromise.catch(() => ({}))
437
+ ]);
438
+ return { captured, output: output2 };
439
+ }
440
+ async function runHookHandlerForOutput(target2, opts = {}) {
441
+ return (await processHookHandler(target2, opts, true)).output;
183
442
  }
184
443
 
185
444
  // src/hook-handler.ts
186
445
  var target = parseHookTarget(process.argv[2]);
187
- if (target) {
188
- await runHookHandler(target).catch(() => false);
189
- }
190
- process.stdout.write("{}");
446
+ var output = target ? await runHookHandlerForOutput(target).catch(() => ({})) : {};
447
+ process.stdout.write(JSON.stringify(output));
@@ -2,10 +2,10 @@
2
2
  import {
3
3
  createFmlClient,
4
4
  getAuthenticatedClient
5
- } from "../chunk-P3QLARXE.js";
5
+ } from "../chunk-R45CWWZO.js";
6
6
  import {
7
7
  resolveRepoFromCwd
8
- } from "../chunk-T5YVLSUC.js";
8
+ } from "../chunk-7V7G7NVF.js";
9
9
  import {
10
10
  FML_DATA_DIR,
11
11
  FML_LOG_DIR,
@@ -17,7 +17,7 @@ import {
17
17
  getValidToken,
18
18
  initSentry,
19
19
  readTokens
20
- } from "../chunk-7DR5X7XI.js";
20
+ } from "../chunk-A6HAEVJX.js";
21
21
  import "../chunk-MLKGABMK.js";
22
22
 
23
23
  // src/mcp/server.ts
@@ -75,15 +75,15 @@ function registerTools(server2, environment = getActiveEnv()) {
75
75
  const registerSessionTimelineTool = () => {
76
76
  server2.tool(
77
77
  "get_session_timeline",
78
- 'Get chronological timeline for a coding agent session. Returns messages and/or hook events interleaved by timestamp. Use source="hooks" to see permission requests, tool approvals, etc. REQUIRES: sessionId from list_engineering_sessions.',
78
+ 'Get chronological timeline for a coding agent session. Returns messages, hook events, and transcript-derived scanner events interleaved by timestamp. Use source="events" for the scanner event stream (thinking, tool_call, permission_mode, stop_hook_summary, ...); source="hooks" only returns live hook-capture events and is empty for scanner-only sessions. REQUIRES: sessionId from list_engineering_sessions.',
79
79
  {
80
80
  sessionId: z.string().describe("The session ID to get details for."),
81
81
  includeSystemMessages: z.boolean().optional().describe("Include system messages (default false)."),
82
- source: z.enum(["messages", "hooks", "all"]).optional().describe(
83
- 'Filter by source: "messages" (scanner messages only), "hooks" (hook events only), "all" (both interleaved). Default "all".'
82
+ source: z.enum(["messages", "hooks", "events", "all"]).optional().describe(
83
+ 'Filter by source: "messages" (scanner messages only), "hooks" (live hook-capture events only), "events" (transcript-derived scanner events), "all" (everything interleaved). Default "all".'
84
84
  ),
85
85
  eventType: z.string().optional().describe(
86
- 'Filter hook events by type (e.g. "PermissionRequest", "PreToolUse", "Stop"). Only applies when source includes hooks.'
86
+ 'Filter hook/scanner events by type (hook: "PermissionRequest", "PreToolUse", "Stop"; scanner: "tool_call", "permission_mode", "stop_hook_summary"). Only applies when source includes hooks or events.'
87
87
  ),
88
88
  limit: z.number().optional().describe("Max entries to return (default 50, max 200)."),
89
89
  offset: z.number().optional().describe("Skip first N entries for pagination.")
@@ -6,11 +6,11 @@ import {
6
6
  observerLeaseIsOwned,
7
7
  removeObserverStateIfOwned,
8
8
  updateObserverState
9
- } from "./chunk-HUR2HCUG.js";
9
+ } from "./chunk-7DWFJENY.js";
10
10
  import {
11
11
  createHookSyncLoop,
12
12
  getActiveHookDestinationBinding
13
- } from "./chunk-F66IBM3Z.js";
13
+ } from "./chunk-DZKYW327.js";
14
14
  import {
15
15
  OBSERVER_PROTOCOL_VERSION,
16
16
  OBSERVER_SERVICE_NAME,
@@ -25,15 +25,15 @@ import {
25
25
  parseHookInboxMetadata,
26
26
  readHookInboxStatusAsync,
27
27
  retireHookInboxEntry
28
- } from "./chunk-DEMVOVVY.js";
29
- import "./chunk-T5YVLSUC.js";
28
+ } from "./chunk-KBT5RMAU.js";
29
+ import "./chunk-7V7G7NVF.js";
30
30
  import {
31
31
  ObserverSyncCycleAbortedError,
32
32
  runObserverSyncCycleSubprocess
33
- } from "./chunk-CK5MEL5J.js";
33
+ } from "./chunk-3KISRT57.js";
34
34
  import {
35
35
  createCliRawSync
36
- } from "./chunk-JXSSPQ5B.js";
36
+ } from "./chunk-YSU5CS2V.js";
37
37
  import {
38
38
  FML_OBSERVER_AUTH_TOKEN_FILE,
39
39
  FML_OBSERVER_ENDPOINT_FILE,
@@ -50,7 +50,7 @@ import {
50
50
  getSyncDestinationAuthBinding,
51
51
  getValidToken,
52
52
  observerOnlyOverrideWarning
53
- } from "./chunk-7DR5X7XI.js";
53
+ } from "./chunk-A6HAEVJX.js";
54
54
  import "./chunk-MLKGABMK.js";
55
55
 
56
56
  // src/observer-server.ts
@@ -1239,7 +1239,7 @@ var hookRejectionStatus = () => ({
1239
1239
  ...lastRejectedReason !== void 0 ? { lastRejectedReason } : {},
1240
1240
  ...lastRejectedTarget !== void 0 ? { lastRejectedTarget } : {}
1241
1241
  });
1242
- var packageVersion = true ? "0.8.0-rc.1+60c9b7e1" : "dev";
1242
+ var packageVersion = true ? "0.8.0-rc.3+246af775" : "dev";
1243
1243
  var health = () => ({
1244
1244
  status: "ok",
1245
1245
  service: OBSERVER_SERVICE_NAME,