Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 47 additions & 36 deletions .githooks/pre-commit
Original file line number Diff line number Diff line change
@@ -1,43 +1,54 @@
#!/usr/bin/env bash
# Pre-commit hook: auto-rebuild console build artifacts
# Ensures pilot/scripts/*.cjs and pilot/ui/ assets are always in sync with console/src/

set -e

# Build artifacts that must stay in sync with console source
ARTIFACTS=(
"pilot/scripts/mcp-server.cjs"
"pilot/scripts/worker-service.cjs"
"pilot/scripts/context-generator.cjs"
"pilot/scripts/worker-wrapper.cjs"
"pilot/ui/viewer-bundle.js"
"pilot/ui/viewer.css"
)

# Check if any console source files are staged
CONSOLE_CHANGED=$(git diff --cached --name-only -- 'console/src/' 'console/scripts/' 'console/package.json' 'console/tsconfig.json' 'console/vite.config.ts' | head -1)

if [ -z "$CONSOLE_CHANGED" ]; then
exit 0
fi
# Pre-commit hook: test gates + auto-rebuild console build artifacts
#
# 1. Python unit tests — when launcher/ or pilot/hooks/ changed
# 2. Console unit tests — when console/ changed
# 3. Console typecheck + build + stage artifacts — when console/src/ changed

echo "[pre-commit] Console source changed — rebuilding artifacts..."
set -eo pipefail

# Verify node_modules exist
if [ ! -d "console/node_modules" ]; then
echo "[pre-commit] ERROR: console/node_modules missing. Run: cd console && npm install"
exit 1
fi
# --- 1. Python unit tests ---
PYTHON_CHANGED=$(git diff --cached --name-only -- 'launcher/' 'pilot/hooks/' | head -1)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

set -eo pipefail + head -1 causes spurious hook abort when ≥2 files are staged

With pipefail in effect, code like somecmd | head -n1 will sometimes cause an error, depending on whether the output of somecmd exceeds the size of the pipe buffer or not. When two or more Python or console files are staged simultaneously, git diff gets SIGPIPE (exit 141) as soon as head -1 closes the pipe. Exit status 141 indicates the program was terminated by SIGPIPE (141 − 128 = 13). When running scripts with set -o pipefail, this terminates the script.

Net result: the hook aborts before any tests run whenever a multi-file commit touches launcher/, pilot/hooks/, or console/.

The head -1 was likely added as a performance short-circuit, but since git diff --cached --name-only is fast for a local index check, it's not necessary:

🐛 Proposed fix — remove head -1 to eliminate the SIGPIPE entirely
-PYTHON_CHANGED=$(git diff --cached --name-only -- 'launcher/' 'pilot/hooks/' | head -1)
+PYTHON_CHANGED=$(git diff --cached --name-only -- 'launcher/' 'pilot/hooks/')
-CONSOLE_CHANGED=$(git diff --cached --name-only -- 'console/src/' 'console/scripts/' 'console/package.json' 'console/tsconfig.json' 'console/vite.config.ts' | head -1)
+CONSOLE_CHANGED=$(git diff --cached --name-only -- 'console/src/' 'console/scripts/' 'console/package.json' 'console/tsconfig.json' 'console/vite.config.ts')

git diff --cached --name-only exits 0 in all cases; removing head -1 makes PYTHON_CHANGED/CONSOLE_CHANGED capture the full list of changed files (still non-empty ⟹ truthy), with no SIGPIPE risk.

Also applies to: 20-20

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.githooks/pre-commit at line 11, The hook aborts due to SIGPIPE when using
head -1 with set -eo pipefail; remove the piping to head (i.e., change the
assignment to PYTHON_CHANGED to use the full output of git diff --cached
--name-only -- 'launcher/' 'pilot/hooks/' without | head -1) so the command
always exits 0 and PYTHON_CHANGED remains truthy when files are staged; update
the analogous CONSOLE_CHANGED/other occurrences that use | head -1 the same way
to eliminate the pipefail risk.


# Rebuild
cd console
if ! npm run build 2>&1 | tail -3; then
echo "[pre-commit] ERROR: Console build failed. Fix build errors before committing."
exit 1
if [ -n "$PYTHON_CHANGED" ]; then
echo "[pre-commit] Python source changed — running unit tests..."
uv run pytest launcher/tests/ pilot/hooks/tests/ -q --tb=short 2>&1 | tail -5
echo "[pre-commit] Python unit tests passed."
fi
cd ..

# Stage the rebuilt artifacts
git add "${ARTIFACTS[@]}"
# --- 2. Console unit tests ---
CONSOLE_CHANGED=$(git diff --cached --name-only -- 'console/src/' 'console/scripts/' 'console/package.json' 'console/tsconfig.json' 'console/vite.config.ts' | head -1)

echo "[pre-commit] Console artifacts rebuilt and staged."
if [ -n "$CONSOLE_CHANGED" ]; then
# Verify node_modules exist
if [ ! -d "console/node_modules" ]; then
echo "[pre-commit] ERROR: console/node_modules missing. Run: cd console && bun install"
exit 1
fi

echo "[pre-commit] Console source changed — running unit tests..."
(cd console && bun test 2>&1) | tail -5
echo "[pre-commit] Console unit tests passed."

# --- 3. Console typecheck + build + stage artifacts ---
echo "[pre-commit] Running typecheck..."
(cd console && bun run typecheck 2>&1) | tail -5
echo "[pre-commit] Console typecheck passed."

echo "[pre-commit] Rebuilding console artifacts..."
(cd console && npm run build 2>&1) | tail -3
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

npm run build is inconsistent — the rest of the script uses bun

Lines 30 and 35 use bun test and bun run typecheck; line 39 reverts to npm run build. If developers have only bun installed (not npm), the build step will fail unexpectedly after the tests pass.

🛠️ Proposed fix
-  (cd console && npm run build 2>&1) | tail -3
+  (cd console && bun run build 2>&1) | tail -3
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
(cd console && npm run build 2>&1) | tail -3
(cd console && bun run build 2>&1) | tail -3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In @.githooks/pre-commit at line 39, Replace the inconsistent npm invocation in
the pre-commit hook: the line using "(cd console && npm run build 2>&1) | tail
-3" should use bun like the other lines (e.g., "bun run build") so the hook
consistently relies on bun; update that command to "(cd console && bun run build
2>&1) | tail -3" to match the existing use of "bun test" and "bun run
typecheck".

echo ""

# Build artifacts that must stay in sync with console source
ARTIFACTS=(
"pilot/scripts/mcp-server.cjs"
"pilot/scripts/worker-service.cjs"
"pilot/scripts/context-generator.cjs"
"pilot/scripts/worker-wrapper.cjs"
"pilot/ui/viewer-bundle.js"
"pilot/ui/viewer.css"
)

git add "${ARTIFACTS[@]}"
echo "[pre-commit] Console artifacts rebuilt and staged."
fi
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ playwright/
.claude/skills/
.claude/rules/
.claude/.pilot-*-baseline.json
.DS_Store

# Build artifacts
docs/plans
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,7 @@ Pilot uses the right model for each phase — Opus where reasoning quality matte

**The insight:** Implementation is the easy part when the plan is good and verification is thorough. Pilot invests reasoning power where it has the highest impact — planning and verification — and uses fast execution where a clear spec makes quality predictable.

**Configurable:** All model assignments are configurable per-component via the Pilot Console (`localhost:41777/#/settings`). Choose between Sonnet 4.6, Sonnet 4.6 1M, Opus 4.6, and Opus 4.6 1M for the main session and each command. Sub-agents always use the base model (no 1M). **Note:** 1M context models require a compatible Anthropic subscription — not available to all users.
**Configurable:** All model assignments are configurable per-component via the Pilot Console (`localhost:41777/#/settings`). Choose between Sonnet 4.6 and Opus 4.6 for the main session, each command, and sub-agents. A global "Extended Context (1M)" toggle enables the 1M token context window across all models simultaneously. **Note:** 1M context models require a Max (20x) or Enterprise subscription — not available to all users.

### Quick Mode

Expand Down
117 changes: 90 additions & 27 deletions console/src/services/worker/http/routes/SettingsRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,18 @@ import * as path from "path";
import { BaseRouteHandler } from "../BaseRouteHandler.js";
import { logger } from "../../../../utils/logger.js";

export const MODEL_CHOICES_FULL: readonly string[] = ["sonnet", "sonnet[1m]", "opus", "opus[1m]"];
export const MODEL_CHOICES_AGENT: readonly string[] = ["sonnet", "opus"];
export const MODEL_CHOICES: readonly string[] = ["sonnet", "opus"];

export interface ModelSettings {
model: string;
extendedContext: boolean;
commands: Record<string, string>;
agents: Record<string, string>;
}

export const DEFAULT_SETTINGS: ModelSettings = {
model: "opus",
extendedContext: false,
commands: {
spec: "sonnet",
"spec-plan": "opus",
Expand All @@ -47,7 +48,8 @@ export class SettingsRoutes extends BaseRouteHandler {

constructor(configPath?: string) {
super();
this.configPath = configPath ?? path.join(os.homedir(), ".pilot", "config.json");
this.configPath =
configPath ?? path.join(os.homedir(), ".pilot", "config.json");
}

setupRoutes(app: express.Application): void {
Expand All @@ -64,49 +66,98 @@ export class SettingsRoutes extends BaseRouteHandler {
}
}

private static stripLegacy1m(model: string): string {
return model.replace("[1m]", "");
}

private mergeWithDefaults(raw: Record<string, unknown>): ModelSettings {
const mainModel =
typeof raw.model === "string" && MODEL_CHOICES_FULL.includes(raw.model)
? raw.model
let hasLegacy1m =
typeof raw.model === "string" && raw.model.includes("[1m]");

let mainModel =
typeof raw.model === "string"
? SettingsRoutes.stripLegacy1m(raw.model)
: DEFAULT_SETTINGS.model;
if (!MODEL_CHOICES.includes(mainModel)) {
mainModel = DEFAULT_SETTINGS.model;
}

const rawCommands = raw.commands;
const mergedCommands: Record<string, string> = { ...DEFAULT_SETTINGS.commands };
if (rawCommands && typeof rawCommands === "object" && !Array.isArray(rawCommands)) {
for (const [k, v] of Object.entries(rawCommands as Record<string, unknown>)) {
if (typeof v === "string" && MODEL_CHOICES_FULL.includes(v)) {
mergedCommands[k] = v;
const mergedCommands: Record<string, string> = {
...DEFAULT_SETTINGS.commands,
};
if (
rawCommands &&
typeof rawCommands === "object" &&
!Array.isArray(rawCommands)
) {
for (const [k, v] of Object.entries(
rawCommands as Record<string, unknown>,
)) {
if (typeof v === "string") {
if (v.includes("[1m]")) hasLegacy1m = true;
const stripped = SettingsRoutes.stripLegacy1m(v);
if (MODEL_CHOICES.includes(stripped)) {
mergedCommands[k] = stripped;
}
}
}
}

const rawAgents = raw.agents;
const mergedAgents: Record<string, string> = { ...DEFAULT_SETTINGS.agents };
if (rawAgents && typeof rawAgents === "object" && !Array.isArray(rawAgents)) {
for (const [k, v] of Object.entries(rawAgents as Record<string, unknown>)) {
if (typeof v === "string" && MODEL_CHOICES_AGENT.includes(v)) {
mergedAgents[k] = v;
if (
rawAgents &&
typeof rawAgents === "object" &&
!Array.isArray(rawAgents)
) {
for (const [k, v] of Object.entries(
rawAgents as Record<string, unknown>,
)) {
if (typeof v === "string") {
const stripped = SettingsRoutes.stripLegacy1m(v);
if (MODEL_CHOICES.includes(stripped)) {
mergedAgents[k] = stripped;
}
}
}
}
Comment on lines +109 to 124
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Agents loop doesn't set hasLegacy1m — asymmetry with the commands loop

The commands loop (lines 94–104) correctly does if (v.includes("[1m]")) hasLegacy1m = true; before stripping. The agents loop strips and validates but never updates hasLegacy1m. A config that has only legacy [1m] agent values (but a clean main model and commands) will silently get extendedContext: false after migration, losing the 1M context the user previously had.

🐛 Proposed fix
       for (const [k, v] of Object.entries(
         rawAgents as Record<string, unknown>,
       )) {
         if (typeof v === "string") {
+          if (v.includes("[1m]")) hasLegacy1m = true;
           const stripped = SettingsRoutes.stripLegacy1m(v);
           if (MODEL_CHOICES.includes(stripped)) {
             mergedAgents[k] = stripped;
           }
         }
       }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if (
rawAgents &&
typeof rawAgents === "object" &&
!Array.isArray(rawAgents)
) {
for (const [k, v] of Object.entries(
rawAgents as Record<string, unknown>,
)) {
if (typeof v === "string") {
const stripped = SettingsRoutes.stripLegacy1m(v);
if (MODEL_CHOICES.includes(stripped)) {
mergedAgents[k] = stripped;
}
}
}
}
if (
rawAgents &&
typeof rawAgents === "object" &&
!Array.isArray(rawAgents)
) {
for (const [k, v] of Object.entries(
rawAgents as Record<string, unknown>,
)) {
if (typeof v === "string") {
if (v.includes("[1m]")) hasLegacy1m = true;
const stripped = SettingsRoutes.stripLegacy1m(v);
if (MODEL_CHOICES.includes(stripped)) {
mergedAgents[k] = stripped;
}
}
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@console/src/services/worker/http/routes/SettingsRoutes.ts` around lines 109 -
124, In the agents-processing block inside SettingsRoutes, detect legacy "[1m]"
the same way as the commands loop: before calling SettingsRoutes.stripLegacy1m
on each agent value (the loop iterating over rawAgents entries and assigning
into mergedAgents), check if the original string v includes "[1m]" and set
hasLegacy1m = true when so; then continue to strip with
SettingsRoutes.stripLegacy1m and validate against MODEL_CHOICES as currently
implemented. Ensure you reference the existing variables/funcs mergedAgents,
hasLegacy1m, MODEL_CHOICES, SettingsRoutes.stripLegacy1m, and rawAgents so the
change is localized to that agents loop.


return { model: mainModel, commands: mergedCommands, agents: mergedAgents };
const extendedContext = raw.extendedContext === true || hasLegacy1m;

return {
model: mainModel,
extendedContext,
commands: mergedCommands,
agents: mergedAgents,
};
}

private validateSettings(body: Record<string, unknown>): string | null {
if (body.model !== undefined) {
if (typeof body.model !== "string" || !MODEL_CHOICES_FULL.includes(body.model)) {
return `Invalid model '${body.model}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`;
if (
typeof body.model !== "string" ||
!MODEL_CHOICES.includes(body.model)
) {
return `Invalid model '${body.model}'; must be one of: ${MODEL_CHOICES.join(", ")}`;
}
}

if (body.extendedContext !== undefined) {
if (typeof body.extendedContext !== "boolean") {
return "extendedContext must be a boolean";
}
}

if (body.commands !== undefined) {
if (typeof body.commands !== "object" || Array.isArray(body.commands)) {
return "commands must be an object";
}
for (const [cmd, model] of Object.entries(body.commands as Record<string, unknown>)) {
if (typeof model !== "string" || !MODEL_CHOICES_FULL.includes(model)) {
return `Invalid model '${model}' for command '${cmd}'; must be one of: ${MODEL_CHOICES_FULL.join(", ")}`;
for (const [cmd, model] of Object.entries(
body.commands as Record<string, unknown>,
)) {
if (typeof model !== "string" || !MODEL_CHOICES.includes(model)) {
return `Invalid model '${model}' for command '${cmd}'; must be one of: ${MODEL_CHOICES.join(", ")}`;
}
}
}
Expand All @@ -115,9 +166,11 @@ export class SettingsRoutes extends BaseRouteHandler {
if (typeof body.agents !== "object" || Array.isArray(body.agents)) {
return "agents must be an object";
}
for (const [agent, model] of Object.entries(body.agents as Record<string, unknown>)) {
if (typeof model !== "string" || !MODEL_CHOICES_AGENT.includes(model)) {
return `Invalid model '${model}' for agent '${agent}'; agents can only use: ${MODEL_CHOICES_AGENT.join(", ")} (no 1M context)`;
for (const [agent, model] of Object.entries(
body.agents as Record<string, unknown>,
)) {
if (typeof model !== "string" || !MODEL_CHOICES.includes(model)) {
return `Invalid model '${model}' for agent '${agent}'; must be one of: ${MODEL_CHOICES.join(", ")}`;
}
}
}
Expand Down Expand Up @@ -153,13 +206,23 @@ export class SettingsRoutes extends BaseRouteHandler {
if (body.model !== undefined) {
existing.model = body.model;
}
if (body.extendedContext !== undefined) {
existing.extendedContext = body.extendedContext;
}
if (body.commands !== undefined) {
const existingCommands = (existing.commands as Record<string, unknown>) ?? {};
existing.commands = { ...existingCommands, ...(body.commands as Record<string, unknown>) };
const existingCommands =
(existing.commands as Record<string, unknown>) ?? {};
existing.commands = {
...existingCommands,
...(body.commands as Record<string, unknown>),
};
}
if (body.agents !== undefined) {
const existingAgents = (existing.agents as Record<string, unknown>) ?? {};
existing.agents = { ...existingAgents, ...(body.agents as Record<string, unknown>) };
existing.agents = {
...existingAgents,
...(body.agents as Record<string, unknown>),
};
}

try {
Expand Down
Loading
Loading