@awak-app/simy-cli 0.1.2 → 0.1.4

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.
@@ -19,7 +19,7 @@ const TERMINAL_STATES = new Set([
19
19
  ]);
20
20
  const NEW_TASK = "__new_task__";
21
21
 
22
- export function CodingLoopConsole({ agent, onQuit = () => {} }) {
22
+ export function AgenticLoopConsole({ agent, onQuit = () => {} }) {
23
23
  const { exit } = useApp();
24
24
  const { stdout } = useStdout();
25
25
  const [terminal, setTerminal] = useState(() => terminalSize(stdout));
@@ -138,7 +138,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
138
138
  }
139
139
  if (!selectedRun) {
140
140
  if (command.type !== "guidance") {
141
- setNotice("That command requires a selected coding run.");
141
+ setNotice("That command requires a selected Agentic Loop run.");
142
142
  return;
143
143
  }
144
144
  setBusy(true);
@@ -175,12 +175,12 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
175
175
  setNotice("Guidance queued for the next executor handoff.");
176
176
  } else {
177
177
  await agent.controls.continue(selectedRun, command.message);
178
- setNotice("Human guidance accepted. The coding loop is continuing.");
178
+ setNotice("Human guidance accepted. The agentic loop is continuing.");
179
179
  }
180
180
  break;
181
181
  case "decision":
182
182
  await agent.controls.applyDecision(selectedRun, command);
183
- setNotice("Human decision recorded. The coding loop is continuing.");
183
+ setNotice("Human decision recorded. The agentic loop is continuing.");
184
184
  break;
185
185
  case "recheck":
186
186
  await agent.controls.recheck(selectedRun);
@@ -259,7 +259,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
259
259
  await agent.controls.selectRepository(selectedRun, match);
260
260
  setDetailOpen(true);
261
261
  setDetailScrollFromEnd(0);
262
- setNotice(`Authorized ${match.repository}. The same coding run is continuing.`);
262
+ setNotice(`Authorized ${match.repository}. The same Agentic Loop run is continuing.`);
263
263
  } else if (selectedRun) {
264
264
  setNotice(
265
265
  `${selectedRun.request.repository} was not found under ${result.root}. Run /scan <path> for another authorized root.`,
@@ -290,7 +290,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
290
290
  setRepositoryPicker(null);
291
291
  setDetailOpen(true);
292
292
  setDetailScrollFromEnd(0);
293
- setNotice(`Authorized ${selected.repository}. The same coding run is continuing.`);
293
+ setNotice(`Authorized ${selected.repository}. The same Agentic Loop run is continuing.`);
294
294
  } else {
295
295
  const verified = await agent.controls.selectRepository(null, selected);
296
296
  setDraft((current) => ({
@@ -461,7 +461,7 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
461
461
  if (confirmStop) void execute("/stop");
462
462
  else {
463
463
  setConfirmStop(true);
464
- setNotice("Press x again to stop the selected coding run.");
464
+ setNotice("Press x again to stop the selected Agentic Loop run.");
465
465
  }
466
466
  return;
467
467
  }
@@ -534,6 +534,8 @@ export function CodingLoopConsole({ agent, onQuit = () => {} }) {
534
534
  });
535
535
  }
536
536
 
537
+ export const CodingLoopConsole = AgenticLoopConsole;
538
+
537
539
  function Header({ agent, runCount }) {
538
540
  return jsxs(Box, {
539
541
  borderStyle: "single",
@@ -1,11 +1,11 @@
1
1
  import { jsx } from "react/jsx-runtime";
2
2
  import { render } from "ink";
3
3
 
4
- import { CodingLoopConsole } from "./app.js";
4
+ import { AgenticLoopConsole } from "./app.js";
5
5
 
6
- export async function runCodingLoopConsole(agent, options = {}) {
6
+ export async function runAgenticLoopConsole(agent, options = {}) {
7
7
  const instance = render(
8
- jsx(CodingLoopConsole, {
8
+ jsx(AgenticLoopConsole, {
9
9
  agent,
10
10
  onQuit: options.onQuit,
11
11
  }),
@@ -21,5 +21,6 @@ export async function runCodingLoopConsole(agent, options = {}) {
21
21
  await instance.waitUntilExit();
22
22
  }
23
23
 
24
- export { CodingLoopConsole } from "./app.js";
24
+ export const runCodingLoopConsole = runAgenticLoopConsole;
25
+ export { AgenticLoopConsole, CodingLoopConsole } from "./app.js";
25
26
  export { consoleHelpLines, parseConsoleCommand } from "./commands.js";
@@ -0,0 +1,137 @@
1
+ import { spawn } from "node:child_process";
2
+
3
+ import {
4
+ BACKEND_VERSION_POLICIES,
5
+ inspectBackendExecutable,
6
+ } from "./backend-executable.js";
7
+
8
+ export const DESKTOP_EXECUTION_TARGET = "desktop";
9
+ export const DESKTOP_EXECUTION_LABEL = "Your Desktop";
10
+
11
+ export function normalizeDesktopExecutionTarget(value) {
12
+ const target = String(value ?? "").trim().toLowerCase();
13
+ if (!target || target === DESKTOP_EXECUTION_TARGET) return DESKTOP_EXECUTION_TARGET;
14
+ const error = new Error(
15
+ `SIMY CLI runs only on ${DESKTOP_EXECUTION_LABEL}; execution_target must be desktop.`,
16
+ );
17
+ error.code = "unsupported_execution_target";
18
+ error.executionTarget = target;
19
+ throw error;
20
+ }
21
+
22
+ export async function inspectDesktopExecutor(backend, options = {}) {
23
+ const provider = normalizeBackend(backend);
24
+ const inspection = await inspectBackendExecutable(provider, options);
25
+ return {
26
+ execution_target: DESKTOP_EXECUTION_TARGET,
27
+ execution_label: DESKTOP_EXECUTION_LABEL,
28
+ provider_label: BACKEND_VERSION_POLICIES[provider].label,
29
+ ...inspection,
30
+ };
31
+ }
32
+
33
+ export function desktopExecutorCapability(inspection) {
34
+ return {
35
+ execution_target: DESKTOP_EXECUTION_TARGET,
36
+ execution_label: DESKTOP_EXECUTION_LABEL,
37
+ backend: inspection.backend,
38
+ provider_label: inspection.provider_label,
39
+ status: inspection.status,
40
+ available: inspection.available,
41
+ compatible: inspection.compatible,
42
+ installed_version: inspection.installed_version,
43
+ minimum_version: inspection.minimum_version,
44
+ update_command: inspection.update_command,
45
+ };
46
+ }
47
+
48
+ export async function resolveDesktopExecutorCommand({
49
+ backend,
50
+ instruction,
51
+ repositoryPath,
52
+ environment = process.env,
53
+ spawnImpl = spawn,
54
+ inspectionOptions = {},
55
+ } = {}) {
56
+ const provider = normalizeBackend(backend);
57
+ const target = normalizeDesktopExecutionTarget(DESKTOP_EXECUTION_TARGET);
58
+ const override =
59
+ provider === "claude" ? environment.SIMY_CLAUDE_COMMAND : environment.SIMY_CODEX_COMMAND;
60
+ if (override) {
61
+ return {
62
+ ...shellCommand(override, repositoryPath, instruction, spawnImpl),
63
+ backend: provider,
64
+ execution_target: target,
65
+ verification: "explicit_command_override",
66
+ };
67
+ }
68
+
69
+ const inspection = await inspectDesktopExecutor(provider, {
70
+ ...inspectionOptions,
71
+ spawnImpl: inspectionOptions.spawnImpl ?? spawnImpl,
72
+ });
73
+ if (!inspection.compatible || !inspection.executable) {
74
+ throw desktopExecutorUnavailableError(inspection);
75
+ }
76
+ return {
77
+ bin: inspection.executable,
78
+ args:
79
+ provider === "claude"
80
+ ? claudeBackendArgs(instruction)
81
+ : ["exec", "--json", instruction],
82
+ env: {},
83
+ spawn: spawnImpl,
84
+ backend: provider,
85
+ execution_target: target,
86
+ verification: "compatible_version",
87
+ installed_version: inspection.installed_version,
88
+ };
89
+ }
90
+
91
+ export function claudeBackendArgs(instruction) {
92
+ return [
93
+ "-p",
94
+ instruction,
95
+ "--output-format",
96
+ "stream-json",
97
+ "--verbose",
98
+ // The Agentic Loop is scoped to a verified checkout and explicitly approved
99
+ // by the user. Print mode cannot display permission prompts, so edits would
100
+ // otherwise be silently unavailable to the desktop executor.
101
+ "--dangerously-skip-permissions",
102
+ ];
103
+ }
104
+
105
+ function normalizeBackend(backend) {
106
+ if (backend === "codex" || backend === "claude") return backend;
107
+ throw new Error("Desktop executor backend must be codex or claude.");
108
+ }
109
+
110
+ function desktopExecutorUnavailableError(inspection) {
111
+ const provider = inspection.provider_label;
112
+ let message;
113
+ if (inspection.status === "outdated") {
114
+ message = `${provider} ${inspection.installed_version} is too old for ${DESKTOP_EXECUTION_LABEL}. Update to ${inspection.minimum_version} or newer: ${inspection.update_command}`;
115
+ } else if (inspection.status === "unknown_version") {
116
+ message = `${provider} is installed on ${DESKTOP_EXECUTION_LABEL}, but its version could not be verified. Update it and try again: ${inspection.update_command}`;
117
+ } else {
118
+ message = `${provider} is not installed on ${DESKTOP_EXECUTION_LABEL}. Install or update it before starting this run: ${inspection.update_command}`;
119
+ }
120
+ const error = new Error(message);
121
+ error.code = `desktop_executor_${inspection.status}`;
122
+ error.inspection = desktopExecutorCapability(inspection);
123
+ return error;
124
+ }
125
+
126
+ function shellCommand(command, cwd, instruction, spawnImpl) {
127
+ return {
128
+ bin: process.platform === "win32" ? "cmd.exe" : "sh",
129
+ args: process.platform === "win32" ? ["/d", "/s", "/c", command] : ["-lc", command],
130
+ cwd,
131
+ env: {
132
+ SIMY_AGENTIC_LOOP_REQUIREMENT: instruction,
133
+ SIMY_CODING_LOOP_REQUIREMENT: instruction,
134
+ },
135
+ spawn: spawnImpl,
136
+ };
137
+ }
package/src/index.js CHANGED
@@ -18,7 +18,7 @@ if (args.has("--help") || args.has("-h")) {
18
18
 
19
19
  Options:
20
20
  --daemon, --deamon Run detached in the background
21
- --no-tui Disable the interactive Coding Loop console
21
+ --no-tui Disable the interactive Agentic Loop console
22
22
  --no-open Do not open the browser when authorization is required
23
23
  --port <port> Bind a specific localhost port
24
24
  --host <url> Connect to a SIMY Web origin (default: ${DEFAULT_WEB_ORIGIN})
@@ -53,9 +53,9 @@ if (agent.loginUrl && !args.has("--no-open")) {
53
53
  await openAuthorizationUrl(agent.loginUrl);
54
54
  }
55
55
  if (interactive) {
56
- const { runCodingLoopConsole } = await import("./console/index.js");
56
+ const { runAgenticLoopConsole } = await import("./console/index.js");
57
57
  try {
58
- await runCodingLoopConsole(agent);
58
+ await runAgenticLoopConsole(agent);
59
59
  } finally {
60
60
  await closeServer(agent.server);
61
61
  }
@@ -7,7 +7,7 @@ export const MAX_ATTACHMENT_COUNT = 5;
7
7
  export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024;
8
8
  export const MAX_ATTACHMENTS_TOTAL_BYTES = 50 * 1024 * 1024;
9
9
  const RUN_RETENTION_MS = 24 * 60 * 60 * 1000;
10
- const SAFE_RUN_ID = /^coding_loop_[A-Za-z0-9_-]+$/;
10
+ const SAFE_RUN_ID = /^(?:agentic_loop|coding_loop)_[A-Za-z0-9_-]+$/;
11
11
  const ALLOWED_EXACT_MIME_TYPES = new Set([
12
12
  "application/json",
13
13
  "application/octet-stream",
@@ -32,7 +32,7 @@ export function runsRoot(override) {
32
32
  }
33
33
 
34
34
  export async function stageRunAttachments({ runId, attachments, manifest, root }) {
35
- if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid coding loop run_id");
35
+ if (!SAFE_RUN_ID.test(runId)) throw new Error("invalid agentic loop run_id");
36
36
  if (!Array.isArray(attachments) || attachments.length === 0) return [];
37
37
  if (attachments.length > MAX_ATTACHMENT_COUNT) {
38
38
  throw new Error(`at most ${MAX_ATTACHMENT_COUNT} attachments are allowed`);
@@ -1,4 +1,4 @@
1
- import { stat } from "node:fs/promises";
1
+ import { open, readdir, stat } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
 
4
4
  import {
@@ -7,8 +7,9 @@ import {
7
7
  hasAllowedPrefix,
8
8
  stringArray,
9
9
  } from "./shared.js";
10
+ import { evaluateRetryProblemSolving } from "./problem-solving.js";
10
11
 
11
- export async function auditAttempt(charter, attempt) {
12
+ export async function auditAttempt(charter, attempt, { previousAttempt = null } = {}) {
12
13
  const checks = [];
13
14
  const addCheck = (id, label, passed, detail, severity = "blocker", repairability = "auto") => {
14
15
  checks.push({ id, label, passed, severity, detail, repairability, evidence_refs: [] });
@@ -112,6 +113,12 @@ export async function auditAttempt(charter, attempt) {
112
113
  : "No committed local evidence artifacts reported.",
113
114
  );
114
115
 
116
+ const retryProblemSolving = evaluateRetryProblemSolving(attempt, previousAttempt);
117
+ for (const check of retryProblemSolving.checks) {
118
+ addCheck(check.id, check.label, check.passed, check.detail);
119
+ }
120
+ attempt.retry_problem_solving = retryProblemSolving;
121
+
115
122
  const evidenceRequired = charter.expected_evidence.includes("ui_evidence_path");
116
123
  const evidenceArtifact = await inspectEvidence(charter, attempt.ui_evidence_path);
117
124
  if (evidenceRequired || attempt.ui_evidence_path) {
@@ -126,6 +133,16 @@ export async function auditAttempt(charter, attempt) {
126
133
  evidenceArtifact?.summary || "Local evidence path missing.",
127
134
  );
128
135
  }
136
+ if (charter.evidence_policy?.browser_required) {
137
+ addCheck(
138
+ "browser_visual_evidence_present",
139
+ "Browser screenshot or video evidence is present",
140
+ Boolean(evidenceArtifact?.visual_file_count > 0),
141
+ evidenceArtifact?.visual_file_count > 0
142
+ ? `${evidenceArtifact.visual_file_count} screenshot or video artifact(s) found.`
143
+ : "UI and browser-extension work requires a real screenshot or video; a path, text file, or empty directory is not sufficient.",
144
+ );
145
+ }
129
146
 
130
147
  const findings = checks
131
148
  .filter((check) => !check.passed)
@@ -301,17 +318,94 @@ async function inspectEvidence(charter, evidencePath) {
301
318
  const underRoot = Boolean(
302
319
  root && (resolved === root || resolved.startsWith(`${root}${path.sep}`)),
303
320
  );
321
+ const directoryEvidence = fileStat?.isDirectory()
322
+ ? await scanEvidenceDirectory(resolved)
323
+ : null;
324
+ const directKind = fileStat?.isFile() ? evidenceKind(resolved) : "unknown";
325
+ const visualFileCount = fileStat?.isFile()
326
+ ? (await isVisualEvidenceFile(resolved))
327
+ ? 1
328
+ : 0
329
+ : directoryEvidence?.visualFileCount ?? 0;
330
+ const sizeBytes = fileStat?.isDirectory()
331
+ ? directoryEvidence?.sizeBytes ?? 0
332
+ : fileStat?.size ?? 0;
304
333
  return {
305
334
  path: resolved,
306
- kind: fileStat?.isDirectory() ? "directory" : evidenceKind(resolved),
335
+ kind: fileStat?.isDirectory() ? "directory" : directKind,
307
336
  exists: Boolean(fileStat),
308
337
  under_required_root: underRoot,
309
- size_bytes: fileStat?.size ?? 0,
310
- file_count: fileStat?.isFile() ? 1 : 0,
338
+ size_bytes: sizeBytes,
339
+ file_count: fileStat?.isFile() ? 1 : directoryEvidence?.fileCount ?? 0,
340
+ visual_file_count: visualFileCount,
311
341
  git_tracked: false,
312
342
  summary:
313
- fileStat && underRoot && fileStat.size > 0
314
- ? "Evidence exists under the required local root."
343
+ fileStat && underRoot && sizeBytes > 0
344
+ ? `Evidence exists under the required local root; ${visualFileCount} visual artifact(s) found.`
315
345
  : "Evidence is missing, empty, or outside the required local root.",
316
346
  };
317
347
  }
348
+
349
+ async function scanEvidenceDirectory(directory) {
350
+ const pending = [directory];
351
+ let fileCount = 0;
352
+ let visualFileCount = 0;
353
+ let sizeBytes = 0;
354
+ while (pending.length > 0 && fileCount < 500) {
355
+ const current = pending.shift();
356
+ if (!current) break;
357
+ let entries;
358
+ try {
359
+ entries = await readdir(current, { withFileTypes: true });
360
+ } catch {
361
+ continue;
362
+ }
363
+ for (const entry of entries) {
364
+ if (entry.isSymbolicLink()) continue;
365
+ const candidate = path.join(current, entry.name);
366
+ if (entry.isDirectory()) {
367
+ pending.push(candidate);
368
+ continue;
369
+ }
370
+ if (!entry.isFile()) continue;
371
+ fileCount += 1;
372
+ if (await isVisualEvidenceFile(candidate)) visualFileCount += 1;
373
+ try {
374
+ sizeBytes += (await stat(candidate)).size;
375
+ } catch {
376
+ // A file disappearing during inspection is simply not counted toward its size.
377
+ }
378
+ if (fileCount >= 500) break;
379
+ }
380
+ }
381
+ return { fileCount, visualFileCount, sizeBytes };
382
+ }
383
+
384
+ async function isVisualEvidenceFile(filePath) {
385
+ if (!["image", "video"].includes(evidenceKind(filePath))) return false;
386
+ const header = Buffer.alloc(16);
387
+ let handle;
388
+ try {
389
+ handle = await open(filePath, "r");
390
+ const { bytesRead } = await handle.read(header, 0, header.length, 0);
391
+ if (bytesRead === 0) return false;
392
+ } catch {
393
+ return false;
394
+ } finally {
395
+ try {
396
+ await handle?.close();
397
+ } catch {
398
+ // The evidence file may disappear while it is being inspected.
399
+ }
400
+ }
401
+ const ascii = header.toString("ascii");
402
+ return (
403
+ header.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) ||
404
+ header.subarray(0, 3).equals(Buffer.from([255, 216, 255])) ||
405
+ ascii.startsWith("GIF87a") ||
406
+ ascii.startsWith("GIF89a") ||
407
+ (ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") ||
408
+ ascii.slice(4, 8) === "ftyp" ||
409
+ header.subarray(0, 4).equals(Buffer.from([26, 69, 223, 163]))
410
+ );
411
+ }
@@ -0,0 +1,112 @@
1
+ import { clampAttempts } from "./shared.js";
2
+
3
+ export const DEFAULT_TOKEN_BUDGET = 250_000;
4
+ export const DEFAULT_RETRY_BUDGET = 2;
5
+ const MAX_TOKEN_BUDGET = 10_000_000;
6
+
7
+ export function resolveRunBudgets(request = {}) {
8
+ const legacyMaxAttempts = clampAttempts(request.max_attempts);
9
+ const explicitRetryBudget = integer(request.retry_budget);
10
+ const retryBudget =
11
+ explicitRetryBudget === null
12
+ ? legacyMaxAttempts - 1
13
+ : Math.min(4, Math.max(0, explicitRetryBudget));
14
+ return {
15
+ token_budget: normalizeTokenBudget(request.token_budget),
16
+ retry_budget: retryBudget,
17
+ max_attempts: retryBudget + 1,
18
+ };
19
+ }
20
+
21
+ export function budgetState(snapshot, pendingAttempt = null) {
22
+ const attempts = [
23
+ ...(Array.isArray(snapshot?.attempts) ? snapshot.attempts : []),
24
+ ...(pendingAttempt ? [pendingAttempt] : []),
25
+ ];
26
+ const tokenBudget = normalizeTokenBudget(snapshot?.charter?.token_budget);
27
+ const retryBudget = normalizeRetryBudget(
28
+ snapshot?.charter?.retry_budget,
29
+ snapshot?.charter?.max_attempts,
30
+ );
31
+ const tokensUsed = attempts.reduce(
32
+ (sum, attempt) => sum + tokenUsageTotal(attempt?.token_usage),
33
+ 0,
34
+ );
35
+ const attemptsUsed = attempts.length;
36
+ const retriesUsed = Math.max(attemptsUsed - 1, 0);
37
+ const tokenExhausted = tokensUsed >= tokenBudget;
38
+ const retryExhausted = attemptsUsed > 0 && retriesUsed >= retryBudget;
39
+ return {
40
+ schema_version: 1,
41
+ token_budget: tokenBudget,
42
+ tokens_used: tokensUsed,
43
+ tokens_remaining: Math.max(tokenBudget - tokensUsed, 0),
44
+ token_exhausted: tokenExhausted,
45
+ retry_budget: retryBudget,
46
+ retries_used: retriesUsed,
47
+ retries_remaining: Math.max(retryBudget - retriesUsed, 0),
48
+ retry_exhausted: retryExhausted,
49
+ exhausted: tokenExhausted || retryExhausted,
50
+ exhausted_reason: tokenExhausted
51
+ ? "token_budget_exhausted"
52
+ : retryExhausted
53
+ ? "retry_budget_exhausted"
54
+ : null,
55
+ };
56
+ }
57
+
58
+ export function refreshBudgetState(snapshot, pendingAttempt = null) {
59
+ const state = budgetState(snapshot, pendingAttempt);
60
+ snapshot.budget = state;
61
+ return state;
62
+ }
63
+
64
+ export function canStartProvider(snapshot, { attemptNumber, phase }) {
65
+ const state = refreshBudgetState(snapshot);
66
+ if (state.token_exhausted) {
67
+ return { allowed: false, reason: "token_budget_exhausted", budget: state };
68
+ }
69
+ if (phase === "executor" && attemptNumber > state.retry_budget + 1) {
70
+ return { allowed: false, reason: "retry_budget_exhausted", budget: state };
71
+ }
72
+ return { allowed: true, reason: null, budget: state };
73
+ }
74
+
75
+ export function tokenUsageTotal(value) {
76
+ if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
77
+ if (Array.isArray(value.records)) {
78
+ return value.records.reduce((sum, record) => sum + recordTokenTotal(record), 0);
79
+ }
80
+ return recordTokenTotal(value);
81
+ }
82
+
83
+ function recordTokenTotal(value) {
84
+ if (!value || typeof value !== "object" || Array.isArray(value)) return 0;
85
+ const explicit = nonNegativeNumber(value.total_tokens);
86
+ if (explicit !== null) return explicit;
87
+ return (
88
+ (nonNegativeNumber(value.tokens_in ?? value.input_tokens) ?? 0) +
89
+ (nonNegativeNumber(value.tokens_out ?? value.output_tokens) ?? 0)
90
+ );
91
+ }
92
+
93
+ function normalizeTokenBudget(value) {
94
+ const parsed = integer(value);
95
+ if (parsed === null || parsed <= 0) return DEFAULT_TOKEN_BUDGET;
96
+ return Math.min(MAX_TOKEN_BUDGET, parsed);
97
+ }
98
+
99
+ function normalizeRetryBudget(value, maxAttempts) {
100
+ const parsed = integer(value);
101
+ if (parsed !== null) return Math.min(4, Math.max(0, parsed));
102
+ return clampAttempts(maxAttempts) - 1;
103
+ }
104
+
105
+ function integer(value) {
106
+ const parsed = Number.parseInt(String(value ?? ""), 10);
107
+ return Number.isFinite(parsed) ? parsed : null;
108
+ }
109
+
110
+ function nonNegativeNumber(value) {
111
+ return Number.isFinite(value) && value >= 0 ? Math.round(value) : null;
112
+ }