@tpsdev-ai/flair-mcp 0.56.0 → 0.58.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/README.md +5 -5
- package/dist/adapter-tools.js +2 -1
- package/dist/continuity-capture-hook.d.ts +7 -0
- package/dist/continuity-capture-hook.js +3 -2
- package/dist/continuity.d.ts +69 -0
- package/dist/continuity.js +132 -14
- package/dist/env-guard.d.ts +2 -1
- package/dist/env-guard.js +2 -1
- package/dist/precompact-hook.d.ts +174 -0
- package/dist/precompact-hook.js +418 -0
- package/dist/precompact.d.ts +404 -0
- package/dist/precompact.js +895 -0
- package/dist/prompt-recall-hook.d.ts +289 -0
- package/dist/prompt-recall-hook.js +651 -0
- package/dist/record-id-path.d.ts +20 -0
- package/dist/record-id-path.js +28 -0
- package/dist/session-start-hook.d.ts +29 -6
- package/dist/session-start-hook.js +104 -11
- package/dist/tool-descriptors/index.js +15 -15
- package/package.json +6 -4
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Flair PreCompact hook for Claude Code (flair#2069): when the transcript
|
|
4
|
+
* tail holds something to record and the local checks pass, attempt one PUT
|
|
5
|
+
* of a bounded continuity record before context is lost (to a new row, or,
|
|
6
|
+
* within the dedup window, to the same row), so the next session start can
|
|
7
|
+
* show it first. The record's
|
|
8
|
+
* content, bounds, redaction, storage and dedup are defined in
|
|
9
|
+
* ./precompact.ts; this file is the binary around them.
|
|
10
|
+
*
|
|
11
|
+
* PER RUN
|
|
12
|
+
* -------
|
|
13
|
+
* 1. Reads Claude Code's PreCompact payload on stdin: `hook_event_name`
|
|
14
|
+
* ("PreCompact"), `session_id`, `transcript_path` and `trigger`
|
|
15
|
+
* ("manual" for /compact, "auto" for automatic compaction).
|
|
16
|
+
* 2. Finds this harness session's continuity state (seeded by
|
|
17
|
+
* flair-session-start) and consumes a journal seq, as
|
|
18
|
+
* flair-continuity-capture does, through the bounded asynchronous
|
|
19
|
+
* bumpSeqBounded.
|
|
20
|
+
* 3. Reads the transcript TAIL (bounded in bytes and lines) and builds the
|
|
21
|
+
* record: standing instructions, open tasks, in-flight work and the last
|
|
22
|
+
* assistant message, redacted and cut to the record bound. Nothing to
|
|
23
|
+
* record: nothing written.
|
|
24
|
+
* 4. Resolves the record id through the local marker (a later run for the
|
|
25
|
+
* same harness session and trigger within the dedup window reuses it),
|
|
26
|
+
* writes the marker, then writes the row with one signed
|
|
27
|
+
* `PUT /Memory/<id>` as the agent's own Ed25519 identity.
|
|
28
|
+
*
|
|
29
|
+
* EXIT 0 ON EVERY PATH IT HANDLES
|
|
30
|
+
* -------------------------------
|
|
31
|
+
* Claude Code blocks compaction when a PreCompact hook exits 2 or prints a
|
|
32
|
+
* `decision: "block"` object. Once this binary has started, it does neither
|
|
33
|
+
* on any path it handles: it exits 0 and prints either nothing or ONE
|
|
34
|
+
* `{"systemMessage": …}` object (a warning Claude Code shows the user). A
|
|
35
|
+
* failure before that (the launcher, the runtime's start-up, a static import
|
|
36
|
+
* that fails to load) is outside the binary; the documented command's
|
|
37
|
+
* `|| true` covers it. The time budget (FLAIR_PRECOMPACT_TIMEOUT_MS,
|
|
38
|
+
* default 5 s) is a process-level deadline armed in main(), after the module
|
|
39
|
+
* has loaded and the entry-point check (isDirectRun) has run, and before
|
|
40
|
+
* stdin is read. When it passes, the timer starts finishing whatever
|
|
41
|
+
* asynchronous work is still pending (stdin held open, a slow read, a write
|
|
42
|
+
* in flight): the hook prints the one timeout note when FLAIR_AGENT_ID is
|
|
43
|
+
* set, and nothing when it is not (main() decides this when it arms the
|
|
44
|
+
* deadline), then exits 0 once stdout drains, waiting at most a further
|
|
45
|
+
* STDOUT_DRAIN_GRACE_MS (1 s).
|
|
46
|
+
* Synchronous work can delay the timer itself; Claude Code's hook timeout is
|
|
47
|
+
* the outer bound. stdin is read up
|
|
48
|
+
* to STDIN_MAX_BYTES; a larger payload is ignored. The hook's own local files
|
|
49
|
+
* (the transcript, the continuity state file and the marker) are read
|
|
50
|
+
* asynchronously with a size cap checked (fstat) before any byte is read: the
|
|
51
|
+
* transcript by its tail caps, the state file and the marker by
|
|
52
|
+
* SESSION_FILE_MAX_BYTES. A state file or marker larger than that, or anything
|
|
53
|
+
* at those three paths that is not a regular file, is refused with a note.
|
|
54
|
+
* Its local writes are asynchronous too, so none of the hook's own file work
|
|
55
|
+
* can hold the process past the deadline.
|
|
56
|
+
* The deadline bounds asynchronous work only: a timer cannot preempt
|
|
57
|
+
* synchronous code, and one local read is synchronous and not this hook's
|
|
58
|
+
* own: flair-client reads the agent's key file synchronously while the client
|
|
59
|
+
* is built, outside the size caps above. The outer bound on the whole process
|
|
60
|
+
* is Claude Code's own hook `timeout` in the settings entry. What runs before
|
|
61
|
+
* main() arms the deadline (the launcher, the runtime's start-up, module
|
|
62
|
+
* loading, the entry-point check) is outside the budget too.
|
|
63
|
+
*
|
|
64
|
+
* NOTES (the only output)
|
|
65
|
+
* -----------------------
|
|
66
|
+
* Silent: a probe, a malformed or non-PreCompact payload, no FLAIR_AGENT_ID,
|
|
67
|
+
* a tail with nothing to record, and success. One note, naming the reason: no
|
|
68
|
+
* continuity state for the session, a state file that could not be read (too
|
|
69
|
+
* large, not a regular file, malformed) or updated, an unreadable transcript,
|
|
70
|
+
* a marker that could not be read or written, and a write that was not
|
|
71
|
+
* confirmed (named by its kind, from classifyPreCompactFailure's three
|
|
72
|
+
* inputs: a numeric HTTP status, 401 or 403 as auth and any other as
|
|
73
|
+
* http-<status>; the hook's own timer or an error named exactly TimeoutError,
|
|
74
|
+
* as timeout; anything else as unreachable. Never by an error's message text
|
|
75
|
+
* or URL. Worded as "may be missing", never as "not saved", because the
|
|
76
|
+
* server may have applied it). A note that names a state file or the marker
|
|
77
|
+
* shows its path through notePath, which changes it only where each rule
|
|
78
|
+
* applies (see notePath).
|
|
79
|
+
*
|
|
80
|
+
* IDENTITY
|
|
81
|
+
* --------
|
|
82
|
+
* The agent's own key, resolved like the other hooks (FLAIR_AGENT_ID +
|
|
83
|
+
* FLAIR_KEY_PATH or the standard key locations). The client is built with an
|
|
84
|
+
* empty admin pair, which turns off flair-client's FLAIR_ADMIN_USER /
|
|
85
|
+
* FLAIR_ADMIN_PASSWORD Basic fallback: the record is written as the agent or
|
|
86
|
+
* not at all.
|
|
87
|
+
*
|
|
88
|
+
* CONFIG (env)
|
|
89
|
+
* FLAIR_AGENT_ID (required; absent → silent no-op)
|
|
90
|
+
* FLAIR_URL (default via flair-client)
|
|
91
|
+
* FLAIR_KEY_PATH (default ~/.flair/keys/<agent>.key via flair-client)
|
|
92
|
+
* FLAIR_PRECOMPACT_TIMEOUT_MS (default 5000; 250..15000)
|
|
93
|
+
* FLAIR_SESSION_DIR (default ~/.flair/session; tests)
|
|
94
|
+
* FLAIR_HOOK_PROBE (probe mode: exit 0 before stdin, files or network)
|
|
95
|
+
*/
|
|
96
|
+
import { realpathSync } from "node:fs";
|
|
97
|
+
import { homedir } from "node:os";
|
|
98
|
+
import { fileURLToPath } from "node:url";
|
|
99
|
+
import { isProbeMode, readEnvOrUnset, stripInterpolationLiteralsFromEnv } from "./env-guard.js";
|
|
100
|
+
import { memoryPutPath } from "./record-id-path.js";
|
|
101
|
+
import { bumpSeqBounded, isSafeFileId, resolveSessionDir, statePath } from "./continuity.js";
|
|
102
|
+
import { PRECOMPACT_HOOK, buildPreCompactContent, buildPreCompactRow, cutTo, extractFromTranscript, normalizeTrigger, precompactMarkerPath, readPreCompactMarker, readTranscriptTail, redactSecrets, resolvePreCompactRecordId, writePreCompactMarker, } from "./precompact.js";
|
|
103
|
+
export const ENV_PRECOMPACT_TIMEOUT_MS = "FLAIR_PRECOMPACT_TIMEOUT_MS";
|
|
104
|
+
export const DEFAULT_PRECOMPACT_TIMEOUT_MS = 5000;
|
|
105
|
+
export const PRECOMPACT_TIMEOUT_FLOOR_MS = 250;
|
|
106
|
+
export const PRECOMPACT_TIMEOUT_CEILING_MS = 15_000;
|
|
107
|
+
/** Upper bound on the hook's stdin (the PreCompact payload is a few hundred bytes). */
|
|
108
|
+
export const STDIN_MAX_BYTES = 256 * 1024;
|
|
109
|
+
/** The process-level budget (armed in main(), before stdin is read; when it passes, finishing starts and the process exits once stdout drains, at most STDOUT_DRAIN_GRACE_MS later):
|
|
110
|
+
* FLAIR_PRECOMPACT_TIMEOUT_MS when in range, else the default. */
|
|
111
|
+
export function resolvePreCompactBudgetMs(env = process.env) {
|
|
112
|
+
const raw = readEnvOrUnset(ENV_PRECOMPACT_TIMEOUT_MS, env);
|
|
113
|
+
const n = raw != null && raw.trim() !== "" ? Number(raw) : NaN;
|
|
114
|
+
return Number.isFinite(n) && n >= PRECOMPACT_TIMEOUT_FLOOR_MS && n <= PRECOMPACT_TIMEOUT_CEILING_MS
|
|
115
|
+
? n
|
|
116
|
+
: DEFAULT_PRECOMPACT_TIMEOUT_MS;
|
|
117
|
+
}
|
|
118
|
+
// ── failure → one note ──────────────────────────────────────────────────────
|
|
119
|
+
/** The hook's OWN budget timer. Recognized by identity, never by message. */
|
|
120
|
+
export class PreCompactTimeoutError extends Error {
|
|
121
|
+
constructor() {
|
|
122
|
+
super("precompact timeout");
|
|
123
|
+
this.name = "PreCompactTimeoutError";
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Same rules as the session-start classifier (flair#1943): a numeric HTTP
|
|
127
|
+
* status first (401/403 → auth), then the hook's own timer or an error named
|
|
128
|
+
* exactly TimeoutError, else unreachable. Message text is never read. */
|
|
129
|
+
export function classifyPreCompactFailure(err) {
|
|
130
|
+
const e = err;
|
|
131
|
+
for (const key of ["status", "status_code", "statusCode"]) {
|
|
132
|
+
const v = e?.[key];
|
|
133
|
+
if (typeof v === "number" && Number.isFinite(v))
|
|
134
|
+
return v === 401 || v === 403 ? "auth" : `http-${v}`;
|
|
135
|
+
}
|
|
136
|
+
if (err instanceof PreCompactTimeoutError)
|
|
137
|
+
return "timeout";
|
|
138
|
+
if (typeof e?.name === "string" && e.name === "TimeoutError")
|
|
139
|
+
return "timeout";
|
|
140
|
+
return "unreachable";
|
|
141
|
+
}
|
|
142
|
+
/** The hook's only non-empty output: ONE JSON object with a `systemMessage`
|
|
143
|
+
* (shown to the user). Never a `decision` field: that could block compaction. */
|
|
144
|
+
export function preCompactNote(text) {
|
|
145
|
+
return JSON.stringify({ systemMessage: text });
|
|
146
|
+
}
|
|
147
|
+
/** The note for a write that was not confirmed. It never says the record
|
|
148
|
+
* was not saved: after a timeout the server may still complete the write,
|
|
149
|
+
* and after any other error the server may already have applied it (the
|
|
150
|
+
* client can fail while reading or parsing the answer). What is known is
|
|
151
|
+
* that the save was not confirmed, so the record may be missing. */
|
|
152
|
+
export function writeFailedNote(kind) {
|
|
153
|
+
const what = kind === "timeout"
|
|
154
|
+
? "saving the pre-compaction continuity record did not finish in time (timeout), so it may be missing"
|
|
155
|
+
: `saving the pre-compaction continuity record could not be confirmed (${kind}), so it may be missing`;
|
|
156
|
+
return preCompactNote(`Flair: ${what}; compaction goes ahead. Check Flair with \`flair doctor\`.`);
|
|
157
|
+
}
|
|
158
|
+
function withTimeout(promise, ms) {
|
|
159
|
+
return new Promise((resolve, reject) => {
|
|
160
|
+
const timer = setTimeout(() => reject(new PreCompactTimeoutError()), ms);
|
|
161
|
+
timer.unref?.();
|
|
162
|
+
promise.then((value) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
resolve(value);
|
|
165
|
+
}, (err) => {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
reject(err);
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
/** The longest local path a note shows, in characters. */
|
|
172
|
+
export const NOTE_PATH_MAX_CHARS = 200;
|
|
173
|
+
/**
|
|
174
|
+
* A local path as a note shows it. Each change applies only where it fits:
|
|
175
|
+
* - a path inside the home directory (`env.HOME`, else the OS's) starts
|
|
176
|
+
* with "~" instead; a home directory of "/" collapses nothing;
|
|
177
|
+
* - each control or line-break character is shown as "?";
|
|
178
|
+
* - strings that match the credential patterns are redacted
|
|
179
|
+
* (redactSecrets);
|
|
180
|
+
* - a result longer than NOTE_PATH_MAX_CHARS is cut to that, ending in "…".
|
|
181
|
+
* A path outside the home directory that none of these touch is shown as it
|
|
182
|
+
* is. The session directory and the agent id in the path are configuration;
|
|
183
|
+
* a secret in them that matches no pattern is shown as written.
|
|
184
|
+
*/
|
|
185
|
+
export function notePath(path, env = process.env) {
|
|
186
|
+
const home = (env.HOME || homedir()).replace(/\/+$/, "");
|
|
187
|
+
let shown = path;
|
|
188
|
+
if (home !== "" && (path === home || path.startsWith(`${home}/`)))
|
|
189
|
+
shown = `~${path.slice(home.length)}`;
|
|
190
|
+
shown = shown.replace(/[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, "?");
|
|
191
|
+
return cutTo(redactSecrets(shown), NOTE_PATH_MAX_CHARS);
|
|
192
|
+
}
|
|
193
|
+
/** LAZY for the same reason as ./continuity-capture-hook.ts's factory: the root
|
|
194
|
+
* test lane imports this module before flair-client is built. Tests inject
|
|
195
|
+
* makeClient; only the real binary takes this path. */
|
|
196
|
+
async function defaultClientFactory(agentId, timeoutMs, env) {
|
|
197
|
+
// @ts-ignore -- resolvable only once flair-client's dist is built; see ./continuity-capture-hook.ts
|
|
198
|
+
const mod = await import("@tpsdev-ai/flair-client");
|
|
199
|
+
const FlairClient = mod.FlairClient;
|
|
200
|
+
return new FlairClient({
|
|
201
|
+
agentId,
|
|
202
|
+
url: readEnvOrUnset("FLAIR_URL", env),
|
|
203
|
+
keyPath: readEnvOrUnset("FLAIR_KEY_PATH", env),
|
|
204
|
+
timeoutMs,
|
|
205
|
+
adminUser: "",
|
|
206
|
+
adminPassword: "",
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Core flow with injectable dependencies. Never throws for a failed read or
|
|
211
|
+
* write; returns the exact output plus a diagnostic reason.
|
|
212
|
+
*/
|
|
213
|
+
export async function runPreCompact(rawInput, deps = {}) {
|
|
214
|
+
const startedAt = deps.startedAt ?? Date.now();
|
|
215
|
+
const env = deps.env ?? process.env;
|
|
216
|
+
const now = deps.now ?? (() => new Date());
|
|
217
|
+
const budgetMs = deps.budgetMs ?? resolvePreCompactBudgetMs(env);
|
|
218
|
+
let input;
|
|
219
|
+
try {
|
|
220
|
+
const parsed = JSON.parse(rawInput);
|
|
221
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed))
|
|
222
|
+
return { output: "", reason: "malformed-input" };
|
|
223
|
+
input = parsed;
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return { output: "", reason: "malformed-input" };
|
|
227
|
+
}
|
|
228
|
+
if (input.hook_event_name !== PRECOMPACT_HOOK)
|
|
229
|
+
return { output: "", reason: "not-precompact" };
|
|
230
|
+
const agentId = readEnvOrUnset("FLAIR_AGENT_ID", env);
|
|
231
|
+
if (!agentId || !isSafeFileId(agentId))
|
|
232
|
+
return { output: "", reason: "no-agent-id" };
|
|
233
|
+
const harnessSessionId = input.session_id;
|
|
234
|
+
if (!isSafeFileId(harnessSessionId))
|
|
235
|
+
return { output: "", reason: "bad-session-id" };
|
|
236
|
+
const trigger = normalizeTrigger(input.trigger);
|
|
237
|
+
// The session's continuity state, seeded by flair-session-start. Consuming a
|
|
238
|
+
// seq orders the record inside the session's journal. The read is
|
|
239
|
+
// asynchronous and size-capped before any byte is read, so a large or odd
|
|
240
|
+
// file at the state path cannot hold the process past its deadline; a state
|
|
241
|
+
// file that exists but cannot be used is reported as such, never as absent.
|
|
242
|
+
const sessionDir = deps.sessionDir ?? resolveSessionDir(env);
|
|
243
|
+
const bumped = await bumpSeqBounded(sessionDir, agentId, harnessSessionId, now());
|
|
244
|
+
if (bumped.kind === "absent") {
|
|
245
|
+
return {
|
|
246
|
+
output: preCompactNote("Flair: no continuity state for this session, so no pre-compaction record was saved. flair-session-start creates it when a session starts."),
|
|
247
|
+
reason: "no-state",
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
if (bumped.kind === "unreadable" || bumped.kind === "unwritable") {
|
|
251
|
+
const path = notePath(statePath(sessionDir, agentId, harnessSessionId), env);
|
|
252
|
+
return {
|
|
253
|
+
output: preCompactNote(bumped.kind === "unreadable"
|
|
254
|
+
? `Flair: the continuity state file ${path} could not be read (${bumped.detail}), so no pre-compaction record was saved. Remove that file to reset it; flair-session-start recreates it when a session starts.`
|
|
255
|
+
: `Flair: the continuity state file ${path} could not be updated, so no pre-compaction record was saved.`),
|
|
256
|
+
reason: bumped.kind === "unreadable" ? "state-unreadable" : "state-unwritable",
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
const state = bumped.state;
|
|
260
|
+
const tail = await readTranscriptTail(input.transcript_path);
|
|
261
|
+
if (!tail.ok) {
|
|
262
|
+
return {
|
|
263
|
+
output: preCompactNote(`Flair: the transcript could not be read (${tail.reason}), so no pre-compaction record was saved.`),
|
|
264
|
+
reason: "no-transcript",
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
const content = buildPreCompactContent(extractFromTranscript(tail.lines), trigger);
|
|
268
|
+
if (content === null)
|
|
269
|
+
return { output: "", reason: "nothing-to-record" };
|
|
270
|
+
// Dedup: an unreadable marker is NOT "no marker". Treating it as absent would
|
|
271
|
+
// license a second record for a compaction that already has one.
|
|
272
|
+
const markerPath = notePath(precompactMarkerPath(sessionDir, agentId), env);
|
|
273
|
+
const read = await readPreCompactMarker(sessionDir, agentId);
|
|
274
|
+
if (read.kind === "unknown") {
|
|
275
|
+
return {
|
|
276
|
+
output: preCompactNote(`Flair: the pre-compaction marker ${markerPath} could not be read (${read.detail}), so no record was saved. Remove that file to reset it.`),
|
|
277
|
+
reason: "marker-unreadable",
|
|
278
|
+
};
|
|
279
|
+
}
|
|
280
|
+
const at = now();
|
|
281
|
+
const resolved = resolvePreCompactRecordId(read.kind === "present" ? read.marker : null, harnessSessionId, trigger, agentId, at);
|
|
282
|
+
try {
|
|
283
|
+
await writePreCompactMarker(sessionDir, agentId, {
|
|
284
|
+
harnessSessionId,
|
|
285
|
+
sessionId: state.sessionId,
|
|
286
|
+
trigger,
|
|
287
|
+
recordId: resolved.recordId,
|
|
288
|
+
firstWrittenAt: resolved.firstWrittenAt,
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return {
|
|
293
|
+
output: preCompactNote(`Flair: the pre-compaction marker ${markerPath} could not be written, so no record was saved (a rerun could not be recognized).`),
|
|
294
|
+
reason: "marker-unwritable",
|
|
295
|
+
recordId: resolved.recordId,
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
const row = buildPreCompactRow(agentId, state, resolved.recordId, content, trigger, at);
|
|
299
|
+
const remainingMs = startedAt + budgetMs - Date.now();
|
|
300
|
+
if (remainingMs <= 0) {
|
|
301
|
+
return { output: writeFailedNote("timeout"), reason: "write-failed", recordId: row.id, reused: resolved.reused };
|
|
302
|
+
}
|
|
303
|
+
const makeClient = deps.makeClient ?? defaultClientFactory;
|
|
304
|
+
try {
|
|
305
|
+
await withTimeout((async () => {
|
|
306
|
+
const client = await makeClient(agentId, remainingMs, env);
|
|
307
|
+
await client.request("PUT", memoryPutPath(row.id), row);
|
|
308
|
+
})(), remainingMs);
|
|
309
|
+
}
|
|
310
|
+
catch (err) {
|
|
311
|
+
return { output: writeFailedNote(classifyPreCompactFailure(err)), reason: "write-failed", recordId: row.id, reused: resolved.reused };
|
|
312
|
+
}
|
|
313
|
+
return { output: "", reason: "written", recordId: row.id, reused: resolved.reused };
|
|
314
|
+
}
|
|
315
|
+
// ── entry point ─────────────────────────────────────────────────────────────
|
|
316
|
+
/** Read stdin up to `maxBytes`: the text on EOF, or null as soon as it is
|
|
317
|
+
* larger (stdin is then closed). No timer here: stdin held open is bounded
|
|
318
|
+
* by the process-level deadline armed in main(). */
|
|
319
|
+
function readStdin(maxBytes) {
|
|
320
|
+
return new Promise((resolve) => {
|
|
321
|
+
const chunks = [];
|
|
322
|
+
let size = 0;
|
|
323
|
+
let settled = false;
|
|
324
|
+
const settle = (value) => {
|
|
325
|
+
if (settled)
|
|
326
|
+
return;
|
|
327
|
+
settled = true;
|
|
328
|
+
resolve(value);
|
|
329
|
+
};
|
|
330
|
+
process.stdin.on("data", (chunk) => {
|
|
331
|
+
const bytes = typeof chunk === "string" ? Buffer.from(chunk, "utf8") : chunk;
|
|
332
|
+
size += bytes.length;
|
|
333
|
+
if (size > maxBytes) {
|
|
334
|
+
process.stdin.destroy();
|
|
335
|
+
settle(null);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
chunks.push(bytes);
|
|
339
|
+
});
|
|
340
|
+
process.stdin.on("end", () => settle(Buffer.concat(chunks).toString("utf8")));
|
|
341
|
+
process.stdin.on("error", () => settle(Buffer.concat(chunks).toString("utf8")));
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
/** How long to wait for stdout to drain before exiting anyway. */
|
|
345
|
+
const STDOUT_DRAIN_GRACE_MS = 1000;
|
|
346
|
+
let finished = false;
|
|
347
|
+
/** Print the output (possibly nothing) and end the process with exit 0 once
|
|
348
|
+
* the write drains. The first caller wins: the deadline and the normal path
|
|
349
|
+
* can both get here, and only one output may be written. Explicit, so an
|
|
350
|
+
* abandoned request cannot keep the process alive past the budget. */
|
|
351
|
+
function finish(output) {
|
|
352
|
+
if (finished)
|
|
353
|
+
return;
|
|
354
|
+
finished = true;
|
|
355
|
+
let done = false;
|
|
356
|
+
const exit = () => {
|
|
357
|
+
if (done)
|
|
358
|
+
return;
|
|
359
|
+
done = true;
|
|
360
|
+
process.exit(0);
|
|
361
|
+
};
|
|
362
|
+
process.stdout.on("error", exit);
|
|
363
|
+
try {
|
|
364
|
+
process.stdout.write(output, exit);
|
|
365
|
+
}
|
|
366
|
+
catch {
|
|
367
|
+
exit();
|
|
368
|
+
}
|
|
369
|
+
setTimeout(exit, STDOUT_DRAIN_GRACE_MS).unref?.();
|
|
370
|
+
}
|
|
371
|
+
async function main() {
|
|
372
|
+
const startedAt = Date.now();
|
|
373
|
+
// Probe mode (flair#1007 pattern): being reached is the whole answer.
|
|
374
|
+
if (isProbeMode()) {
|
|
375
|
+
finish("");
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
stripInterpolationLiteralsFromEnv();
|
|
379
|
+
const budgetMs = resolvePreCompactBudgetMs(process.env);
|
|
380
|
+
// The process-level deadline, armed before stdin is read.
|
|
381
|
+
const expired = readEnvOrUnset("FLAIR_AGENT_ID") ? writeFailedNote("timeout") : "";
|
|
382
|
+
setTimeout(() => finish(expired), budgetMs);
|
|
383
|
+
let output = "";
|
|
384
|
+
try {
|
|
385
|
+
const input = await readStdin(STDIN_MAX_BYTES);
|
|
386
|
+
if (input !== null)
|
|
387
|
+
output = (await runPreCompact(input, { startedAt, budgetMs })).output;
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
output = "";
|
|
391
|
+
}
|
|
392
|
+
finish(output);
|
|
393
|
+
}
|
|
394
|
+
// Only run when executed as a script, not when imported by tests.
|
|
395
|
+
/**
|
|
396
|
+
* Whether this module is the process entry point. Where the runtime provides
|
|
397
|
+
* `import.meta.main` (Bun; Node 22.18+), its answer decides, true or false.
|
|
398
|
+
* Otherwise compare FILESYSTEM paths resolved through symlinks: the module URL
|
|
399
|
+
* is percent-encoded and an npm bin shim is a symlink, so comparing the URL
|
|
400
|
+
* string with `argv[1]` misses both.
|
|
401
|
+
*/
|
|
402
|
+
export function isDirectRun(moduleUrl, argv1, metaMain, realpath = realpathSync) {
|
|
403
|
+
if (metaMain !== undefined)
|
|
404
|
+
return metaMain;
|
|
405
|
+
if (argv1 == null || argv1 === "")
|
|
406
|
+
return false;
|
|
407
|
+
try {
|
|
408
|
+
return realpath(fileURLToPath(moduleUrl)) === realpath(argv1);
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
return false;
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
const importMeta = import.meta;
|
|
415
|
+
const isMain = typeof process !== "undefined" && isDirectRun(import.meta.url, process.argv[1], importMeta.main);
|
|
416
|
+
if (isMain) {
|
|
417
|
+
void main().catch(() => finish(""));
|
|
418
|
+
}
|