jules-orchestrator-kit 0.29.0 → 0.31.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/src/state.mjs CHANGED
@@ -10,10 +10,20 @@ import {
10
10
  fsyncSync,
11
11
  unlinkSync,
12
12
  } from "node:fs";
13
- import { join } from "node:path";
13
+ import { join, basename } from "node:path";
14
14
  import { createHash, randomUUID } from "node:crypto";
15
15
  import { hostname } from "node:os";
16
16
  import { resolveRoot } from "./config.mjs";
17
+ import { redactSecrets } from "./security.mjs";
18
+
19
+ function scrubStateValue(value) {
20
+ if (typeof value === "string") return redactSecrets(value);
21
+ if (Array.isArray(value)) return value.map(scrubStateValue);
22
+ if (value && typeof value === "object") {
23
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubStateValue(child)]));
24
+ }
25
+ return value;
26
+ }
17
27
 
18
28
  export function ensureDir(dirPath) {
19
29
  if (!dirPath || typeof dirPath !== "string" || dirPath.includes("\0")) {
@@ -109,7 +119,7 @@ export function appendLedger(entry, rootOrOpts = resolveRoot()) {
109
119
  }
110
120
 
111
121
  const timestamp = new Date().toISOString();
112
- const rawPayload = { timestamp, ...entry, prevHash };
122
+ const rawPayload = { timestamp, ...scrubStateValue(entry), prevHash };
113
123
  const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
114
124
  const payload = { ...rawPayload, hash };
115
125
 
@@ -321,7 +331,17 @@ export async function withBudget(fn, root = resolveRoot(), limit = 300) {
321
331
  if (err && typeof err === "object") {
322
332
  err.reservationId = reservation.reservationId;
323
333
  }
324
- appendLedger({ event: "budget_reservation_failed", reservationId: reservation.reservationId, error: err.message }, root);
334
+ try {
335
+ rollbackBudgetReservation(root, reservation.reservationId);
336
+ if (err && typeof err === "object") err.budgetReservationRolledBack = true;
337
+ } catch (_) {}
338
+ try {
339
+ appendLedger({
340
+ event: "budget_reservation_failed",
341
+ reservationId: reservation.reservationId,
342
+ error: err?.message || String(err),
343
+ }, root);
344
+ } catch (_) {}
325
345
  throw err;
326
346
  }
327
347
  }
@@ -363,8 +383,8 @@ export function isPidAlive(pid, expectedStartTime = null) {
363
383
  if (!pid || typeof pid !== "number") return false;
364
384
  try {
365
385
  process.kill(pid, 0);
366
- } catch (_) {
367
- return false;
386
+ } catch (err) {
387
+ if (err?.code !== "EPERM") return false;
368
388
  }
369
389
 
370
390
  if (expectedStartTime !== null && expectedStartTime !== undefined && process.platform === "linux") {
@@ -381,7 +401,14 @@ export function isPidAlive(pid, expectedStartTime = null) {
381
401
  return true;
382
402
  }
383
403
 
404
+ function assertSafeTaskId(taskId) {
405
+ if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskId) || basename(taskId) !== taskId) {
406
+ throw new Error("Invalid task id for lock path");
407
+ }
408
+ }
409
+
384
410
  export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot(), opts = {}) {
411
+ assertSafeTaskId(taskId);
385
412
  const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
386
413
  const branch = (typeof rootOrOpts === "object" && rootOrOpts?.branch) || opts?.branch || process.env.JULES_BRANCH || process.env.BRANCH_NAME || "";
387
414
  const lockDir = getLockDir(root);
@@ -418,11 +445,11 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
418
445
  acquiredAt: new Date().toISOString(),
419
446
  };
420
447
 
448
+ let fd;
421
449
  try {
422
- const fd = openSync(lockFile, "wx");
450
+ fd = openSync(lockFile, "wx");
423
451
  writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
424
452
  fsyncSync(fd);
425
- closeSync(fd);
426
453
  return { ok: true, lockFile };
427
454
  } catch (err) {
428
455
  if (err.code === "EEXIST") {
@@ -433,11 +460,21 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
433
460
  return { ok: false, holder: "unknown", taskId };
434
461
  }
435
462
  }
463
+ try { unlinkSync(lockFile); } catch (_) {}
436
464
  throw err;
465
+ } finally {
466
+ if (fd !== undefined) {
467
+ try { closeSync(fd); } catch (_) {}
468
+ }
437
469
  }
438
470
  }
439
471
 
440
472
  export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
473
+ try {
474
+ assertSafeTaskId(taskId);
475
+ } catch (_) {
476
+ return false;
477
+ }
441
478
  const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
442
479
  const lockDir = getLockDir(root);
443
480
  const lockFile = join(lockDir, `${taskId}.json`);
@@ -0,0 +1,306 @@
1
+ import { existsSync, readdirSync, statSync } from "node:fs";
2
+ import { join, relative, resolve } from "node:path";
3
+ import { checkScope } from "./security.mjs";
4
+ import { detectStackOracles } from "./wizard-oracle.mjs";
5
+ import { loadConfig } from "./config.mjs";
6
+
7
+ /**
8
+ * Calculates Levenshtein distance between two strings.
9
+ * Zero external dependencies.
10
+ */
11
+ export function levenshteinDistance(a, b) {
12
+ if (a === b) return 0;
13
+ if (!a || a.length === 0) return b ? b.length : 0;
14
+ if (!b || b.length === 0) return a.length;
15
+
16
+ const row = Array.from({ length: b.length + 1 }, (_, i) => i);
17
+ for (let i = 1; i <= a.length; i++) {
18
+ let prev = i;
19
+ for (let j = 1; j <= b.length; j++) {
20
+ const val = a[i - 1] === b[j - 1] ? row[j - 1] : Math.min(row[j - 1], prev, row[j]) + 1;
21
+ row[j - 1] = prev;
22
+ prev = val;
23
+ }
24
+ row[b.length] = prev;
25
+ }
26
+ return row[b.length];
27
+ }
28
+
29
+ /**
30
+ * Traverses local repository directory to harvest candidate relative file paths.
31
+ */
32
+ function harvestRepoFiles(dir, maxDepth = 4, currentDepth = 0) {
33
+ if (currentDepth > maxDepth) return [];
34
+ const files = [];
35
+ try {
36
+ const entries = readdirSync(dir);
37
+ for (const entry of entries) {
38
+ if (entry === "node_modules" || entry === ".git" || entry === "dist" || entry === "coverage") continue;
39
+ const fullPath = join(dir, entry);
40
+ const relPath = relative(process.cwd(), fullPath).replace(/\\/g, "/");
41
+ let stat;
42
+ try { stat = statSync(fullPath); } catch (_) { continue; }
43
+ if (stat.isDirectory()) {
44
+ files.push(...harvestRepoFiles(fullPath, maxDepth, currentDepth + 1));
45
+ } else if (stat.isFile()) {
46
+ files.push(relPath);
47
+ }
48
+ }
49
+ } catch (_) {}
50
+ return files;
51
+ }
52
+
53
+ /**
54
+ * Extracts potential file/directory path tokens from prompt text.
55
+ */
56
+ export function extractPathTokens(promptText) {
57
+ if (!promptText || typeof promptText !== "string") return [];
58
+ const tokens = [];
59
+ const words = promptText.split(/\s+/);
60
+ for (const rawWord of words) {
61
+ const cleanWord = rawWord.replace(/^[`'"]+|[`'":;,.]+$|\.$/g, "");
62
+ if (
63
+ cleanWord.includes("/") &&
64
+ (cleanWord.includes(".") || cleanWord.startsWith(".github/") || cleanWord.startsWith(".agent/")) &&
65
+ !cleanWord.startsWith("http://") &&
66
+ !cleanWord.startsWith("https://")
67
+ ) {
68
+ if (!tokens.includes(cleanWord)) {
69
+ tokens.push(cleanWord);
70
+ }
71
+ }
72
+ }
73
+ return tokens;
74
+ }
75
+
76
+ const VAGUE_BUZZWORDS = [
77
+ { term: "clean up", penalty: 15, msg: "Vague goal: 'clean up' lacks explicit acceptance criteria." },
78
+ { term: "make faster", penalty: 15, msg: "Vague goal: 'make faster' lacks quantitative benchmark criteria." },
79
+ { term: "fix bug", penalty: 10, msg: "Vague description: 'fix bug' does not reference specific error code or line." },
80
+ { term: "refactor", penalty: 10, msg: "Unbounded directive: 'refactor' without boundary limits risks payload blowouts." },
81
+ { term: "improve", penalty: 10, msg: "Subjective goal: 'improve' is not objectively scoreable." },
82
+ { term: "better", penalty: 10, msg: "Subjective wording: 'better' lacks falsifiable pass/fail criteria." },
83
+ { term: "various fixes", penalty: 20, msg: "Unbounded scope: 'various fixes' triggers scope leakage and ring-buffer thrashing." },
84
+ { term: "update code", penalty: 15, msg: "Generic request: 'update code' does not specify target contract or behavior." }
85
+ ];
86
+
87
+ const TRIVIAL_VERIFY_CMDS = ["true", "echo", ":", "false", "exit 0"];
88
+
89
+ /**
90
+ * Scores task prompt falsifiability, scope compliance, path validity, and oracle readiness.
91
+ */
92
+ export function scorePromptFalsifiability(promptText, options = {}) {
93
+ const rootDir = options.rootDir || process.cwd();
94
+ const rawPrompt = (promptText || "").trim();
95
+ const config = options.config || loadConfig(rootDir);
96
+
97
+ let score = 100;
98
+ const issues = [];
99
+ const suggestions = [];
100
+
101
+ if (!rawPrompt) {
102
+ return {
103
+ score: 0,
104
+ grade: "F",
105
+ isFalsifiable: false,
106
+ issues: [{ type: "EMPTY_PROMPT", message: "Task prompt is empty.", penalty: 100 }],
107
+ suggestions: ["Provide a clear description of the code change requested."],
108
+ paths: { found: [], missingCount: 0, scopeDeniedCount: 0 },
109
+ oracle: { command: null, autoDetected: false, isTrivial: false }
110
+ };
111
+ }
112
+
113
+ // 1. Length & Buzzword Analysis
114
+ if (rawPrompt.length < 15) {
115
+ score -= 25;
116
+ issues.push({ type: "SHORT_PROMPT", message: "Prompt is under 15 characters long.", penalty: 25 });
117
+ suggestions.push("Expand prompt with context, affected symbols, or expected output behavior.");
118
+ }
119
+
120
+ const promptLower = rawPrompt.toLowerCase();
121
+ for (const bw of VAGUE_BUZZWORDS) {
122
+ if (promptLower.includes(bw.term)) {
123
+ score -= bw.penalty;
124
+ issues.push({ type: "VAGUE_WORDING", message: bw.msg, penalty: bw.penalty });
125
+ }
126
+ }
127
+
128
+ // 2. Concrete Evidence Indicators (Bonus/Protection)
129
+ const hasErrorTrace = /(?:error|exception|fail|failed|stack|traceback|line\s+\d+|exit\s+code)/i.test(rawPrompt);
130
+ const hasSymbolRef = /(?:`[^`]+`|\b[a-zA-Z0-9_]+\.[a-zA-Z0-9_]+\b|\b[a-zA-Z0-9_]+\(\))/i.test(rawPrompt);
131
+ const hasExplicitCheck = /(?:verify|assert|should|must|returns?|expect)/i.test(rawPrompt);
132
+
133
+ if (hasErrorTrace || hasSymbolRef || hasExplicitCheck) {
134
+ score = Math.min(100, score + 10);
135
+ } else {
136
+ score -= 10;
137
+ issues.push({ type: "NO_CONCRETE_CRITERIA", message: "Lacks explicit symbol references, test names, or error tracebacks.", penalty: 10 });
138
+ suggestions.push("Specify exact function names, file paths, or expected test assertions.");
139
+ }
140
+
141
+ // 3. Static Path & Scope Verification
142
+ const extractedPaths = extractPathTokens(rawPrompt);
143
+ const repoFiles = harvestRepoFiles(rootDir);
144
+ const pathResults = [];
145
+ let missingCount = 0;
146
+ let scopeDeniedCount = 0;
147
+
148
+ for (const token of extractedPaths) {
149
+ const fullPath = resolve(rootDir, token);
150
+ const relPath = relative(rootDir, fullPath).replace(/\\/g, "/");
151
+ const exists = existsSync(fullPath);
152
+
153
+ // Check scope policy
154
+ const scopeCheck = checkScope([relPath], config.scope || {});
155
+ const scopeDenied = Boolean(scopeCheck.violations && scopeCheck.violations.length > 0);
156
+ const denialReason = scopeDenied ? scopeCheck.violations[0].reason : null;
157
+
158
+ if (scopeDenied) {
159
+ score -= 30;
160
+ scopeDeniedCount++;
161
+ issues.push({ type: "SCOPE_VIOLATION", message: `Referenced path '${relPath}' violates security policy (${denialReason}).`, penalty: 30 });
162
+ }
163
+
164
+ let suggestion;
165
+ if (!exists && !scopeDenied) {
166
+ missingCount++;
167
+ score -= 15;
168
+ // Fuzzy search closest file
169
+ let bestDist = Infinity;
170
+ let bestFile = null;
171
+ for (const repoFile of repoFiles) {
172
+ const dist = levenshteinDistance(token, repoFile);
173
+ if (dist < bestDist && dist <= 4) {
174
+ bestDist = dist;
175
+ bestFile = repoFile;
176
+ }
177
+ }
178
+ if (bestFile) {
179
+ suggestion = bestFile;
180
+ suggestions.push(`Referenced file '${token}' not found. Did you mean '${bestFile}'?`);
181
+ } else {
182
+ suggestions.push(`Referenced file '${token}' does not exist on disk.`);
183
+ }
184
+ }
185
+
186
+ pathResults.push({
187
+ token,
188
+ exists,
189
+ suggestion,
190
+ scopeDenied,
191
+ reason: denialReason
192
+ });
193
+ }
194
+
195
+ // 4. Stack Oracle Detection
196
+ let verifyCmd = options.verifyCmd || null;
197
+ let autoDetected = false;
198
+ let isTrivial = false;
199
+
200
+ if (!verifyCmd) {
201
+ const detectedOracles = detectStackOracles(rootDir);
202
+ if (detectedOracles.length > 0 && detectedOracles[0].testCmd) {
203
+ verifyCmd = detectedOracles[0].testCmd;
204
+ autoDetected = true;
205
+ }
206
+ }
207
+
208
+ if (verifyCmd && TRIVIAL_VERIFY_CMDS.includes(verifyCmd.trim())) {
209
+ isTrivial = true;
210
+ score -= 25;
211
+ issues.push({ type: "TRIVIAL_ORACLE", message: `Verification command '${verifyCmd}' is non-evaluable / trivial.`, penalty: 25 });
212
+ suggestions.push("Use a non-trivial test command like 'npm test', 'pytest', or 'cargo test'.");
213
+ } else if (!verifyCmd) {
214
+ score -= 15;
215
+ issues.push({ type: "MISSING_ORACLE", message: "No automated test or build verification command specified.", penalty: 15 });
216
+ suggestions.push("Specify a verification command using --verify (e.g. 'npm test').");
217
+ }
218
+
219
+ // Final score clamping & letter grade assignment
220
+ score = Math.max(0, Math.min(100, Math.round(score)));
221
+
222
+ let grade = "F";
223
+ if (score >= 90) grade = "A+";
224
+ else if (score >= 80) grade = "A";
225
+ else if (score >= 70) grade = "B";
226
+ else if (score >= 60) grade = "C";
227
+ else if (score >= 50) grade = "D";
228
+
229
+ const isFalsifiable = score >= 65 && scopeDeniedCount === 0 && !isTrivial;
230
+
231
+ return {
232
+ score,
233
+ grade,
234
+ isFalsifiable,
235
+ issues,
236
+ suggestions: [...new Set(suggestions)],
237
+ paths: {
238
+ found: pathResults,
239
+ missingCount,
240
+ scopeDeniedCount
241
+ },
242
+ oracle: {
243
+ command: verifyCmd,
244
+ autoDetected,
245
+ isTrivial
246
+ }
247
+ };
248
+ }
249
+
250
+ /**
251
+ * Transforms raw prompt into an optimized, structured task envelope.
252
+ */
253
+ export function optimizeTaskPrompt(promptText, options = {}) {
254
+ const analysis = scorePromptFalsifiability(promptText, options);
255
+ const rawPrompt = (promptText || "").trim();
256
+
257
+ if (!rawPrompt) {
258
+ return {
259
+ optimizedPrompt: "",
260
+ analysis
261
+ };
262
+ }
263
+
264
+ let promptBody = rawPrompt;
265
+
266
+ // Apply suggestions & path corrections to prompt body if requested
267
+ for (const pathInfo of analysis.paths.found) {
268
+ if (!pathInfo.exists && pathInfo.suggestion) {
269
+ const regex = new RegExp(pathInfo.token.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"), "g");
270
+ promptBody = promptBody.replace(regex, pathInfo.suggestion);
271
+ }
272
+ }
273
+
274
+ // Construct structured Markdown envelope
275
+ const lines = [];
276
+ lines.push(`# TASK: ${promptBody.split("\n")[0]}`);
277
+ lines.push("");
278
+ lines.push("## Objective & Acceptance Criteria");
279
+ lines.push(`- **Goal**: ${promptBody}`);
280
+ if (analysis.oracle.command) {
281
+ lines.push(`- **Verification Command**: \`${analysis.oracle.command}\` (Must pass cleanly with exit code 0)`);
282
+ }
283
+ lines.push("- **Falsifiability Criteria**: Zero deleted tests, zero weakened assertions, zero lint errors.");
284
+ lines.push("");
285
+
286
+ if (analysis.paths.found.length > 0) {
287
+ lines.push("## Primary Target Paths");
288
+ for (const p of analysis.paths.found) {
289
+ const pathTarget = p.exists ? p.token : (p.suggestion || p.token);
290
+ lines.push(`- \`${pathTarget}\` (${p.exists ? "Verified" : "Corrected"})`);
291
+ }
292
+ lines.push("");
293
+ }
294
+
295
+ lines.push("## Standard Guardrails");
296
+ lines.push("- Do NOT modify package.json, lockfiles, or .github/ infrastructure files.");
297
+ lines.push("- Diff Payload Governor: Keep total diff payload under 75 KB (\`git diff | wc -c\`).");
298
+ lines.push("- Verify before finishing: Execute full verification command and confirm zero errors.");
299
+
300
+ const optimizedPrompt = lines.join("\n");
301
+
302
+ return {
303
+ optimizedPrompt,
304
+ analysis
305
+ };
306
+ }
package/src/telemetry.mjs CHANGED
@@ -7,16 +7,26 @@ import {
7
7
  existsSync,
8
8
  statSync,
9
9
  readdirSync,
10
+ truncateSync,
10
11
  } from "node:fs";
11
12
  import { join } from "node:path";
12
13
  import { createHash } from "node:crypto";
13
14
  import { resolveRoot } from "./config.mjs";
14
15
  import { getStateDir, withVfsMutex } from "./state.mjs";
15
- import { safeAtomicWrite } from "./security.mjs";
16
+ import { safeAtomicWrite, redactSecrets } from "./security.mjs";
16
17
 
17
18
  export const MAX_TELEMETRY_SEGMENT_BYTES = 8 * 1024 * 1024; // 8 MB log segment ceiling
18
19
  export const TELEMETRY_GENESIS_HASH = "0".repeat(64);
19
20
 
21
+ function scrubTelemetryValue(value) {
22
+ if (typeof value === "string") return redactSecrets(value);
23
+ if (Array.isArray(value)) return value.map(scrubTelemetryValue);
24
+ if (value && typeof value === "object") {
25
+ return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubTelemetryValue(child)]));
26
+ }
27
+ return value;
28
+ }
29
+
20
30
  /**
21
31
  * Reads up to 64 KB from EOF of target log segment to extract true tail hash.
22
32
  */
@@ -48,6 +58,27 @@ function readTailHash(filePath, bufferSize = 64 * 1024) {
48
58
  return null;
49
59
  }
50
60
 
61
+ function discardIncompleteTail(filePath, bufferSize = 64 * 1024) {
62
+ if (!existsSync(filePath)) return;
63
+ try {
64
+ const stat = statSync(filePath);
65
+ if (stat.size === 0) return;
66
+ const readSize = Math.min(stat.size, bufferSize);
67
+ const position = stat.size - readSize;
68
+ const fd = openSync(filePath, "r");
69
+ const buf = Buffer.alloc(readSize);
70
+ try {
71
+ readSync(fd, buf, 0, readSize, position);
72
+ } finally {
73
+ closeSync(fd);
74
+ }
75
+ if (buf[buf.length - 1] === 0x0a) return;
76
+ const lastNewline = buf.lastIndexOf(0x0a);
77
+ const truncateAt = lastNewline === -1 ? position : position + lastNewline + 1;
78
+ truncateSync(filePath, truncateAt);
79
+ } catch (_) {}
80
+ }
81
+
51
82
  /**
52
83
  * Returns telemetry file paths for a given date string sorted by segment index.
53
84
  */
@@ -132,6 +163,8 @@ export function appendTelemetry(rootOrOpts = resolveRoot(), kind = "event", fiel
132
163
  : `telemetry-${dateStr}-${activeSegmentIndex}.jsonl`;
133
164
  let activeFilePath = join(stateDir, activeFileName);
134
165
 
166
+ // Discard a torn final JSONL record before reconciling the true tail.
167
+ discardIncompleteTail(activeFilePath);
135
168
  // Reconcile .head hash against true log tail (reading last 64 KB from EOF)
136
169
  const tailHash = readTailHash(activeFilePath);
137
170
  if (tailHash) {
@@ -162,7 +195,7 @@ export function appendTelemetry(rootOrOpts = resolveRoot(), kind = "event", fiel
162
195
  }
163
196
 
164
197
  const ts = new Date().toISOString();
165
- const payloadWithoutHash = { v: 1, ts, kind, ...fields, prevHash };
198
+ const payloadWithoutHash = { v: 1, ts, kind, ...scrubTelemetryValue(fields), prevHash };
166
199
  const hash = createHash("sha256").update(JSON.stringify(payloadWithoutHash)).digest("hex");
167
200
  const entry = { ...payloadWithoutHash, hash };
168
201
 
package/src/tui.mjs CHANGED
@@ -41,6 +41,9 @@ export function styleText(text, style) {
41
41
  return `${style}${text}${ANSI.reset}`;
42
42
  }
43
43
 
44
+ import { WizardCancelledError } from "./ux/terminal-session.mjs";
45
+ export { WizardCancelledError };
46
+
44
47
  /**
45
48
  * Read keypress in raw mode from TTY input stream.
46
49
  * @param {import("node:stream").Readable} stream
@@ -60,7 +63,7 @@ async function* readKeypresses(stream = process.stdin) {
60
63
  if (str === "\u0003") {
61
64
  // Ctrl+C SIGINT
62
65
  process.stdout.write(ANSI.showCursor + "\n");
63
- process.exit(130);
66
+ throw new WizardCancelledError("Wizard operation cancelled by user (SIGINT).");
64
67
  }
65
68
  yield str;
66
69
  }
@@ -0,0 +1,180 @@
1
+ import { isatty } from "node:tty";
2
+
3
+ /**
4
+ * @typedef {Object} TerminalCapabilities
5
+ * @property {boolean} inputIsTTY
6
+ * @property {boolean} outputIsTTY
7
+ * @property {number} columns
8
+ * @property {number} rows
9
+ * @property {0 | 4 | 8 | 24} colorDepth
10
+ * @property {boolean} ansi
11
+ * @property {boolean} unicode
12
+ * @property {boolean} alternateScreen
13
+ * @property {false} mouse
14
+ * @property {NodeJS.Platform} platform
15
+ * @property {string} term
16
+ * @property {boolean} noColor
17
+ * @property {boolean} forceColor
18
+ * @property {boolean} reducedMotion
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} TerminalSessionOptions
23
+ * @property {import("node:stream").Readable} [input]
24
+ * @property {import("node:stream").Writable} [output]
25
+ * @property {import("node:stream").Writable} [errorOutput]
26
+ * @property {boolean} [jsonMode]
27
+ * @property {boolean} [alternateScreen]
28
+ * @property {"auto" | "always" | "never"} [color]
29
+ * @property {"auto" | "always" | "never"} [unicode]
30
+ * @property {boolean} [reducedMotion]
31
+ */
32
+
33
+ /**
34
+ * Detect current terminal capabilities based on streams, environment variables, and CLI options.
35
+ * @param {TerminalSessionOptions} [options]
36
+ * @returns {TerminalCapabilities}
37
+ */
38
+ export function detectTerminalCapabilities(options = {}) {
39
+ const input = options.input || process.stdin;
40
+ const output = options.output || process.stdout;
41
+ const env = process.env || {};
42
+ const argv = process.argv || [];
43
+
44
+ const inputIsTTY = Boolean(input && (input.isTTY || (typeof input.fd === "number" && isatty(input.fd))));
45
+ const outputIsTTY = Boolean(output && (output.isTTY || (typeof output.fd === "number" && isatty(output.fd))));
46
+
47
+ const term = String(env.TERM || "").toLowerCase();
48
+ const colorTerm = String(env.COLORTERM || "").toLowerCase();
49
+
50
+ // Environment color flags
51
+ const envNoColor = Boolean(env.NO_COLOR !== undefined && env.NO_COLOR !== "");
52
+ let envForceColor = false;
53
+ let envDisableColor = false;
54
+
55
+ if (env.FORCE_COLOR !== undefined) {
56
+ if (env.FORCE_COLOR === "0" || env.FORCE_COLOR === "false") {
57
+ envDisableColor = true;
58
+ } else {
59
+ envForceColor = true;
60
+ }
61
+ }
62
+
63
+ // CLI argument overrides
64
+ const cliNoColor = argv.includes("--no-color");
65
+ const cliColor = argv.includes("--color");
66
+ const cliAscii = argv.includes("--ascii");
67
+ const jsonMode = Boolean(options.jsonMode || argv.includes("--json"));
68
+
69
+ const noColor = envNoColor || cliNoColor || options.color === "never";
70
+ const forceColor = (envForceColor || cliColor || options.color === "always") && !envDisableColor;
71
+
72
+ // Priority evaluation for ANSI support:
73
+ // --no-color / options.color === "never" > NO_COLOR > FORCE_COLOR=0 > jsonMode > --color / FORCE_COLOR > auto TTY capability
74
+ let ansi = false;
75
+ if (jsonMode) {
76
+ ansi = false;
77
+ } else if (noColor || envDisableColor) {
78
+ ansi = false;
79
+ } else if (forceColor) {
80
+ ansi = true;
81
+ } else {
82
+ ansi = outputIsTTY && term !== "dumb";
83
+ }
84
+
85
+ // Color depth calculation
86
+ /** @type {0 | 4 | 8 | 24} */
87
+ let colorDepth = 0;
88
+ if (ansi) {
89
+ if (colorTerm === "truecolor" || colorTerm === "24bit" || env.FORCE_COLOR === "3") {
90
+ colorDepth = 24;
91
+ } else if (term.includes("256color") || colorTerm === "256color" || env.FORCE_COLOR === "2") {
92
+ colorDepth = 8;
93
+ } else {
94
+ colorDepth = 4;
95
+ }
96
+ }
97
+
98
+ // Unicode support detection
99
+ let unicode = true;
100
+ if (cliAscii || options.unicode === "never") {
101
+ unicode = false;
102
+ } else if (options.unicode === "always") {
103
+ unicode = true;
104
+ } else {
105
+ const lang = String(env.LANG || env.LC_ALL || env.LC_CTYPE || "").toLowerCase();
106
+ const isUtf8 = lang.includes("utf-8") || lang.includes("utf8");
107
+ // Default to true on non-windows or utf-8 windows terminals
108
+ unicode = isUtf8 || process.platform !== "win32" || Boolean(env.WT_SESSION);
109
+ }
110
+
111
+ // Terminal dimensions
112
+ const columns = Math.max(10, output.columns || input.columns || process.stdout.columns || 80);
113
+ const rows = Math.max(5, output.rows || input.rows || process.stdout.rows || 24);
114
+
115
+ const reducedMotion = Boolean(
116
+ options.reducedMotion ||
117
+ env.REDUCED_MOTION === "1" ||
118
+ env.REDUCED_MOTION === "true"
119
+ );
120
+
121
+ const alternateScreen = options.alternateScreen !== false && ansi && outputIsTTY;
122
+
123
+ return {
124
+ inputIsTTY,
125
+ outputIsTTY,
126
+ columns,
127
+ rows,
128
+ colorDepth,
129
+ ansi,
130
+ unicode,
131
+ alternateScreen,
132
+ mouse: false,
133
+ platform: process.platform,
134
+ term: env.TERM || "",
135
+ noColor,
136
+ forceColor,
137
+ reducedMotion,
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Get display symbol set based on unicode capability.
143
+ * @param {TerminalCapabilities} caps
144
+ */
145
+ export function getSymbols(caps) {
146
+ if (caps.unicode) {
147
+ return {
148
+ select: "❯",
149
+ checked: "●",
150
+ unchecked: "○",
151
+ success: "✓",
152
+ warning: "!",
153
+ failure: "✗",
154
+ info: "ℹ",
155
+ bullet: "•",
156
+ ellipsis: "…",
157
+ spinner: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
158
+ branchMiddle: "├─",
159
+ branchEnd: "└─",
160
+ arrowUp: "↑",
161
+ arrowDown: "↓",
162
+ };
163
+ }
164
+ return {
165
+ select: ">",
166
+ checked: "[x]",
167
+ unchecked: "[ ]",
168
+ success: "OK",
169
+ warning: "WARN",
170
+ failure: "ERR",
171
+ info: "INFO",
172
+ bullet: "*",
173
+ ellipsis: "...",
174
+ spinner: ["-", "\\", "|", "/"],
175
+ branchMiddle: "+-",
176
+ branchEnd: "`-",
177
+ arrowUp: "^",
178
+ arrowDown: "v",
179
+ };
180
+ }