From adcb563c698e68a2ce13b16f58447266ac6a82f8 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:17:28 +0000 Subject: [PATCH 01/19] feat: add copilot CLI steering hooks for time and run budgets Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 101 ++++++++ actions/setup/js/copilot_harness.test.cjs | 32 +++ actions/setup/js/copilot_steering_hook.cjs | 239 ++++++++++++++++++ .../setup/js/copilot_steering_hook.test.cjs | 80 ++++++ 4 files changed, 452 insertions(+) create mode 100644 actions/setup/js/copilot_steering_hook.cjs create mode 100644 actions/setup/js/copilot_steering_hook.test.cjs diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 698a6fd5211..cae201be3f7 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -67,6 +67,9 @@ const MAX_SCHEDULED_EXIT2_RETRIES = 1; // If prompt files are larger than this threshold, avoid inlining into argv. const PROMPT_FILE_INLINE_THRESHOLD_BYTES = 100 * 1024; const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB"; +const STEERING_HOOK_CONFIG_FILENAME = "gh-aw-steering.json"; +const DEFAULT_STEERING_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json"; +const DEFAULT_MAX_AUTOPILOT_RUNS = 1; // Pattern to detect transient CAPIError 400 in copilot output const CAPI_ERROR_400_PATTERN = /CAPIError:\s*400/; @@ -295,6 +298,100 @@ function resolvePromptFileArgs(args) { return resolvedArgs; } +/** + * Parse --max-autopilot-continues from copilot CLI args. + * @param {string[]} args + * @returns {number} + */ +function parseMaxAutopilotContinues(args) { + const index = args.indexOf("--max-autopilot-continues"); + if (index < 0 || index + 1 >= args.length) { + return 0; + } + const parsed = parseInt(args[index + 1], 10); + return Number.isFinite(parsed) && parsed > 0 ? parsed : 0; +} + +/** + * Compute the maximum number of autonomous runs (initial run + autopilot continuations). + * @param {string[]} args + * @returns {number} + */ +function computeMaxAutopilotRuns(args) { + if (!args.includes("--autopilot")) { + return DEFAULT_MAX_AUTOPILOT_RUNS; + } + const maxContinues = parseMaxAutopilotContinues(args); + if (maxContinues <= 0) { + return DEFAULT_MAX_AUTOPILOT_RUNS; + } + return maxContinues + 1; +} + +/** + * Build Copilot CLI hook config for gh-aw steering messages. + * @param {string} hookScriptPath + * @param {string} nodeExecPath + * @returns {{ version: number, hooks: Record> }} + */ +function buildSteeringHookConfig(hookScriptPath, nodeExecPath) { + const quotedNode = JSON.stringify(nodeExecPath); + const quotedHookScript = JSON.stringify(hookScriptPath); + return { + version: 1, + hooks: { + sessionStart: [ + { + type: "command", + bash: `${quotedNode} ${quotedHookScript} sessionStart`, + timeoutSec: 10, + }, + ], + agentStop: [ + { + type: "command", + bash: `${quotedNode} ${quotedHookScript} agentStop`, + timeoutSec: 10, + }, + ], + }, + }; +} + +/** + * Install Copilot CLI steering hooks in the workspace and export hook env vars. + * @param {string[]} resolvedArgs + */ +function installCopilotSteeringHooks(resolvedArgs) { + try { + const workspace = process.env.GITHUB_WORKSPACE || process.cwd(); + const hooksDir = path.join(workspace, ".github", "hooks"); + const hookConfigPath = path.join(hooksDir, STEERING_HOOK_CONFIG_FILENAME); + const hookScriptPath = path.join(__dirname, "copilot_steering_hook.cjs"); + + if (!fs.existsSync(hookScriptPath)) { + log(`warning: steering hook script not found at ${hookScriptPath}; skipping hook installation`); + return; + } + + const statePath = `${DEFAULT_STEERING_STATE_PATH}.${process.pid}`; + process.env.GH_AW_COPILOT_STEERING_STATE_PATH = statePath; + process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs)); + process.env.GH_AW_TIMEOUT_MINUTES = process.env.GH_AW_TIMEOUT_MINUTES || "30"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = process.env.GH_AW_STEERING_TIME_WARNING_MINUTES || "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES || "2"; + process.env.GH_AW_STEERING_RUN_WARNING_REMAINING = process.env.GH_AW_STEERING_RUN_WARNING_REMAINING || "2"; + process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING = process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING || "1"; + + fs.mkdirSync(hooksDir, { recursive: true }); + fs.writeFileSync(hookConfigPath, JSON.stringify(buildSteeringHookConfig(hookScriptPath, process.execPath), null, 2) + "\n", "utf8"); + log(`installed steering hook config: ${hookConfigPath}`); + } catch (error) { + const err = /** @type {Error} */ error; + log(`warning: failed to install steering hook config: ${err.message}`); + } +} + /** * Main entry point: run copilot with retry logic for partially-executed sessions. */ @@ -310,6 +407,7 @@ async function main() { await checkCommandAccessible(command); const resolvedArgs = resolvePromptFileArgs(args); + installCopilotSteeringHooks(resolvedArgs); // Fetch AWF API proxy reflection data before running the agent to capture initial proxy state. // This is best-effort: failures are logged but do not affect the agent run. @@ -479,7 +577,10 @@ if (typeof module !== "undefined" && module.exports) { extractModelIds, fetchAWFReflect, fetchModelsFromUrl, + buildSteeringHookConfig, + computeMaxAutopilotRuns, resolvePromptFileArgs, + parseMaxAutopilotContinues, }; } diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 47e602a233e..d0bd635db1e 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -7,14 +7,17 @@ import path from "path"; const require = createRequire(import.meta.url); const { appendSafeOutputLine, + buildSteeringHookConfig, buildInfrastructureIncompletePayload, buildPromptFileFallbackInstruction, + computeMaxAutopilotRuns, emitInfrastructureIncomplete, enrichReflectModels, extractModelIds, fetchAWFReflect, fetchModelsFromUrl, GEMINI_MODEL_NAME_PREFIX, + parseMaxAutopilotContinues, PROMPT_FILE_INLINE_THRESHOLD_BYTES, resolvePromptFileArgs, } = require("./copilot_harness.cjs"); @@ -630,6 +633,35 @@ describe("copilot_harness.cjs", () => { }); }); + describe("steering hook setup helpers", () => { + it("parses --max-autopilot-continues when present", () => { + const value = parseMaxAutopilotContinues(["--autopilot", "--max-autopilot-continues", "7"]); + expect(value).toBe(7); + }); + + it("returns zero when --max-autopilot-continues is missing or invalid", () => { + expect(parseMaxAutopilotContinues(["--autopilot"])).toBe(0); + expect(parseMaxAutopilotContinues(["--max-autopilot-continues", "invalid"])).toBe(0); + }); + + it("computes max autopilot runs as initial run plus continuations", () => { + expect(computeMaxAutopilotRuns(["--autopilot", "--max-autopilot-continues", "3"])).toBe(4); + }); + + it("falls back to single-run budget when autopilot is disabled", () => { + expect(computeMaxAutopilotRuns(["--add-dir", "/tmp"])).toBe(1); + }); + + it("builds hook config with sessionStart and agentStop command hooks", () => { + const config = buildSteeringHookConfig("/tmp/gh-aw/actions/copilot_steering_hook.cjs", "/usr/bin/node"); + expect(config.version).toBe(1); + expect(config.hooks.sessionStart).toHaveLength(1); + expect(config.hooks.agentStop).toHaveLength(1); + expect(config.hooks.sessionStart[0].bash).toContain("sessionStart"); + expect(config.hooks.agentStop[0].bash).toContain("agentStop"); + }); + }); + describe("formatDuration", () => { // Inline the same logic as the driver's formatDuration for unit testing function formatDuration(ms) { diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs new file mode 100644 index 00000000000..4a6055370d2 --- /dev/null +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -0,0 +1,239 @@ +// @ts-check + +"use strict"; + +const fs = require("fs"); + +const DEFAULT_TIMEOUT_MINUTES = 30; +const DEFAULT_TIME_WARNING_MINUTES = 5; +const DEFAULT_TIME_CRITICAL_MINUTES = 2; +const DEFAULT_RUN_WARNING_REMAINING = 2; +const DEFAULT_RUN_CRITICAL_REMAINING = 1; +const DEFAULT_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json"; + +/** + * @typedef {{ + * startedAtMs: number, + * turns: number, + * warningInjected: boolean, + * criticalInjected: boolean + * }} SteeringState + */ + +/** + * @param {string | undefined} rawValue + * @param {number} fallback + * @returns {number} + */ +function parsePositiveNumber(rawValue, fallback) { + const parsed = parseFloat(rawValue || ""); + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +/** + * @param {NodeJS.ProcessEnv} env + * @returns {{ + * timeoutMinutes: number, + * timeWarningMinutes: number, + * timeCriticalMinutes: number, + * runsWarningRemaining: number, + * runsCriticalRemaining: number, + * maxRuns: number, + * statePath: string + * }} + */ +function loadSteeringConfig(env = process.env) { + return { + timeoutMinutes: parsePositiveNumber(env.GH_AW_TIMEOUT_MINUTES, DEFAULT_TIMEOUT_MINUTES), + timeWarningMinutes: parsePositiveNumber(env.GH_AW_STEERING_TIME_WARNING_MINUTES, DEFAULT_TIME_WARNING_MINUTES), + timeCriticalMinutes: parsePositiveNumber(env.GH_AW_STEERING_TIME_CRITICAL_MINUTES, DEFAULT_TIME_CRITICAL_MINUTES), + runsWarningRemaining: parsePositiveNumber(env.GH_AW_STEERING_RUN_WARNING_REMAINING, DEFAULT_RUN_WARNING_REMAINING), + runsCriticalRemaining: parsePositiveNumber(env.GH_AW_STEERING_RUN_CRITICAL_REMAINING, DEFAULT_RUN_CRITICAL_REMAINING), + maxRuns: parsePositiveNumber(env.GH_AW_COPILOT_MAX_RUNS, 0), + statePath: env.GH_AW_COPILOT_STEERING_STATE_PATH || DEFAULT_STATE_PATH, + }; +} + +/** + * @param {number} timestamp + * @returns {SteeringState} + */ +function createInitialState(timestamp) { + return { + startedAtMs: timestamp, + turns: 0, + warningInjected: false, + criticalInjected: false, + }; +} + +/** + * @param {string} statePath + * @returns {SteeringState | null} + */ +function loadState(statePath) { + try { + if (!fs.existsSync(statePath)) { + return null; + } + const raw = fs.readFileSync(statePath, "utf8"); + return /** @type {SteeringState} */ JSON.parse(raw); + } catch { + return null; + } +} + +/** + * @param {string} statePath + * @param {SteeringState} state + */ +function saveState(statePath, state) { + fs.writeFileSync(statePath, JSON.stringify(state), "utf8"); +} + +/** + * @param {unknown} value + * @returns {number} + */ +function parseEventTimestamp(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) { + return parsed; + } + } + return Date.now(); +} + +/** + * @param {number | null} remainingMinutes + * @param {number | null} remainingRuns + * @returns {string} + */ +function buildBudgetSummary(remainingMinutes, remainingRuns) { + /** @type {string[]} */ + const parts = []; + if (remainingMinutes !== null) { + parts.push(`${Math.max(0, remainingMinutes).toFixed(1)} minute(s) left`); + } + if (remainingRuns !== null) { + parts.push(`${Math.max(0, remainingRuns)} run(s) left`); + } + return parts.join(", "); +} + +/** + * @param {SteeringState} state + * @param {{ + * timeoutMinutes: number, + * timeWarningMinutes: number, + * timeCriticalMinutes: number, + * runsWarningRemaining: number, + * runsCriticalRemaining: number, + * maxRuns: number + * }} config + * @param {number} timestamp + * @returns {{ state: SteeringState, decision: { decision: "block", reason: string } | null }} + */ +function computeSteeringDecision(state, config, timestamp) { + const nextState = { ...state, turns: state.turns + 1 }; + const elapsedMinutes = (timestamp - nextState.startedAtMs) / 60000; + const remainingMinutes = Number.isFinite(config.timeoutMinutes) ? config.timeoutMinutes - elapsedMinutes : null; + const remainingRuns = config.maxRuns > 0 ? config.maxRuns - nextState.turns : null; + + const isCriticalTime = remainingMinutes !== null && remainingMinutes <= config.timeCriticalMinutes; + const isWarningTime = remainingMinutes !== null && remainingMinutes <= config.timeWarningMinutes; + const isCriticalRuns = remainingRuns !== null && remainingRuns <= config.runsCriticalRemaining; + const isWarningRuns = remainingRuns !== null && remainingRuns <= config.runsWarningRemaining; + const budgetSummary = buildBudgetSummary(remainingMinutes, remainingRuns); + + if (!nextState.criticalInjected && (isCriticalTime || isCriticalRuns)) { + nextState.warningInjected = true; + nextState.criticalInjected = true; + return { + state: nextState, + decision: { + decision: "block", + reason: `⚠️ CRITICAL: Budget is nearly exhausted (${budgetSummary}). Stop new exploration and produce your final output now.`, + }, + }; + } + + if (!nextState.warningInjected && (isWarningTime || isWarningRuns)) { + nextState.warningInjected = true; + return { + state: nextState, + decision: { + decision: "block", + reason: `⚠️ Warning: Budget is getting low (${budgetSummary}). Wrap up your work and move to final output.`, + }, + }; + } + + return { state: nextState, decision: null }; +} + +/** + * @param {"sessionStart" | "agentStop"} eventName + * @param {Record} payload + * @param {NodeJS.ProcessEnv} env + * @returns {{ state: SteeringState, decision: { decision: "block", reason: string } | null }} + */ +function handleSteeringEvent(eventName, payload, env = process.env) { + const config = loadSteeringConfig(env); + const timestamp = parseEventTimestamp(payload.timestamp); + const priorState = loadState(config.statePath) || createInitialState(timestamp); + + if (eventName === "sessionStart") { + const isNewSession = payload.source === "new"; + const state = isNewSession ? createInitialState(timestamp) : priorState; + saveState(config.statePath, state); + return { state, decision: null }; + } + + const result = computeSteeringDecision(priorState, config, timestamp); + saveState(config.statePath, result.state); + return result; +} + +/** + * @returns {Record} + */ +function readStdinJSON() { + try { + const input = fs.readFileSync(0, "utf8").trim(); + return input ? JSON.parse(input) : {}; + } catch { + return {}; + } +} + +function main() { + const eventName = process.argv[2]; + if (eventName !== "sessionStart" && eventName !== "agentStop") { + return; + } + + const payload = readStdinJSON(); + const { decision } = handleSteeringEvent(eventName, payload, process.env); + if (decision) { + process.stdout.write(JSON.stringify(decision)); + } +} + +if (typeof module !== "undefined" && module.exports) { + module.exports = { + computeSteeringDecision, + createInitialState, + handleSteeringEvent, + loadSteeringConfig, + parseEventTimestamp, + }; +} + +if (require.main === module) { + main(); +} diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs new file mode 100644 index 00000000000..f26d07f6764 --- /dev/null +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -0,0 +1,80 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { createRequire } from "module"; +import fs from "fs"; +import os from "os"; +import path from "path"; + +const require = createRequire(import.meta.url); +const { createInitialState, handleSteeringEvent, loadSteeringConfig } = require("./copilot_steering_hook.cjs"); + +describe("copilot_steering_hook.cjs", () => { + let tempDir = ""; + let statePath = ""; + + afterEach(() => { + if (tempDir) { + fs.rmSync(tempDir, { recursive: true, force: true }); + tempDir = ""; + statePath = ""; + } + }); + + function makeEnv(overrides = {}) { + tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-steering-hook-")); + statePath = path.join(tempDir, "state.json"); + return { + GH_AW_TIMEOUT_MINUTES: "30", + GH_AW_STEERING_TIME_WARNING_MINUTES: "5", + GH_AW_STEERING_TIME_CRITICAL_MINUTES: "2", + GH_AW_STEERING_RUN_WARNING_REMAINING: "2", + GH_AW_STEERING_RUN_CRITICAL_REMAINING: "1", + GH_AW_COPILOT_MAX_RUNS: "4", + GH_AW_COPILOT_STEERING_STATE_PATH: statePath, + ...overrides, + }; + } + + it("loads steering config from environment with defaults fallback", () => { + const config = loadSteeringConfig({ GH_AW_COPILOT_STEERING_STATE_PATH: "/tmp/state.json" }); + expect(config.timeoutMinutes).toBe(30); + expect(config.timeWarningMinutes).toBe(5); + expect(config.timeCriticalMinutes).toBe(2); + expect(config.runsWarningRemaining).toBe(2); + expect(config.runsCriticalRemaining).toBe(1); + }); + + it("initializes state on sessionStart without emitting a decision", () => { + const env = makeEnv(); + const result = handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); + expect(result.decision).toBeNull(); + expect(result.state).toEqual(createInitialState(1000)); + expect(fs.existsSync(statePath)).toBe(true); + }); + + it("emits warning steering when remaining run budget hits warning threshold", () => { + const env = makeEnv({ GH_AW_STEERING_TIME_WARNING_MINUTES: "0.1", GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.05" }); + handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); + const firstStop = handleSteeringEvent("agentStop", { timestamp: 1100 }, env); + expect(firstStop.decision).toBeNull(); + const secondStop = handleSteeringEvent("agentStop", { timestamp: 1200 }, env); + expect(secondStop.decision).not.toBeNull(); + expect(secondStop.decision.decision).toBe("block"); + expect(secondStop.decision.reason).toContain("Warning"); + expect(secondStop.decision.reason).toContain("run(s) left"); + }); + + it("emits critical steering when remaining time is below critical threshold", () => { + const env = makeEnv({ + GH_AW_TIMEOUT_MINUTES: "1", + GH_AW_STEERING_TIME_WARNING_MINUTES: "0.5", + GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.2", + GH_AW_COPILOT_MAX_RUNS: "0", + }); + handleSteeringEvent("sessionStart", { timestamp: 0, source: "new" }, env); + const result = handleSteeringEvent("agentStop", { timestamp: 50 * 1000 }, env); + expect(result.decision).not.toBeNull(); + expect(result.decision.decision).toBe("block"); + expect(result.decision.reason).toContain("CRITICAL"); + expect(result.decision.reason).toContain("minute(s) left"); + }); +}); From 8d19d75dc30ca34402cdf8a7eee70caba88b40b2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:21:57 +0000 Subject: [PATCH 02/19] chore: polish copilot steering hook integration Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 25 ++++++++++++++++--- actions/setup/js/copilot_steering_hook.cjs | 1 + .../setup/js/copilot_steering_hook.test.cjs | 8 +++--- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index cae201be3f7..7c7e8bf0168 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -374,8 +374,8 @@ function installCopilotSteeringHooks(resolvedArgs) { return; } - const statePath = `${DEFAULT_STEERING_STATE_PATH}.${process.pid}`; - process.env.GH_AW_COPILOT_STEERING_STATE_PATH = statePath; + const processStatePath = `${DEFAULT_STEERING_STATE_PATH}.${process.pid}`; + process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath; process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs)); process.env.GH_AW_TIMEOUT_MINUTES = process.env.GH_AW_TIMEOUT_MINUTES || "30"; process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = process.env.GH_AW_STEERING_TIME_WARNING_MINUTES || "5"; @@ -384,7 +384,8 @@ function installCopilotSteeringHooks(resolvedArgs) { process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING = process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING || "1"; fs.mkdirSync(hooksDir, { recursive: true }); - fs.writeFileSync(hookConfigPath, JSON.stringify(buildSteeringHookConfig(hookScriptPath, process.execPath), null, 2) + "\n", "utf8"); + const hookConfig = buildSteeringHookConfig(hookScriptPath, process.execPath); + fs.writeFileSync(hookConfigPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8"); log(`installed steering hook config: ${hookConfigPath}`); } catch (error) { const err = /** @type {Error} */ error; @@ -392,6 +393,22 @@ function installCopilotSteeringHooks(resolvedArgs) { } } +function cleanupCopilotSteeringState() { + const statePath = process.env.GH_AW_COPILOT_STEERING_STATE_PATH || ""; + if (!statePath) { + return; + } + try { + if (fs.existsSync(statePath)) { + fs.unlinkSync(statePath); + log(`removed steering hook state file: ${statePath}`); + } + } catch (error) { + const err = /** @type {Error} */ error; + log(`warning: failed to remove steering hook state file ${statePath}: ${err.message}`); + } +} + /** * Main entry point: run copilot with retry logic for partially-executed sessions. */ @@ -557,6 +574,7 @@ async function main() { // This is best-effort: failures are logged but do not affect the agent exit code. await fetchAWFReflect({ logger: log }); + cleanupCopilotSteeringState(); log(`done: exitCode=${lastExitCode} totalDuration=${formatDuration(Date.now() - driverStartTime)}`); process.exit(lastExitCode); } @@ -587,6 +605,7 @@ if (typeof module !== "undefined" && module.exports) { if (require.main === module) { main().catch(err => { log(`unexpected error: ${err.message}`); + cleanupCopilotSteeringState(); process.exit(1); }); } diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 4a6055370d2..44e4cb313e9 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -214,6 +214,7 @@ function readStdinJSON() { function main() { const eventName = process.argv[2]; if (eventName !== "sessionStart" && eventName !== "agentStop") { + process.stderr.write(`[copilot-steering-hook] unsupported event: ${String(eventName || "")}\n`); return; } diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index f26d07f6764..80724b6594b 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -19,7 +19,7 @@ describe("copilot_steering_hook.cjs", () => { } }); - function makeEnv(overrides = {}) { + function makeTestEnv(overrides = {}) { tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "copilot-steering-hook-")); statePath = path.join(tempDir, "state.json"); return { @@ -44,7 +44,7 @@ describe("copilot_steering_hook.cjs", () => { }); it("initializes state on sessionStart without emitting a decision", () => { - const env = makeEnv(); + const env = makeTestEnv(); const result = handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); expect(result.decision).toBeNull(); expect(result.state).toEqual(createInitialState(1000)); @@ -52,7 +52,7 @@ describe("copilot_steering_hook.cjs", () => { }); it("emits warning steering when remaining run budget hits warning threshold", () => { - const env = makeEnv({ GH_AW_STEERING_TIME_WARNING_MINUTES: "0.1", GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.05" }); + const env = makeTestEnv({ GH_AW_STEERING_TIME_WARNING_MINUTES: "0.1", GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.05" }); handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); const firstStop = handleSteeringEvent("agentStop", { timestamp: 1100 }, env); expect(firstStop.decision).toBeNull(); @@ -64,7 +64,7 @@ describe("copilot_steering_hook.cjs", () => { }); it("emits critical steering when remaining time is below critical threshold", () => { - const env = makeEnv({ + const env = makeTestEnv({ GH_AW_TIMEOUT_MINUTES: "1", GH_AW_STEERING_TIME_WARNING_MINUTES: "0.5", GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.2", From 506da7ba4e90ceb40d5478999187740663d7e4ea Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:24:02 +0000 Subject: [PATCH 03/19] chore: refine copilot steering hook logging Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_steering_hook.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 44e4cb313e9..b7ac28db847 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -214,7 +214,7 @@ function readStdinJSON() { function main() { const eventName = process.argv[2]; if (eventName !== "sessionStart" && eventName !== "agentStop") { - process.stderr.write(`[copilot-steering-hook] unsupported event: ${String(eventName || "")}\n`); + process.stderr.write(`[copilot-steering-hook] unsupported event: ${eventName || ""}\n`); return; } From f8bdd5df6abf691bf9b6abd155fba09971f3a50e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:26:22 +0000 Subject: [PATCH 04/19] chore: clarify steering hook config internals Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 10 ++++++---- actions/setup/js/copilot_steering_hook.test.cjs | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 7c7e8bf0168..8858a2acbd2 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -335,22 +335,23 @@ function computeMaxAutopilotRuns(args) { * @returns {{ version: number, hooks: Record> }} */ function buildSteeringHookConfig(hookScriptPath, nodeExecPath) { - const quotedNode = JSON.stringify(nodeExecPath); - const quotedHookScript = JSON.stringify(hookScriptPath); + // JSON-encode paths so they are safely quoted when embedded into bash hook command strings. + const jsonEncodedNode = JSON.stringify(nodeExecPath); + const jsonEncodedHookScript = JSON.stringify(hookScriptPath); return { version: 1, hooks: { sessionStart: [ { type: "command", - bash: `${quotedNode} ${quotedHookScript} sessionStart`, + bash: `${jsonEncodedNode} ${jsonEncodedHookScript} sessionStart`, timeoutSec: 10, }, ], agentStop: [ { type: "command", - bash: `${quotedNode} ${quotedHookScript} agentStop`, + bash: `${jsonEncodedNode} ${jsonEncodedHookScript} agentStop`, timeoutSec: 10, }, ], @@ -374,6 +375,7 @@ function installCopilotSteeringHooks(resolvedArgs) { return; } + // Append PID for per-process isolation when multiple harnesses run concurrently on the same runner. const processStatePath = `${DEFAULT_STEERING_STATE_PATH}.${process.pid}`; process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath; process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs)); diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index 80724b6594b..80494cf4bfa 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -52,7 +52,10 @@ describe("copilot_steering_hook.cjs", () => { }); it("emits warning steering when remaining run budget hits warning threshold", () => { - const env = makeTestEnv({ GH_AW_STEERING_TIME_WARNING_MINUTES: "0.1", GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.05" }); + const env = makeTestEnv({ + GH_AW_STEERING_TIME_WARNING_MINUTES: "0.1", + GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.05", + }); handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); const firstStop = handleSteeringEvent("agentStop", { timestamp: 1100 }, env); expect(firstStop.decision).toBeNull(); From c8dcc9bb3239225642c88529968bbca8c4e2dc09 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:29:10 +0000 Subject: [PATCH 05/19] chore: harden steering state path uniqueness Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 8858a2acbd2..a1091294159 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -336,22 +336,22 @@ function computeMaxAutopilotRuns(args) { */ function buildSteeringHookConfig(hookScriptPath, nodeExecPath) { // JSON-encode paths so they are safely quoted when embedded into bash hook command strings. - const jsonEncodedNode = JSON.stringify(nodeExecPath); - const jsonEncodedHookScript = JSON.stringify(hookScriptPath); + const quotedNodePath = JSON.stringify(nodeExecPath); + const quotedHookScriptPath = JSON.stringify(hookScriptPath); return { version: 1, hooks: { sessionStart: [ { type: "command", - bash: `${jsonEncodedNode} ${jsonEncodedHookScript} sessionStart`, + bash: `${quotedNodePath} ${quotedHookScriptPath} sessionStart`, timeoutSec: 10, }, ], agentStop: [ { type: "command", - bash: `${jsonEncodedNode} ${jsonEncodedHookScript} agentStop`, + bash: `${quotedNodePath} ${quotedHookScriptPath} agentStop`, timeoutSec: 10, }, ], @@ -375,8 +375,9 @@ function installCopilotSteeringHooks(resolvedArgs) { return; } - // Append PID for per-process isolation when multiple harnesses run concurrently on the same runner. - const processStatePath = `${DEFAULT_STEERING_STATE_PATH}.${process.pid}`; + // Include run ID, PID, and timestamp to avoid collisions across concurrent/serial runner jobs. + const runID = process.env.GITHUB_RUN_ID || "local"; + const processStatePath = `${DEFAULT_STEERING_STATE_PATH}.${runID}.${process.pid}.${Date.now()}`; process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath; process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs)); process.env.GH_AW_TIMEOUT_MINUTES = process.env.GH_AW_TIMEOUT_MINUTES || "30"; From 5a0f35f960d95e2e5abb9ea5babbdecb133f4070 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:31:33 +0000 Subject: [PATCH 06/19] chore: improve steering hook robustness and tests Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 3 ++- actions/setup/js/copilot_harness.test.cjs | 6 ++++++ actions/setup/js/copilot_steering_hook.cjs | 23 +++++++++++++--------- 3 files changed, 22 insertions(+), 10 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index a1091294159..33458466438 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -371,11 +371,12 @@ function installCopilotSteeringHooks(resolvedArgs) { const hookScriptPath = path.join(__dirname, "copilot_steering_hook.cjs"); if (!fs.existsSync(hookScriptPath)) { - log(`warning: steering hook script not found at ${hookScriptPath}; skipping hook installation`); + log(`warning: steering hook script missing at ${hookScriptPath}; this may indicate setup action copy/deploy drift, so Copilot steering hooks will be skipped`); return; } // Include run ID, PID, and timestamp to avoid collisions across concurrent/serial runner jobs. + // This installer runs once per harness process, so exactly one state path is expected per run. const runID = process.env.GITHUB_RUN_ID || "local"; const processStatePath = `${DEFAULT_STEERING_STATE_PATH}.${runID}.${process.pid}.${Date.now()}`; process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath; diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index d0bd635db1e..96217192c40 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -660,6 +660,12 @@ describe("copilot_harness.cjs", () => { expect(config.hooks.sessionStart[0].bash).toContain("sessionStart"); expect(config.hooks.agentStop[0].bash).toContain("agentStop"); }); + + it("quotes node and hook script paths in hook commands", () => { + const config = buildSteeringHookConfig("/tmp/with space/copilot_steering_hook.cjs", "/opt/tools/node with space"); + expect(config.hooks.sessionStart[0].bash).toContain('"/opt/tools/node with space"'); + expect(config.hooks.sessionStart[0].bash).toContain('"/tmp/with space/copilot_steering_hook.cjs"'); + }); }); describe("formatDuration", () => { diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index b7ac28db847..981654f2588 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -212,16 +212,21 @@ function readStdinJSON() { } function main() { - const eventName = process.argv[2]; - if (eventName !== "sessionStart" && eventName !== "agentStop") { - process.stderr.write(`[copilot-steering-hook] unsupported event: ${eventName || ""}\n`); - return; - } + try { + const eventName = process.argv[2]; + if (eventName !== "sessionStart" && eventName !== "agentStop") { + process.stderr.write(`[copilot-steering-hook] unsupported event: ${eventName || ""}\n`); + return; + } - const payload = readStdinJSON(); - const { decision } = handleSteeringEvent(eventName, payload, process.env); - if (decision) { - process.stdout.write(JSON.stringify(decision)); + const payload = readStdinJSON(); + const { decision } = handleSteeringEvent(eventName, payload, process.env); + if (decision) { + process.stdout.write(JSON.stringify(decision)); + } + } catch (error) { + const err = /** @type {Error} */ error; + process.stderr.write(`[copilot-steering-hook] unexpected error: ${err.message}\n`); } } From 044efbfe33b96edf8fd1d328135ff2bf00d138d6 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 04:33:43 +0000 Subject: [PATCH 07/19] chore: organize steering state files per run Agent-Logs-Url: https://github.com/github/gh-aw/sessions/d69918d3-92bc-47f8-be39-1a214fbbf7cd Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 33458466438..e90cb277b60 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -378,7 +378,9 @@ function installCopilotSteeringHooks(resolvedArgs) { // Include run ID, PID, and timestamp to avoid collisions across concurrent/serial runner jobs. // This installer runs once per harness process, so exactly one state path is expected per run. const runID = process.env.GITHUB_RUN_ID || "local"; - const processStatePath = `${DEFAULT_STEERING_STATE_PATH}.${runID}.${process.pid}.${Date.now()}`; + const stateDir = path.join(path.dirname(DEFAULT_STEERING_STATE_PATH), "steering-hooks"); + fs.mkdirSync(stateDir, { recursive: true }); + const processStatePath = path.join(stateDir, `copilot-steering-${runID}-${process.pid}-${Date.now()}.json`); process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath; process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs)); process.env.GH_AW_TIMEOUT_MINUTES = process.env.GH_AW_TIMEOUT_MINUTES || "30"; From 8699e63e3404e16cbc0e04435e00be59116fc4f5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:15:33 +0000 Subject: [PATCH 08/19] fix: harden copilot steering state load and save Agent-Logs-Url: https://github.com/github/gh-aw/sessions/10b67751-6800-491f-97fd-2b102bbee5ca Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_steering_hook.cjs | 30 +++++++++++++++++-- .../setup/js/copilot_steering_hook.test.cjs | 25 ++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 981654f2588..f6adb6c8e93 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -3,6 +3,7 @@ "use strict"; const fs = require("fs"); +const path = require("path"); const DEFAULT_TIMEOUT_MINUTES = 30; const DEFAULT_TIME_WARNING_MINUTES = 5; @@ -10,6 +11,7 @@ const DEFAULT_TIME_CRITICAL_MINUTES = 2; const DEFAULT_RUN_WARNING_REMAINING = 2; const DEFAULT_RUN_CRITICAL_REMAINING = 1; const DEFAULT_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json"; +const MS_PER_MINUTE = 60000; /** * @typedef {{ @@ -77,7 +79,11 @@ function loadState(statePath) { return null; } const raw = fs.readFileSync(statePath, "utf8"); - return /** @type {SteeringState} */ JSON.parse(raw); + const parsed = JSON.parse(raw); + if (!isValidSteeringState(parsed)) { + return null; + } + return parsed; } catch { return null; } @@ -88,9 +94,29 @@ function loadState(statePath) { * @param {SteeringState} state */ function saveState(statePath, state) { + fs.mkdirSync(path.dirname(statePath), { recursive: true }); fs.writeFileSync(statePath, JSON.stringify(state), "utf8"); } +/** + * @param {unknown} value + * @returns {value is SteeringState} + */ +function isValidSteeringState(value) { + if (!value || typeof value !== "object") { + return false; + } + const candidate = /** @type {Record} */ value; + return ( + typeof candidate.startedAtMs === "number" && + Number.isFinite(candidate.startedAtMs) && + typeof candidate.turns === "number" && + Number.isFinite(candidate.turns) && + typeof candidate.warningInjected === "boolean" && + typeof candidate.criticalInjected === "boolean" + ); +} + /** * @param {unknown} value * @returns {number} @@ -140,7 +166,7 @@ function buildBudgetSummary(remainingMinutes, remainingRuns) { */ function computeSteeringDecision(state, config, timestamp) { const nextState = { ...state, turns: state.turns + 1 }; - const elapsedMinutes = (timestamp - nextState.startedAtMs) / 60000; + const elapsedMinutes = (timestamp - nextState.startedAtMs) / MS_PER_MINUTE; const remainingMinutes = Number.isFinite(config.timeoutMinutes) ? config.timeoutMinutes - elapsedMinutes : null; const remainingRuns = config.maxRuns > 0 ? config.maxRuns - nextState.turns : null; diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index 80494cf4bfa..3588ded577c 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -80,4 +80,29 @@ describe("copilot_steering_hook.cjs", () => { expect(result.decision.reason).toContain("CRITICAL"); expect(result.decision.reason).toContain("minute(s) left"); }); + + it("falls back to initial state when persisted state is malformed-but-parseable", () => { + const env = makeTestEnv({ + GH_AW_TIMEOUT_MINUTES: "10", + GH_AW_STEERING_TIME_WARNING_MINUTES: "9.9", + GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.1", + GH_AW_COPILOT_MAX_RUNS: "2", + }); + fs.writeFileSync(statePath, "{}", "utf8"); + const result = handleSteeringEvent("agentStop", { timestamp: 60 * 1000 }, env); + expect(result.state.turns).toBe(1); + expect(result.state.warningInjected).toBe(true); + expect(result.decision).not.toBeNull(); + expect(result.decision.reason).toContain("minute(s) left"); + }); + + it("creates parent directory before saving state", () => { + const env = makeTestEnv(); + const nestedStatePath = path.join(tempDir, "nested", "deeper", "state.json"); + env.GH_AW_COPILOT_STEERING_STATE_PATH = nestedStatePath; + + const result = handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); + expect(result.decision).toBeNull(); + expect(fs.existsSync(nestedStatePath)).toBe(true); + }); }); From 9a583dd3c9dfa5e5414e5e0b752e1ff18221c365 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 05:28:41 +0000 Subject: [PATCH 09/19] Add changeset --- .changeset/patch-add-copilot-steering-hooks.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/patch-add-copilot-steering-hooks.md diff --git a/.changeset/patch-add-copilot-steering-hooks.md b/.changeset/patch-add-copilot-steering-hooks.md new file mode 100644 index 00000000000..b6724295e36 --- /dev/null +++ b/.changeset/patch-add-copilot-steering-hooks.md @@ -0,0 +1,5 @@ +--- +"gh-aw": patch +--- + +Added Copilot CLI steering hooks that warn agent sessions when time or run budgets are running low. From ef60b87b59cb5060b811d6931498e3f18cf91e5d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:32:01 +0000 Subject: [PATCH 10/19] fix: resolve js typecheck for steering state guard Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e601ff46-a312-4e8a-9d6b-a799d5701053 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_steering_hook.cjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index f6adb6c8e93..9a9c1360752 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -106,7 +106,12 @@ function isValidSteeringState(value) { if (!value || typeof value !== "object") { return false; } - const candidate = /** @type {Record} */ value; + const candidate = /** @type {{ + * startedAtMs?: unknown, + * turns?: unknown, + * warningInjected?: unknown, + * criticalInjected?: unknown + * }} */ value; return ( typeof candidate.startedAtMs === "number" && Number.isFinite(candidate.startedAtMs) && From 62cc12e16ee289bd5080b23085078338c9549e16 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:34:46 +0000 Subject: [PATCH 11/19] fix: clean up steering hook jsdoc typing Agent-Logs-Url: https://github.com/github/gh-aw/sessions/e601ff46-a312-4e8a-9d6b-a799d5701053 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_steering_hook.cjs | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 9a9c1360752..6654157d63e 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -22,6 +22,15 @@ const MS_PER_MINUTE = 60000; * }} SteeringState */ +/** + * @typedef {{ + * startedAtMs?: unknown, + * turns?: unknown, + * warningInjected?: unknown, + * criticalInjected?: unknown + * }} PartialSteeringState + */ + /** * @param {string | undefined} rawValue * @param {number} fallback @@ -106,12 +115,7 @@ function isValidSteeringState(value) { if (!value || typeof value !== "object") { return false; } - const candidate = /** @type {{ - * startedAtMs?: unknown, - * turns?: unknown, - * warningInjected?: unknown, - * criticalInjected?: unknown - * }} */ value; + const candidate = /** @type {PartialSteeringState} */ value; return ( typeof candidate.startedAtMs === "number" && Number.isFinite(candidate.startedAtMs) && From 8c6cbdfe34ee219bcafd45fc1b33cf176a541331 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:43:50 +0000 Subject: [PATCH 12/19] fix: align copilot steering hooks with documented lifecycle events Agent-Logs-Url: https://github.com/github/gh-aw/sessions/294ea110-b23b-4a05-a10c-438f596a35c9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 7 ++++ actions/setup/js/copilot_harness.test.cjs | 4 +- actions/setup/js/copilot_steering_hook.cjs | 38 ++++++++++--------- .../setup/js/copilot_steering_hook.test.cjs | 10 +++++ 4 files changed, 40 insertions(+), 19 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index e90cb277b60..1e3608c0013 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -348,6 +348,13 @@ function buildSteeringHookConfig(hookScriptPath, nodeExecPath) { timeoutSec: 10, }, ], + sessionEnd: [ + { + type: "command", + bash: `${quotedNodePath} ${quotedHookScriptPath} sessionEnd`, + timeoutSec: 10, + }, + ], agentStop: [ { type: "command", diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 96217192c40..3825fadb56e 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -652,12 +652,14 @@ describe("copilot_harness.cjs", () => { expect(computeMaxAutopilotRuns(["--add-dir", "/tmp"])).toBe(1); }); - it("builds hook config with sessionStart and agentStop command hooks", () => { + it("builds hook config with sessionStart/sessionEnd and agentStop command hooks", () => { const config = buildSteeringHookConfig("/tmp/gh-aw/actions/copilot_steering_hook.cjs", "/usr/bin/node"); expect(config.version).toBe(1); expect(config.hooks.sessionStart).toHaveLength(1); + expect(config.hooks.sessionEnd).toHaveLength(1); expect(config.hooks.agentStop).toHaveLength(1); expect(config.hooks.sessionStart[0].bash).toContain("sessionStart"); + expect(config.hooks.sessionEnd[0].bash).toContain("sessionEnd"); expect(config.hooks.agentStop[0].bash).toContain("agentStop"); }); diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 6654157d63e..727c57b9981 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -22,15 +22,6 @@ const MS_PER_MINUTE = 60000; * }} SteeringState */ -/** - * @typedef {{ - * startedAtMs?: unknown, - * turns?: unknown, - * warningInjected?: unknown, - * criticalInjected?: unknown - * }} PartialSteeringState - */ - /** * @param {string | undefined} rawValue * @param {number} fallback @@ -115,14 +106,14 @@ function isValidSteeringState(value) { if (!value || typeof value !== "object") { return false; } - const candidate = /** @type {PartialSteeringState} */ value; + const candidate = /** @type {Record} */ value; return ( - typeof candidate.startedAtMs === "number" && - Number.isFinite(candidate.startedAtMs) && - typeof candidate.turns === "number" && - Number.isFinite(candidate.turns) && - typeof candidate.warningInjected === "boolean" && - typeof candidate.criticalInjected === "boolean" + typeof candidate["startedAtMs"] === "number" && + Number.isFinite(candidate["startedAtMs"]) && + typeof candidate["turns"] === "number" && + Number.isFinite(candidate["turns"]) && + typeof candidate["warningInjected"] === "boolean" && + typeof candidate["criticalInjected"] === "boolean" ); } @@ -212,7 +203,7 @@ function computeSteeringDecision(state, config, timestamp) { } /** - * @param {"sessionStart" | "agentStop"} eventName + * @param {"sessionStart" | "sessionEnd" | "agentStop"} eventName * @param {Record} payload * @param {NodeJS.ProcessEnv} env * @returns {{ state: SteeringState, decision: { decision: "block", reason: string } | null }} @@ -229,6 +220,17 @@ function handleSteeringEvent(eventName, payload, env = process.env) { return { state, decision: null }; } + if (eventName === "sessionEnd") { + try { + if (fs.existsSync(config.statePath)) { + fs.unlinkSync(config.statePath); + } + } catch { + // best-effort cleanup only + } + return { state: priorState, decision: null }; + } + const result = computeSteeringDecision(priorState, config, timestamp); saveState(config.statePath, result.state); return result; @@ -249,7 +251,7 @@ function readStdinJSON() { function main() { try { const eventName = process.argv[2]; - if (eventName !== "sessionStart" && eventName !== "agentStop") { + if (eventName !== "sessionStart" && eventName !== "sessionEnd" && eventName !== "agentStop") { process.stderr.write(`[copilot-steering-hook] unsupported event: ${eventName || ""}\n`); return; } diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index 3588ded577c..55848e8b36c 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -105,4 +105,14 @@ describe("copilot_steering_hook.cjs", () => { expect(result.decision).toBeNull(); expect(fs.existsSync(nestedStatePath)).toBe(true); }); + + it("removes persisted state on sessionEnd", () => { + const env = makeTestEnv(); + handleSteeringEvent("sessionStart", { timestamp: 1000, source: "new" }, env); + expect(fs.existsSync(statePath)).toBe(true); + + const result = handleSteeringEvent("sessionEnd", { timestamp: 2000 }, env); + expect(result.decision).toBeNull(); + expect(fs.existsSync(statePath)).toBe(false); + }); }); From 9f2fd5a4e610cc995bf11ab6908eb58a53a52da7 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 05:45:55 +0000 Subject: [PATCH 13/19] fix: normalize sessionEnd steering state return Agent-Logs-Url: https://github.com/github/gh-aw/sessions/294ea110-b23b-4a05-a10c-438f596a35c9 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_steering_hook.cjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 727c57b9981..02d2f63fe91 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -228,7 +228,7 @@ function handleSteeringEvent(eventName, payload, env = process.env) { } catch { // best-effort cleanup only } - return { state: priorState, decision: null }; + return { state: createInitialState(timestamp), decision: null }; } const result = computeSteeringDecision(priorState, config, timestamp); From 378cd5bf5d7175edd31f54dd7303adfbf7574910 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 13:28:48 +0000 Subject: [PATCH 14/19] fix: persist steering hook load diagnostics in stderr and artifacts Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ece6b148-c0d6-41da-9dac-8d123c588d30 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 31 ++++++++++++++ actions/setup/js/copilot_harness.test.cjs | 23 +++++++++++ actions/setup/js/copilot_steering_hook.cjs | 41 ++++++++++++++++++- .../setup/js/copilot_steering_hook.test.cjs | 21 +++++++++- 4 files changed, 113 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 1e3608c0013..577db4c23b3 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -69,6 +69,7 @@ const PROMPT_FILE_INLINE_THRESHOLD_BYTES = 100 * 1024; const PROMPT_FILE_INLINE_THRESHOLD_LABEL = "100KB"; const STEERING_HOOK_CONFIG_FILENAME = "gh-aw-steering.json"; const DEFAULT_STEERING_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json"; +const DEFAULT_STEERING_HOOK_LOG_PATH = "/tmp/gh-aw/sandbox/agent/logs/copilot-steering-hook.log"; const DEFAULT_MAX_AUTOPILOT_RUNS = 1; // Pattern to detect transient CAPIError 400 in copilot output @@ -388,7 +389,9 @@ function installCopilotSteeringHooks(resolvedArgs) { const stateDir = path.join(path.dirname(DEFAULT_STEERING_STATE_PATH), "steering-hooks"); fs.mkdirSync(stateDir, { recursive: true }); const processStatePath = path.join(stateDir, `copilot-steering-${runID}-${process.pid}-${Date.now()}.json`); + const hookLogPath = process.env.GH_AW_COPILOT_STEERING_LOG_PATH || DEFAULT_STEERING_HOOK_LOG_PATH; process.env.GH_AW_COPILOT_STEERING_STATE_PATH = processStatePath; + process.env.GH_AW_COPILOT_STEERING_LOG_PATH = hookLogPath; process.env.GH_AW_COPILOT_MAX_RUNS = String(computeMaxAutopilotRuns(resolvedArgs)); process.env.GH_AW_TIMEOUT_MINUTES = process.env.GH_AW_TIMEOUT_MINUTES || "30"; process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = process.env.GH_AW_STEERING_TIME_WARNING_MINUTES || "5"; @@ -400,12 +403,38 @@ function installCopilotSteeringHooks(resolvedArgs) { const hookConfig = buildSteeringHookConfig(hookScriptPath, process.execPath); fs.writeFileSync(hookConfigPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8"); log(`installed steering hook config: ${hookConfigPath}`); + log("steering hooks attached: " + `events=sessionStart,sessionEnd,agentStop ` + `statePath=${processStatePath} ` + `hookLogPath=${hookLogPath}`); } catch (error) { const err = /** @type {Error} */ error; log(`warning: failed to install steering hook config: ${err.message}`); } } +function reportSteeringHookLoadStatus() { + const hookLogPath = process.env.GH_AW_COPILOT_STEERING_LOG_PATH || ""; + if (!hookLogPath) { + log("warning: steering hook load check skipped because GH_AW_COPILOT_STEERING_LOG_PATH is not set"); + return; + } + + try { + if (!fs.existsSync(hookLogPath)) { + log(`warning: steering hook load check found no hook log file at ${hookLogPath}`); + return; + } + const raw = fs.readFileSync(hookLogPath, "utf8").trim(); + const lines = raw ? raw.split("\n").filter(Boolean) : []; + if (lines.length === 0) { + log(`warning: steering hook load check found empty hook log file at ${hookLogPath}`); + return; + } + log(`steering hook load check: observed ${lines.length} hook event(s) in ${hookLogPath}`); + } catch (error) { + const err = /** @type {Error} */ error; + log(`warning: steering hook load check failed for ${hookLogPath}: ${err.message}`); + } +} + function cleanupCopilotSteeringState() { const statePath = process.env.GH_AW_COPILOT_STEERING_STATE_PATH || ""; if (!statePath) { @@ -586,6 +615,7 @@ async function main() { // Fetch AWF API proxy reflection data and persist to disk for post-run step summary. // This is best-effort: failures are logged but do not affect the agent exit code. await fetchAWFReflect({ logger: log }); + reportSteeringHookLoadStatus(); cleanupCopilotSteeringState(); log(`done: exitCode=${lastExitCode} totalDuration=${formatDuration(Date.now() - driverStartTime)}`); @@ -609,6 +639,7 @@ if (typeof module !== "undefined" && module.exports) { fetchAWFReflect, fetchModelsFromUrl, buildSteeringHookConfig, + reportSteeringHookLoadStatus, computeMaxAutopilotRuns, resolvePromptFileArgs, parseMaxAutopilotContinues, diff --git a/actions/setup/js/copilot_harness.test.cjs b/actions/setup/js/copilot_harness.test.cjs index 3825fadb56e..f932da7f0ce 100644 --- a/actions/setup/js/copilot_harness.test.cjs +++ b/actions/setup/js/copilot_harness.test.cjs @@ -19,6 +19,7 @@ const { GEMINI_MODEL_NAME_PREFIX, parseMaxAutopilotContinues, PROMPT_FILE_INLINE_THRESHOLD_BYTES, + reportSteeringHookLoadStatus, resolvePromptFileArgs, } = require("./copilot_harness.cjs"); @@ -668,6 +669,28 @@ describe("copilot_harness.cjs", () => { expect(config.hooks.sessionStart[0].bash).toContain('"/opt/tools/node with space"'); expect(config.hooks.sessionStart[0].bash).toContain('"/tmp/with space/copilot_steering_hook.cjs"'); }); + + it("reports observed hook events when steering hook log has entries", () => { + const tempLogPath = path.join(os.tmpdir(), `copilot-steering-hook-log-${Date.now()}.jsonl`); + fs.writeFileSync(tempLogPath, '{"event":"sessionStart"}\n{"event":"agentStop"}\n', "utf8"); + const prevPath = process.env.GH_AW_COPILOT_STEERING_LOG_PATH; + const stderrSpy = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + process.env.GH_AW_COPILOT_STEERING_LOG_PATH = tempLogPath; + let joined = ""; + try { + reportSteeringHookLoadStatus(); + joined = stderrSpy.mock.calls.map(call => String(call[0])).join(""); + } finally { + stderrSpy.mockRestore(); + if (prevPath === undefined) { + delete process.env.GH_AW_COPILOT_STEERING_LOG_PATH; + } else { + process.env.GH_AW_COPILOT_STEERING_LOG_PATH = prevPath; + } + fs.rmSync(tempLogPath, { force: true }); + } + expect(joined).toContain("steering hook load check: observed 2 hook event(s)"); + }); }); describe("formatDuration", () => { diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 02d2f63fe91..69cae4564f1 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -11,6 +11,7 @@ const DEFAULT_TIME_CRITICAL_MINUTES = 2; const DEFAULT_RUN_WARNING_REMAINING = 2; const DEFAULT_RUN_CRITICAL_REMAINING = 1; const DEFAULT_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json"; +const DEFAULT_HOOK_LOG_PATH = "/tmp/gh-aw/copilot-steering-hook.log"; const MS_PER_MINUTE = 60000; /** @@ -41,7 +42,8 @@ function parsePositiveNumber(rawValue, fallback) { * runsWarningRemaining: number, * runsCriticalRemaining: number, * maxRuns: number, - * statePath: string + * statePath: string, + * hookLogPath: string * }} */ function loadSteeringConfig(env = process.env) { @@ -53,9 +55,27 @@ function loadSteeringConfig(env = process.env) { runsCriticalRemaining: parsePositiveNumber(env.GH_AW_STEERING_RUN_CRITICAL_REMAINING, DEFAULT_RUN_CRITICAL_REMAINING), maxRuns: parsePositiveNumber(env.GH_AW_COPILOT_MAX_RUNS, 0), statePath: env.GH_AW_COPILOT_STEERING_STATE_PATH || DEFAULT_STATE_PATH, + hookLogPath: env.GH_AW_COPILOT_STEERING_LOG_PATH || DEFAULT_HOOK_LOG_PATH, }; } +/** + * @param {string} hookLogPath + * @param {{ + * event: "sessionStart" | "sessionEnd" | "agentStop", + * timestamp: number, + * statePath: string, + * turns: number, + * warningInjected: boolean, + * criticalInjected: boolean, + * decision: "none" | "block" + * }} entry + */ +function appendHookEventLog(hookLogPath, entry) { + fs.mkdirSync(path.dirname(hookLogPath), { recursive: true }); + fs.appendFileSync(hookLogPath, JSON.stringify(entry) + "\n", "utf8"); +} + /** * @param {number} timestamp * @returns {SteeringState} @@ -257,7 +277,23 @@ function main() { } const payload = readStdinJSON(); - const { decision } = handleSteeringEvent(eventName, payload, process.env); + const { decision, state } = handleSteeringEvent(eventName, payload, process.env); + const config = loadSteeringConfig(process.env); + process.stderr.write(`[copilot-steering-hook] event=${eventName} decision=${decision ? decision.decision : "none"} statePath=${config.statePath} hookLogPath=${config.hookLogPath}\n`); + try { + appendHookEventLog(config.hookLogPath, { + event: eventName, + timestamp: Date.now(), + statePath: config.statePath, + turns: state.turns, + warningInjected: state.warningInjected, + criticalInjected: state.criticalInjected, + decision: decision ? decision.decision : "none", + }); + } catch (error) { + const err = /** @type {Error} */ error; + process.stderr.write(`[copilot-steering-hook] failed to append hook log: ${err.message}\n`); + } if (decision) { process.stdout.write(JSON.stringify(decision)); } @@ -272,6 +308,7 @@ if (typeof module !== "undefined" && module.exports) { computeSteeringDecision, createInitialState, handleSteeringEvent, + appendHookEventLog, loadSteeringConfig, parseEventTimestamp, }; diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index 55848e8b36c..358010c5b1f 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -5,7 +5,7 @@ import os from "os"; import path from "path"; const require = createRequire(import.meta.url); -const { createInitialState, handleSteeringEvent, loadSteeringConfig } = require("./copilot_steering_hook.cjs"); +const { appendHookEventLog, createInitialState, handleSteeringEvent, loadSteeringConfig } = require("./copilot_steering_hook.cjs"); describe("copilot_steering_hook.cjs", () => { let tempDir = ""; @@ -41,6 +41,25 @@ describe("copilot_steering_hook.cjs", () => { expect(config.timeCriticalMinutes).toBe(2); expect(config.runsWarningRemaining).toBe(2); expect(config.runsCriticalRemaining).toBe(1); + expect(config.hookLogPath).toBe("/tmp/gh-aw/copilot-steering-hook.log"); + }); + + it("appends hook event log entries as JSON lines", () => { + const env = makeTestEnv(); + const hookLogPath = path.join(tempDir, "hook.log"); + appendHookEventLog(hookLogPath, { + event: "sessionStart", + timestamp: 1234, + statePath, + turns: 0, + warningInjected: false, + criticalInjected: false, + decision: "none", + }); + const content = fs.readFileSync(hookLogPath, "utf8").trim(); + const parsed = JSON.parse(content); + expect(parsed.event).toBe("sessionStart"); + expect(parsed.decision).toBe("none"); }); it("initializes state on sessionStart without emitting a decision", () => { From 557e0e546197647ab2f131faf870472bf4d74ffa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 13:32:51 +0000 Subject: [PATCH 15/19] fix: align steering hook load check fallback and test cleanup Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ece6b148-c0d6-41da-9dac-8d123c588d30 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 8 ++------ actions/setup/js/copilot_steering_hook.test.cjs | 2 +- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 577db4c23b3..beee78a381c 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -411,11 +411,7 @@ function installCopilotSteeringHooks(resolvedArgs) { } function reportSteeringHookLoadStatus() { - const hookLogPath = process.env.GH_AW_COPILOT_STEERING_LOG_PATH || ""; - if (!hookLogPath) { - log("warning: steering hook load check skipped because GH_AW_COPILOT_STEERING_LOG_PATH is not set"); - return; - } + const hookLogPath = process.env.GH_AW_COPILOT_STEERING_LOG_PATH || DEFAULT_STEERING_HOOK_LOG_PATH; try { if (!fs.existsSync(hookLogPath)) { @@ -423,7 +419,7 @@ function reportSteeringHookLoadStatus() { return; } const raw = fs.readFileSync(hookLogPath, "utf8").trim(); - const lines = raw ? raw.split("\n").filter(Boolean) : []; + const lines = raw.split("\n").filter(Boolean); if (lines.length === 0) { log(`warning: steering hook load check found empty hook log file at ${hookLogPath}`); return; diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index 358010c5b1f..9a2101918d3 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -45,7 +45,7 @@ describe("copilot_steering_hook.cjs", () => { }); it("appends hook event log entries as JSON lines", () => { - const env = makeTestEnv(); + makeTestEnv(); const hookLogPath = path.join(tempDir, "hook.log"); appendHookEventLog(hookLogPath, { event: "sessionStart", From 23aa21088f5b70d875fff9fe0f854ff0c7568ed0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 13:37:25 +0000 Subject: [PATCH 16/19] fix: align steering hook default artifact log path Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ece6b148-c0d6-41da-9dac-8d123c588d30 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_steering_hook.cjs | 2 +- actions/setup/js/copilot_steering_hook.test.cjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index 69cae4564f1..fce689ff946 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -11,7 +11,7 @@ const DEFAULT_TIME_CRITICAL_MINUTES = 2; const DEFAULT_RUN_WARNING_REMAINING = 2; const DEFAULT_RUN_CRITICAL_REMAINING = 1; const DEFAULT_STATE_PATH = "/tmp/gh-aw/copilot-steering-state.json"; -const DEFAULT_HOOK_LOG_PATH = "/tmp/gh-aw/copilot-steering-hook.log"; +const DEFAULT_HOOK_LOG_PATH = "/tmp/gh-aw/sandbox/agent/logs/copilot-steering-hook.log"; const MS_PER_MINUTE = 60000; /** diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index 9a2101918d3..dc614b97dd2 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -41,7 +41,7 @@ describe("copilot_steering_hook.cjs", () => { expect(config.timeCriticalMinutes).toBe(2); expect(config.runsWarningRemaining).toBe(2); expect(config.runsCriticalRemaining).toBe(1); - expect(config.hookLogPath).toBe("/tmp/gh-aw/copilot-steering-hook.log"); + expect(config.hookLogPath).toBe("/tmp/gh-aw/sandbox/agent/logs/copilot-steering-hook.log"); }); it("appends hook event log entries as JSON lines", () => { From 4c62720cd60bf360408fd8c3553357e92ffec4cd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 13:41:23 +0000 Subject: [PATCH 17/19] test: cover custom steering hook log path and tidy diagnostics Agent-Logs-Url: https://github.com/github/gh-aw/sessions/ece6b148-c0d6-41da-9dac-8d123c588d30 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 2 +- actions/setup/js/copilot_steering_hook.cjs | 5 +++-- actions/setup/js/copilot_steering_hook.test.cjs | 8 ++++++++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index beee78a381c..7976688a3c7 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -403,7 +403,7 @@ function installCopilotSteeringHooks(resolvedArgs) { const hookConfig = buildSteeringHookConfig(hookScriptPath, process.execPath); fs.writeFileSync(hookConfigPath, JSON.stringify(hookConfig, null, 2) + "\n", "utf8"); log(`installed steering hook config: ${hookConfigPath}`); - log("steering hooks attached: " + `events=sessionStart,sessionEnd,agentStop ` + `statePath=${processStatePath} ` + `hookLogPath=${hookLogPath}`); + log(`steering hooks attached: events=sessionStart,sessionEnd,agentStop statePath=${processStatePath} hookLogPath=${hookLogPath}`); } catch (error) { const err = /** @type {Error} */ error; log(`warning: failed to install steering hook config: ${err.message}`); diff --git a/actions/setup/js/copilot_steering_hook.cjs b/actions/setup/js/copilot_steering_hook.cjs index fce689ff946..b1bd0520e72 100644 --- a/actions/setup/js/copilot_steering_hook.cjs +++ b/actions/setup/js/copilot_steering_hook.cjs @@ -279,7 +279,8 @@ function main() { const payload = readStdinJSON(); const { decision, state } = handleSteeringEvent(eventName, payload, process.env); const config = loadSteeringConfig(process.env); - process.stderr.write(`[copilot-steering-hook] event=${eventName} decision=${decision ? decision.decision : "none"} statePath=${config.statePath} hookLogPath=${config.hookLogPath}\n`); + const decisionValue = decision ? decision.decision : "none"; + process.stderr.write(`[copilot-steering-hook] event=${eventName} decision=${decisionValue} statePath=${config.statePath} hookLogPath=${config.hookLogPath}\n`); try { appendHookEventLog(config.hookLogPath, { event: eventName, @@ -288,7 +289,7 @@ function main() { turns: state.turns, warningInjected: state.warningInjected, criticalInjected: state.criticalInjected, - decision: decision ? decision.decision : "none", + decision: decisionValue, }); } catch (error) { const err = /** @type {Error} */ error; diff --git a/actions/setup/js/copilot_steering_hook.test.cjs b/actions/setup/js/copilot_steering_hook.test.cjs index dc614b97dd2..1c2d424c781 100644 --- a/actions/setup/js/copilot_steering_hook.test.cjs +++ b/actions/setup/js/copilot_steering_hook.test.cjs @@ -44,6 +44,14 @@ describe("copilot_steering_hook.cjs", () => { expect(config.hookLogPath).toBe("/tmp/gh-aw/sandbox/agent/logs/copilot-steering-hook.log"); }); + it("uses GH_AW_COPILOT_STEERING_LOG_PATH when provided", () => { + const config = loadSteeringConfig({ + GH_AW_COPILOT_STEERING_STATE_PATH: "/tmp/state.json", + GH_AW_COPILOT_STEERING_LOG_PATH: "/tmp/custom-steering.log", + }); + expect(config.hookLogPath).toBe("/tmp/custom-steering.log"); + }); + it("appends hook event log entries as JSON lines", () => { makeTestEnv(); const hookLogPath = path.join(tempDir, "hook.log"); From fd8060a435cbc1a69a61052d55c4c30c045acaca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 6 May 2026 23:50:36 +0000 Subject: [PATCH 18/19] fix: enable repo hooks in copilot prompt mode Agent-Logs-Url: https://github.com/github/gh-aw/sessions/50cbe7e5-3fcf-40bb-a33e-f605fa12d2be Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 1 + docs/public/ai/service.json | 10 +--------- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 7976688a3c7..84aa9f860d5 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -398,6 +398,7 @@ function installCopilotSteeringHooks(resolvedArgs) { process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES || "2"; process.env.GH_AW_STEERING_RUN_WARNING_REMAINING = process.env.GH_AW_STEERING_RUN_WARNING_REMAINING || "2"; process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING = process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING || "1"; + process.env.GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS = "true"; fs.mkdirSync(hooksDir, { recursive: true }); const hookConfig = buildSteeringHookConfig(hookScriptPath, process.execPath); diff --git a/docs/public/ai/service.json b/docs/public/ai/service.json index a820d203ed2..3609b13383c 100644 --- a/docs/public/ai/service.json +++ b/docs/public/ai/service.json @@ -20,15 +20,7 @@ { "id": "codex", "name": "OpenAI Codex" }, { "id": "custom", "name": "Custom engine" } ], - "integrations": [ - "GitHub Actions", - "GitHub CLI", - "MCP (Model Context Protocol)", - "Playwright", - "GitHub Issues", - "GitHub Pull Requests", - "GitHub Discussions" - ], + "integrations": ["GitHub Actions", "GitHub CLI", "MCP (Model Context Protocol)", "Playwright", "GitHub Issues", "GitHub Pull Requests", "GitHub Discussions"], "install": "gh extension install github/gh-aw", "docs": "https://github.github.com/gh-aw/", "source": "https://github.com/github/gh-aw", From 359723321dae8aa2f8ef1da771fb82bc5f437d23 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 7 May 2026 03:11:49 +0000 Subject: [PATCH 19/19] fix: enable prompt-mode copilot extensions in harness Agent-Logs-Url: https://github.com/github/gh-aw/sessions/bdf49744-5d3c-48dc-9fdf-652f22b1eed5 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/copilot_harness.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/actions/setup/js/copilot_harness.cjs b/actions/setup/js/copilot_harness.cjs index 0519aac1ee9..fa62ce3e535 100644 --- a/actions/setup/js/copilot_harness.cjs +++ b/actions/setup/js/copilot_harness.cjs @@ -399,6 +399,7 @@ function installCopilotSteeringHooks(resolvedArgs) { process.env.GH_AW_STEERING_RUN_WARNING_REMAINING = process.env.GH_AW_STEERING_RUN_WARNING_REMAINING || "2"; process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING = process.env.GH_AW_STEERING_RUN_CRITICAL_REMAINING || "1"; process.env.GITHUB_COPILOT_PROMPT_MODE_REPO_HOOKS = "true"; + process.env.GITHUB_COPILOT_PROMPT_MODE_EXTENSIONS = "true"; fs.mkdirSync(hooksDir, { recursive: true }); const hookConfig = buildSteeringHookConfig(hookScriptPath, process.execPath);