@pify/autopilot 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 pifydev
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,51 @@
1
+ # @pify/autopilot
2
+
3
+ [![CI](https://github.com/pifydev/autopilot/actions/workflows/ci.yml/badge.svg)](https://github.com/pifydev/autopilot/actions/workflows/ci.yml) [![npm version](https://img.shields.io/npm/v/@pify/autopilot)](https://www.npmjs.com/package/@pify/autopilot) [![npm downloads](https://img.shields.io/npm/dm/@pify/autopilot)](https://www.npmjs.com/package/@pify/autopilot)
4
+
5
+ Let the main [pi](https://github.com/earendil-works/pi) agent keep going on its own between turns β€” **opt-in, hard-capped, and never over your head**. Arm it toward a goal and it drives the next turn itself until the work is done, the cap is hit, or it stops making progress.
6
+
7
+ Part of the [Pify suite](https://github.com/pifydev). Install with [`pify install autopilot`](https://github.com/pifydev/cli) or `pi install npm:@pify/autopilot`.
8
+
9
+ ## Why
10
+
11
+ The suite already loop-guards *child* agents (`@pify/subagent`, `@pify/swarm`), but the *primary* session still stops after every turn and waits for you. For a well-scoped task you'd rather set it going and step away. Autopilot does that β€” carefully.
12
+
13
+ ## Safety first
14
+
15
+ An unbounded self-driving agent is the dangerous kind, so every default is a brake:
16
+
17
+ - **Off by default.** It does nothing until you arm it with `/autopilot on`.
18
+ - **A hard turn cap** it can never exceed, even via settings (ceiling 200).
19
+ - **A no-progress breaker** β€” the same guard `@pify/subagent` uses β€” stops it the moment it repeats itself instead of working.
20
+ - **Never over your head.** While a dialog is open (an `ask_question`, a confirm), it waits rather than driving a turn.
21
+ - **A clean finish.** The agent ends its reply with `<autopilot-done>` when the task is complete, and autopilot stops.
22
+ - **Always visible.** A `πŸ…° autopilot N/max` footer while armed, and a notification with the reason every time it stops.
23
+
24
+ It drives each turn on `agent_settled` (fully idle) via a triggered message, so it never talks over a running turn. Arming is interactive-only, so a headless `pi -p` run never self-drives.
25
+
26
+ ## Use
27
+
28
+ ```
29
+ /autopilot on refactor auth.ts to use the new session API and make the tests pass
30
+ /autopilot status # armed? turns used, goal
31
+ /autopilot off # stop now
32
+ ```
33
+
34
+ Give a goal (recommended) and it works toward that, one concrete step per turn; without one it just continues the current task. It stops on any of: the done signal, the turn cap, no progress, an error, or `/autopilot off`.
35
+
36
+ ## Settings
37
+
38
+ `.pi/autopilot.json` (project) or `<agentDir>/autopilot.json` (global):
39
+
40
+ ```json
41
+ {
42
+ "maxTurns": 10,
43
+ "maxUnchangedTurns": 3
44
+ }
45
+ ```
46
+
47
+ `maxTurns` (1–200) is the hard cap per arming; `maxUnchangedTurns` (2–10) is how many turns with no real progress count as stuck. `PIFY_AUTOPILOT_MAX_TURNS` overrides the cap for one run. Bad values fall back to the defaults with a warning.
48
+
49
+ ## License
50
+
51
+ MIT Β© [Pify maintainers](https://github.com/pifydev)
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @pify/autopilot β€” let the main agent keep going on its own, safely.
3
+ *
4
+ * The suite loop-guards CHILD agents (subagent/swarm) but nothing keeps the
5
+ * PRIMARY session moving without you pressing enter each turn. This does β€” and
6
+ * because an unbounded self-driving agent is the dangerous kind, every default
7
+ * here is a brake:
8
+ *
9
+ * - OFF by default; you arm it explicitly with `/autopilot on [goal]`.
10
+ * - a hard turn cap (maxTurns) it can never exceed, even via settings;
11
+ * - a no-progress breaker (the same LoopGuard subagent uses) that stops it
12
+ * the moment it repeats itself instead of working;
13
+ * - it never drives a turn while a dialog is open (you are being asked
14
+ * something), and it stops the instant the agent emits the done token;
15
+ * - a visible footer while armed, and a notification with the reason whenever
16
+ * it stops.
17
+ *
18
+ * It drives a turn on `agent_settled` (fully idle) via sendMessage triggerTurn,
19
+ * so it never talks over a running turn. Zero runtime dependencies.
20
+ */
21
+ import { getAgentDir, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
22
+ import { readFileSync } from "node:fs";
23
+ import { join } from "node:path";
24
+
25
+ import { decide, hasDoneSignal, DONE_TOKEN } from "../src/decide.ts";
26
+ import { DEFAULT_SETTINGS, resolveSettings, type AutopilotSettings } from "../src/settings.ts";
27
+ import { LoopGuard } from "../src/loop-guard.ts";
28
+
29
+ type UiContext = ExtensionContext;
30
+ const STATUS = "autopilot";
31
+ const NUDGE_TYPE = "pify-autopilot-nudge";
32
+
33
+ export default function autopilot(pi: ExtensionAPI) {
34
+ let settings: AutopilotSettings = DEFAULT_SETTINGS;
35
+ let armed = false;
36
+ let turns = 0;
37
+ let goal: string | null = null;
38
+ let blocked = false;
39
+ let done = false;
40
+ let stalled = false;
41
+ let guard = new LoopGuard();
42
+ let lastCtx: UiContext | null = null;
43
+
44
+ function loadSettings(cwd: string): string[] {
45
+ for (const file of [join(cwd, ".pi", "autopilot.json"), join(getAgentDir(), "autopilot.json")]) {
46
+ let raw: string;
47
+ try {
48
+ raw = readFileSync(file, "utf8");
49
+ } catch {
50
+ continue;
51
+ }
52
+ try {
53
+ const parsed = resolveSettings(JSON.parse(raw));
54
+ settings = parsed.settings;
55
+ return parsed.warnings;
56
+ } catch (err) {
57
+ settings = DEFAULT_SETTINGS;
58
+ return [`${file}: ${err instanceof Error ? err.message : String(err)}`];
59
+ }
60
+ }
61
+ settings = resolveSettings(undefined).settings;
62
+ return [];
63
+ }
64
+
65
+ function renderStatus(ctx: UiContext | null = lastCtx): void {
66
+ if (!ctx || !ctx.hasUI) return;
67
+ ctx.ui.setStatus(STATUS, armed ? `πŸ…° autopilot ${turns}/${settings.maxTurns}` : undefined);
68
+ }
69
+
70
+ function disarm(ctx: UiContext, reason: string): void {
71
+ if (!armed) return;
72
+ armed = false;
73
+ goal = null;
74
+ done = false;
75
+ stalled = false;
76
+ turns = 0;
77
+ if (ctx.hasUI) {
78
+ ctx.ui.notify(`Autopilot ${reason}.`, "info");
79
+ renderStatus(ctx);
80
+ }
81
+ }
82
+
83
+ function nudgeText(): string {
84
+ const close = `When the task is fully complete, end your reply with ${DONE_TOKEN} and stop.`;
85
+ return goal
86
+ ? `Keep working toward this goal, one concrete step at a time:\n${goal}\n\n${close}`
87
+ : `Continue the current task, one concrete step at a time. ${close}`;
88
+ }
89
+
90
+ async function drive(ctx: UiContext): Promise<void> {
91
+ turns++;
92
+ renderStatus(ctx);
93
+ try {
94
+ await pi.sendMessage(
95
+ { customType: NUDGE_TYPE, content: nudgeText(), display: false },
96
+ { triggerTurn: true, deliverAs: "nextTurn" },
97
+ );
98
+ } catch {
99
+ // A /reload or a busy session makes the captured handle throw; stop
100
+ // rather than leave a half-armed loop that never advances.
101
+ disarm(ctx, "stopped: could not drive the next turn");
102
+ }
103
+ }
104
+
105
+ // ── the loop ─────────────────────────────────────────────────────────
106
+
107
+ pi.on("session_start", async (_event, ctx) => {
108
+ lastCtx = ctx;
109
+ const warnings = loadSettings(ctx.cwd);
110
+ if (warnings.length > 0 && ctx.hasUI) ctx.ui.notify(`autopilot settings: ${warnings.join("; ")}`, "warning");
111
+ });
112
+
113
+ pi.on("ui_prompt_start", async () => {
114
+ blocked = true;
115
+ });
116
+ pi.on("ui_prompt_end", async () => {
117
+ blocked = false;
118
+ });
119
+
120
+ pi.on("agent_end", async (event, ctx) => {
121
+ lastCtx = ctx;
122
+ if (!armed) return;
123
+ const messages = (event as { messages?: Array<{ role?: string; content?: unknown }> }).messages ?? [];
124
+ const last = [...messages].reverse().find((m) => m.role === "assistant");
125
+ const content = Array.isArray(last?.content) ? (last!.content as Array<Record<string, unknown>>) : [];
126
+ const usedTool = content.some((b) => b.type === "toolCall");
127
+ const text = content
128
+ .filter((b) => b.type === "text" && typeof b.text === "string")
129
+ .map((b) => b.text as string)
130
+ .join("\n");
131
+ if (hasDoneSignal(text)) done = true;
132
+ // Feed the no-progress breaker; a stall stops autopilot at the next settle.
133
+ stalled = guard.observe({ text, usedTool }).stalled;
134
+ });
135
+
136
+ pi.on("agent_settled", async (_event, ctx) => {
137
+ lastCtx = ctx;
138
+ if (!armed) return;
139
+ const decision = decide({ armed, turns, maxTurns: settings.maxTurns, blocked, stalled, done });
140
+ if (decision.action === "continue") await drive(ctx);
141
+ else if (decision.action === "stop") disarm(ctx, decision.reason);
142
+ // "wait": stay armed, do nothing this settle
143
+ });
144
+
145
+ pi.registerCommand("autopilot", {
146
+ description: "Keep the agent going on its own: /autopilot [on [goal] | off | status]",
147
+ handler: async (args, ctx: UiContext) => {
148
+ if (!ctx.hasUI) return; // arming is interactive-only; headless never self-drives
149
+ lastCtx = ctx;
150
+ const [verb, ...rest] = (args ?? "").trim().split(/\s+/);
151
+ const v = (verb ?? "").toLowerCase();
152
+
153
+ if (v === "off") {
154
+ if (armed) disarm(ctx, "off");
155
+ else ctx.ui.notify("Autopilot is already off.", "info");
156
+ return;
157
+ }
158
+ if (v === "" || v === "status") {
159
+ ctx.ui.notify(
160
+ armed
161
+ ? `Autopilot on: ${turns}/${settings.maxTurns} turns used${goal ? `, goal: ${goal}` : ""}.`
162
+ : `Autopilot off. Arm with /autopilot on [goal]. Caps: ${settings.maxTurns} turns, stop after ${settings.maxUnchangedTurns} unchanged.`,
163
+ "info",
164
+ );
165
+ return;
166
+ }
167
+ if (v === "on") {
168
+ armed = true;
169
+ turns = 0;
170
+ done = false;
171
+ stalled = false;
172
+ guard = new LoopGuard({ repeat: settings.maxUnchangedTurns });
173
+ goal = rest.join(" ").trim() || null;
174
+ ctx.ui.notify(`Autopilot on (cap ${settings.maxTurns} turns)${goal ? `, working toward: ${goal}` : ""}.`, "info");
175
+ await drive(ctx); // start working immediately
176
+ return;
177
+ }
178
+ ctx.ui.notify("Usage: /autopilot [on [goal] | off | status]", "warning");
179
+ },
180
+ });
181
+ }
package/package.json ADDED
@@ -0,0 +1,75 @@
1
+ {
2
+ "name": "@pify/autopilot",
3
+ "version": "0.1.0",
4
+ "description": "Let the main agent keep going on its own between turns β€” opt-in, hard-capped by turn count and a no-progress breaker, and it never runs while you are being asked something",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "pi",
9
+ "pify",
10
+ "autopilot",
11
+ "autonomous",
12
+ "agent"
13
+ ],
14
+ "homepage": "https://github.com/pifydev/autopilot#readme",
15
+ "bugs": {
16
+ "url": "https://github.com/pifydev/autopilot/issues"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/pifydev/autopilot.git"
21
+ },
22
+ "license": "MIT",
23
+ "author": "Pify maintainers",
24
+ "type": "module",
25
+ "engines": {
26
+ "node": ">=22.19.0"
27
+ },
28
+ "files": [
29
+ "extensions",
30
+ "src",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "pi": {
35
+ "extensions": [
36
+ "./extensions/autopilot.ts"
37
+ ]
38
+ },
39
+ "scripts": {
40
+ "typecheck": "tsc --noEmit",
41
+ "test": "bun test",
42
+ "prepublishOnly": "npm run typecheck && npm test"
43
+ },
44
+ "peerDependencies": {
45
+ "@earendil-works/pi-ai": "*",
46
+ "@earendil-works/pi-coding-agent": "*",
47
+ "@earendil-works/pi-tui": "*",
48
+ "typebox": "*"
49
+ },
50
+ "peerDependenciesMeta": {
51
+ "@earendil-works/pi-ai": {
52
+ "optional": true
53
+ },
54
+ "@earendil-works/pi-coding-agent": {
55
+ "optional": true
56
+ },
57
+ "@earendil-works/pi-tui": {
58
+ "optional": true
59
+ },
60
+ "typebox": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@earendil-works/pi-ai": "^0.85.1",
66
+ "@earendil-works/pi-coding-agent": "^0.85.1",
67
+ "@earendil-works/pi-tui": "^0.85.1",
68
+ "@types/node": "^22.10.2",
69
+ "typebox": "^1.1.38",
70
+ "typescript": "^5.7.2"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public"
74
+ }
75
+ }
package/src/decide.ts ADDED
@@ -0,0 +1,49 @@
1
+ /**
2
+ * The one decision that matters, pure and testable: after a turn settles,
3
+ * should autopilot drive another turn, stop, or just wait?
4
+ *
5
+ * The ordering encodes the safety priorities. Not armed β†’ do nothing. The
6
+ * agent said it is done β†’ stop. Blocked on a human question β†’ wait (never talk
7
+ * over the user). No progress across recent turns β†’ stop (it is spinning). Turn
8
+ * budget spent β†’ stop. Only then: continue.
9
+ */
10
+
11
+ export interface AutopilotState {
12
+ /** Explicitly turned on by the user this session. Off by default. */
13
+ armed: boolean;
14
+ /** Auto-continuations driven so far this run. */
15
+ turns: number;
16
+ /** Hard ceiling on auto-continuations. */
17
+ maxTurns: number;
18
+ /** A user-facing dialog is open β€” do not drive a turn over it. */
19
+ blocked: boolean;
20
+ /** The no-progress breaker flagged a stall (see loop-guard.ts). */
21
+ stalled: boolean;
22
+ /** The agent emitted the completion token. */
23
+ done: boolean;
24
+ }
25
+
26
+ export type AutopilotAction = "continue" | "stop" | "wait";
27
+
28
+ export interface AutopilotDecision {
29
+ action: AutopilotAction;
30
+ reason: string;
31
+ }
32
+
33
+ export function decide(state: AutopilotState): AutopilotDecision {
34
+ if (!state.armed) return { action: "wait", reason: "not armed" };
35
+ if (state.done) return { action: "stop", reason: "the agent reported the task complete" };
36
+ if (state.blocked) return { action: "wait", reason: "waiting on your answer" };
37
+ if (state.stalled) return { action: "stop", reason: "stopped: no progress across recent turns" };
38
+ if (state.turns >= state.maxTurns) {
39
+ return { action: "stop", reason: `stopped: reached the ${state.maxTurns}-turn limit` };
40
+ }
41
+ return { action: "continue", reason: `continuing (turn ${state.turns + 1}/${state.maxTurns})` };
42
+ }
43
+
44
+ /** The token the agent ends its reply with to end autopilot cleanly. */
45
+ export const DONE_TOKEN = "<autopilot-done>";
46
+
47
+ export function hasDoneSignal(text: string): boolean {
48
+ return text.toLowerCase().includes(DONE_TOKEN);
49
+ }
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Stop a child that has stopped making progress.
3
+ *
4
+ * A turn cap bounds what a runaway child can cost; it does not notice the
5
+ * characteristic autonomous-agent failure, which is cheaper per turn and just
6
+ * as stuck: restating the same plan every turn without calling a tool, or
7
+ * oscillating between two states forever. pi's own loop will happily let that
8
+ * run to the cap.
9
+ *
10
+ * So fingerprint each assistant turn and watch for two shapes. A turn that
11
+ * called a tool is progress and clears the history β€” the guard only fires on
12
+ * turns that did nothing but talk. `repeat` identical tool-free turns is a
13
+ * child spinning in place; an A-B-A-B… run of `cycle` cycles is one bouncing
14
+ * between two dead ends. Both are advisory signals the caller acts on (a
15
+ * spawned child is unattended, so acting means aborting it).
16
+ *
17
+ * Zero dependencies β€” node:crypto for the hash. Deterministic and pure given
18
+ * the sequence of turns, so it is unit-testable without a live child.
19
+ */
20
+ import { createHash } from "node:crypto";
21
+
22
+ export interface LoopGuardConfig {
23
+ /** Consecutive identical tool-free turns before flagging a stall (min 2, default 3). */
24
+ repeat?: number;
25
+ /** Repeats of a two-turn A-B cycle before flagging (min 2, default 3). */
26
+ cycle?: number;
27
+ }
28
+
29
+ export interface Turn {
30
+ /** The visible assistant text of the turn. */
31
+ text: string;
32
+ /** Did the turn invoke at least one tool? A tool call is progress. */
33
+ usedTool: boolean;
34
+ }
35
+
36
+ export interface LoopVerdict {
37
+ stalled: boolean;
38
+ reason?: string;
39
+ }
40
+
41
+ /** Fold away cosmetic differences so "the same thought" hashes the same. */
42
+ function fingerprint(text: string): string {
43
+ const norm = text.normalize("NFKC").toLowerCase().replace(/\s+/g, " ").trim();
44
+ return createHash("sha256").update(norm).digest("hex");
45
+ }
46
+
47
+ export class LoopGuard {
48
+ private readonly repeat: number;
49
+ private readonly cycle: number;
50
+ /** Recent tool-free fingerprints; a tool call clears this. */
51
+ private readonly recent: string[] = [];
52
+ private static readonly MAX = 16;
53
+
54
+ constructor(cfg: LoopGuardConfig = {}) {
55
+ this.repeat = Math.max(2, cfg.repeat ?? 3);
56
+ this.cycle = Math.max(2, cfg.cycle ?? 3);
57
+ }
58
+
59
+ observe(turn: Turn): LoopVerdict {
60
+ // A tool call is forward motion: forget the stall history entirely.
61
+ if (turn.usedTool) {
62
+ this.recent.length = 0;
63
+ return { stalled: false };
64
+ }
65
+ // A silent turn (no text, no tool) is not evidence of a loop by itself.
66
+ if (turn.text.trim() === "") return { stalled: false };
67
+
68
+ const fp = fingerprint(turn.text);
69
+ this.recent.push(fp);
70
+ if (this.recent.length > LoopGuard.MAX) this.recent.shift();
71
+
72
+ // Spinning in place: the last `repeat` tool-free turns are identical.
73
+ if (this.recent.length >= this.repeat && this.recent.slice(-this.repeat).every((f) => f === fp)) {
74
+ return { stalled: true, reason: `repeated the same output for ${this.repeat} turns without acting` };
75
+ }
76
+
77
+ // Oscillating: the last 2Γ—cycle turns are a strict A-B-A-B… alternation.
78
+ const need = 2 * this.cycle;
79
+ if (this.recent.length >= need) {
80
+ const tail = this.recent.slice(-need);
81
+ const a = tail[0]!;
82
+ const b = tail[1]!;
83
+ if (a !== b && tail.every((f, i) => f === (i % 2 === 0 ? a : b))) {
84
+ return { stalled: true, reason: `oscillated between two states for ${this.cycle} cycles without acting` };
85
+ }
86
+ }
87
+
88
+ return { stalled: false };
89
+ }
90
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Settings for @pify/autopilot. Autonomy is opt-in per session (armed with a
3
+ * command), so there is no "on by default" switch β€” only the safety bounds:
4
+ * how many turns it may drive, and how many unchanged turns count as stuck.
5
+ * `.pi/autopilot.json` (project) or `<agentDir>/autopilot.json` (global).
6
+ */
7
+
8
+ export interface AutopilotSettings {
9
+ /** Hard cap on auto-continuations per arming (1..MAX). */
10
+ maxTurns: number;
11
+ /** Identical/no-tool turns in a row that count as no progress β†’ stop. */
12
+ maxUnchangedTurns: number;
13
+ }
14
+
15
+ export const DEFAULT_SETTINGS: AutopilotSettings = {
16
+ maxTurns: 10,
17
+ maxUnchangedTurns: 3,
18
+ };
19
+
20
+ const LIMITS: Record<keyof AutopilotSettings, { min: number; max: number }> = {
21
+ // A ceiling even the setting can't exceed: autopilot must never be unbounded.
22
+ maxTurns: { min: 1, max: 200 },
23
+ maxUnchangedTurns: { min: 2, max: 10 },
24
+ };
25
+
26
+ export function resolveSettings(
27
+ raw: unknown,
28
+ env: NodeJS.ProcessEnv = process.env,
29
+ ): { settings: AutopilotSettings; warnings: string[] } {
30
+ const settings: AutopilotSettings = { ...DEFAULT_SETTINGS };
31
+ const warnings: string[] = [];
32
+
33
+ if (raw !== undefined && raw !== null) {
34
+ if (typeof raw !== "object" || Array.isArray(raw)) {
35
+ warnings.push("settings file is not an object β€” ignored");
36
+ } else {
37
+ for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
38
+ if (!(key in DEFAULT_SETTINGS)) {
39
+ warnings.push(`unknown setting "${key}"`);
40
+ continue;
41
+ }
42
+ const name = key as keyof AutopilotSettings;
43
+ if (typeof value !== "number" || !Number.isFinite(value)) {
44
+ warnings.push(`"${key}" must be a number β€” using ${DEFAULT_SETTINGS[name]}`);
45
+ continue;
46
+ }
47
+ settings[name] = clamp(name, value, warnings);
48
+ }
49
+ }
50
+ }
51
+
52
+ const envMax = env.PIFY_AUTOPILOT_MAX_TURNS;
53
+ if (envMax !== undefined && envMax !== "") {
54
+ const n = Number(envMax);
55
+ if (Number.isFinite(n)) settings.maxTurns = clamp("maxTurns", n, warnings);
56
+ else warnings.push(`PIFY_AUTOPILOT_MAX_TURNS="${envMax}" is not a number β€” ignored`);
57
+ }
58
+
59
+ return { settings, warnings };
60
+ }
61
+
62
+ function clamp(name: keyof AutopilotSettings, value: number, warnings: string[]): number {
63
+ const { min, max } = LIMITS[name];
64
+ const c = Math.round(Math.min(max, Math.max(min, value)));
65
+ if (c !== value) warnings.push(`"${name}" clamped to ${c} (allowed ${min}–${max})`);
66
+ return c;
67
+ }