From cbf08fff03af9d3d98c4e6573bbd2781a3037b59 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:05:20 +0000 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20add=20pi=5Fsteering=5Fextension.cjs?= =?UTF-8?q?=20implementing=20Pi=20agent=20steering=20per=20aw-harness=20sp?= =?UTF-8?q?ec=20=C2=A78.3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a03d2041-2904-44b1-9652-b34592421793 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/pi_steering_extension.cjs | 105 ++++++ .../setup/js/pi_steering_extension.test.cjs | 326 ++++++++++++++++++ 2 files changed, 431 insertions(+) create mode 100644 actions/setup/js/pi_steering_extension.cjs create mode 100644 actions/setup/js/pi_steering_extension.test.cjs diff --git a/actions/setup/js/pi_steering_extension.cjs b/actions/setup/js/pi_steering_extension.cjs new file mode 100644 index 0000000000..1ddfb5a353 --- /dev/null +++ b/actions/setup/js/pi_steering_extension.cjs @@ -0,0 +1,105 @@ +// @ts-check + +/** + * Pi Steering Extension for gh-aw + * + * Monitors elapsed time and injects steering messages into a Pi agent session + * when remaining time falls below configured thresholds. Implements the + * steering extension described in the aw-harness specification §8.3. + * + * Load this extension by passing it to `pi run` via `engine.args`: + * + * engine: + * id: pi + * args: + * - --extension + * - /tmp/gh-aw/actions/pi_steering_extension.cjs + * + * Configuration (read from environment variables): + * GH_AW_TIMEOUT_MINUTES Total allowed runtime in minutes (default: 30) + * GH_AW_STEERING_TIME_WARNING_MINUTES Minutes-remaining threshold for warning message (default: 5) + * GH_AW_STEERING_TIME_CRITICAL_MINUTES Minutes-remaining threshold for critical message (default: 2) + */ + +"use strict"; + +/** Default total session timeout in minutes. */ +const DEFAULT_TIMEOUT_MINUTES = 30; + +/** Default minutes-remaining threshold for the warning steering message. */ +const DEFAULT_TIME_WARNING_MINUTES = 5; + +/** Default minutes-remaining threshold for the critical steering message. */ +const DEFAULT_TIME_CRITICAL_MINUTES = 2; + +/** + * Loads steering configuration from environment variables. + * @returns {{ timeoutMinutes: number, timeWarningMinutes: number, timeCriticalMinutes: number }} + */ +function loadSteeringConfig() { + const timeoutMinutes = parseFloat(process.env.GH_AW_TIMEOUT_MINUTES || "") || DEFAULT_TIMEOUT_MINUTES; + const timeWarningMinutes = parseFloat(process.env.GH_AW_STEERING_TIME_WARNING_MINUTES || "") || DEFAULT_TIME_WARNING_MINUTES; + const timeCriticalMinutes = parseFloat(process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES || "") || DEFAULT_TIME_CRITICAL_MINUTES; + return { timeoutMinutes, timeWarningMinutes, timeCriticalMinutes }; +} + +/** + * Pi steering extension for gh-aw. + * + * Subscribes to `agent_start` and `turn_end` Pi SDK events and injects time-pressure + * steering messages when the remaining session time falls below configured thresholds. + * Each threshold fires at most once per session to avoid message flooding. + * + * @param {any} pi - Pi ExtensionAPI instance + * @returns {void} + */ +function piSteeringExtension(pi) { + const config = loadSteeringConfig(); + + /** @type {number | undefined} */ + let startTime; + let warningInjected = false; + let criticalInjected = false; + + pi.on("agent_start", async () => { + startTime = Date.now(); + process.stderr.write(`[gh-aw/steering] Session started. ` + `timeout=${config.timeoutMinutes}min, ` + `warn<${config.timeWarningMinutes}min, ` + `critical<${config.timeCriticalMinutes}min\n`); + }); + + pi.on("turn_end", async (/** @type {any} */ _event, /** @type {any} */ ctx) => { + if (startTime === undefined) { + return; + } + + const elapsedMinutes = (Date.now() - startTime) / 60000; + const remainingMinutes = config.timeoutMinutes - elapsedMinutes; + + if (remainingMinutes <= config.timeCriticalMinutes && !criticalInjected) { + // Mark warning as injected too — critical supersedes it. + warningInjected = true; + criticalInjected = true; + process.stderr.write(`[gh-aw/steering] CRITICAL: ${remainingMinutes.toFixed(1)}min remaining — injecting critical message\n`); + ctx.agent.steer({ + role: "user", + content: `⚠️ CRITICAL: Only ${remainingMinutes.toFixed(0)} minute(s) remaining before the workflow times out. Stop all new research and produce your final output immediately.`, + timestamp: Date.now(), + }); + } else if (remainingMinutes <= config.timeWarningMinutes && !warningInjected) { + warningInjected = true; + process.stderr.write(`[gh-aw/steering] WARNING: ${remainingMinutes.toFixed(1)}min remaining — injecting warning message\n`); + ctx.agent.steer({ + role: "user", + content: `⚠️ ${remainingMinutes.toFixed(0)} minute(s) remaining. Please wrap up your current task and start writing your final output.`, + timestamp: Date.now(), + }); + } + }); +} + +module.exports = piSteeringExtension; + +// Export helpers for testing +if (typeof module !== "undefined" && module.exports) { + module.exports = piSteeringExtension; + module.exports.loadSteeringConfig = loadSteeringConfig; +} diff --git a/actions/setup/js/pi_steering_extension.test.cjs b/actions/setup/js/pi_steering_extension.test.cjs new file mode 100644 index 0000000000..3990aa42f0 --- /dev/null +++ b/actions/setup/js/pi_steering_extension.test.cjs @@ -0,0 +1,326 @@ +import { describe, it, expect, beforeEach, afterEach, vi } from "vitest"; + +describe("pi_steering_extension.cjs", () => { + let piSteeringExtension, loadSteeringConfig; + let originalEnv; + let stderrOutput; + + beforeEach(async () => { + originalEnv = { ...process.env }; + + // Capture stderr writes + stderrOutput = []; + vi.spyOn(process.stderr, "write").mockImplementation(msg => { + stderrOutput.push(String(msg)); + return true; + }); + + const module = await import("./pi_steering_extension.cjs?" + Date.now()); + piSteeringExtension = module.default; + loadSteeringConfig = module.loadSteeringConfig; + }); + + afterEach(() => { + process.env = originalEnv; + vi.restoreAllMocks(); + }); + + // --------------------------------------------------------------------------- + // loadSteeringConfig + // --------------------------------------------------------------------------- + describe("loadSteeringConfig", () => { + it("should return default values when no env vars are set", () => { + delete process.env.GH_AW_TIMEOUT_MINUTES; + delete process.env.GH_AW_STEERING_TIME_WARNING_MINUTES; + delete process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES; + + const config = loadSteeringConfig(); + + expect(config.timeoutMinutes).toBe(30); + expect(config.timeWarningMinutes).toBe(5); + expect(config.timeCriticalMinutes).toBe(2); + }); + + it("should read timeout from GH_AW_TIMEOUT_MINUTES", () => { + process.env.GH_AW_TIMEOUT_MINUTES = "45"; + + const config = loadSteeringConfig(); + + expect(config.timeoutMinutes).toBe(45); + }); + + it("should read warning threshold from GH_AW_STEERING_TIME_WARNING_MINUTES", () => { + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "10"; + + const config = loadSteeringConfig(); + + expect(config.timeWarningMinutes).toBe(10); + }); + + it("should read critical threshold from GH_AW_STEERING_TIME_CRITICAL_MINUTES", () => { + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "3"; + + const config = loadSteeringConfig(); + + expect(config.timeCriticalMinutes).toBe(3); + }); + + it("should fall back to defaults for non-numeric env var values", () => { + process.env.GH_AW_TIMEOUT_MINUTES = "not-a-number"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = ""; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "abc"; + + const config = loadSteeringConfig(); + + expect(config.timeoutMinutes).toBe(30); + expect(config.timeWarningMinutes).toBe(5); + expect(config.timeCriticalMinutes).toBe(2); + }); + + it("should support fractional minute values", () => { + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "2.5"; + + const config = loadSteeringConfig(); + + expect(config.timeWarningMinutes).toBe(2.5); + }); + }); + + // --------------------------------------------------------------------------- + // piSteeringExtension — event handler registration + // --------------------------------------------------------------------------- + describe("piSteeringExtension registration", () => { + it("should register agent_start and turn_end handlers", () => { + const handlers = {}; + const mockPi = { + on: vi.fn((event, handler) => { + handlers[event] = handler; + }), + }; + + piSteeringExtension(mockPi); + + expect(mockPi.on).toHaveBeenCalledWith("agent_start", expect.any(Function)); + expect(mockPi.on).toHaveBeenCalledWith("turn_end", expect.any(Function)); + }); + }); + + // --------------------------------------------------------------------------- + // piSteeringExtension — agent_start handler + // --------------------------------------------------------------------------- + describe("agent_start handler", () => { + it("should log session start to stderr", async () => { + const handlers = {}; + const mockPi = { + on: vi.fn((event, handler) => { + handlers[event] = handler; + }), + }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + expect(stderrOutput.some(line => line.includes("[gh-aw/steering] Session started"))).toBe(true); + }); + }); + + // --------------------------------------------------------------------------- + // piSteeringExtension — turn_end handler: no steer before any threshold + // --------------------------------------------------------------------------- + describe("turn_end handler", () => { + it("should not steer when plenty of time remains", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "30"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + + // Start the session + await handlers["agent_start"](); + + // Fake only 1 minute elapsed — 29 minutes remaining, well above thresholds + vi.setSystemTime(Date.now() + 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(0); + }); + + it("should inject a warning message when time remaining falls below warning threshold", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + // 6 minutes elapsed → 4 minutes remaining (below 5-min warning threshold) + vi.setSystemTime(Date.now() + 6 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(1); + expect(steerCalls[0].content).toContain("⚠️"); + expect(steerCalls[0].content).toContain("minute"); + expect(steerCalls[0].role).toBe("user"); + expect(typeof steerCalls[0].timestamp).toBe("number"); + }); + + it("should inject a critical message when time remaining falls below critical threshold", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + // 9 minutes elapsed → 1 minute remaining (below 2-min critical threshold) + vi.setSystemTime(Date.now() + 9 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(1); + expect(steerCalls[0].content).toContain("CRITICAL"); + expect(steerCalls[0].role).toBe("user"); + }); + + it("should only inject the warning message once even across multiple turns", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + // First turn at 4min remaining — triggers warning + vi.setSystemTime(Date.now() + 6 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + // Second turn at 3.5min remaining — should NOT fire again + vi.setSystemTime(Date.now() + 30 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(1); + }); + + it("should only inject the critical message once even across multiple turns", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + // First turn — critical threshold crossed + vi.setSystemTime(Date.now() + 9 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + // Second turn — still critical, should NOT fire again + vi.setSystemTime(Date.now() + 30 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(1); + }); + + it("should not steer when agent_start has not fired yet", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + + // Call turn_end WITHOUT calling agent_start first + vi.setSystemTime(Date.now() + 20 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(0); + }); + + it("should log to stderr when injecting warning message", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn() } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + vi.setSystemTime(Date.now() + 6 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(stderrOutput.some(line => line.includes("[gh-aw/steering] WARNING"))).toBe(true); + }); + + it("should log to stderr when injecting critical message", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn() } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + vi.setSystemTime(Date.now() + 9 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(stderrOutput.some(line => line.includes("[gh-aw/steering] CRITICAL"))).toBe(true); + }); + + it("should inject warning before critical when time drops through both thresholds", async () => { + process.env.GH_AW_TIMEOUT_MINUTES = "10"; + process.env.GH_AW_STEERING_TIME_WARNING_MINUTES = "5"; + process.env.GH_AW_STEERING_TIME_CRITICAL_MINUTES = "2"; + + const handlers = {}; + const steerCalls = []; + const mockPi = { on: vi.fn((e, h) => (handlers[e] = h)) }; + const mockCtx = { agent: { steer: vi.fn(msg => steerCalls.push(msg)) } }; + + piSteeringExtension(mockPi); + await handlers["agent_start"](); + + // Turn 1: 4 min remaining → warning + vi.setSystemTime(Date.now() + 6 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + // Turn 2: 1 min remaining → critical + vi.setSystemTime(Date.now() + 3 * 60 * 1000); + await handlers["turn_end"]({}, mockCtx); + + expect(steerCalls).toHaveLength(2); + expect(steerCalls[0].content).not.toContain("CRITICAL"); + expect(steerCalls[1].content).toContain("CRITICAL"); + }); + }); +}); From 234f5764c7d1dca508090217a16be5adf731438c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:06:43 +0000 Subject: [PATCH 2/8] fix: address code review feedback - clean up redundant exports and string concatenation Agent-Logs-Url: https://github.com/github/gh-aw/sessions/a03d2041-2904-44b1-9652-b34592421793 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/pi_steering_extension.cjs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/actions/setup/js/pi_steering_extension.cjs b/actions/setup/js/pi_steering_extension.cjs index 1ddfb5a353..008308556c 100644 --- a/actions/setup/js/pi_steering_extension.cjs +++ b/actions/setup/js/pi_steering_extension.cjs @@ -63,7 +63,7 @@ function piSteeringExtension(pi) { pi.on("agent_start", async () => { startTime = Date.now(); - process.stderr.write(`[gh-aw/steering] Session started. ` + `timeout=${config.timeoutMinutes}min, ` + `warn<${config.timeWarningMinutes}min, ` + `critical<${config.timeCriticalMinutes}min\n`); + process.stderr.write(`[gh-aw/steering] Session started. timeout=${config.timeoutMinutes}min, warn<${config.timeWarningMinutes}min, critical<${config.timeCriticalMinutes}min\n`); }); pi.on("turn_end", async (/** @type {any} */ _event, /** @type {any} */ ctx) => { @@ -97,9 +97,4 @@ function piSteeringExtension(pi) { } module.exports = piSteeringExtension; - -// Export helpers for testing -if (typeof module !== "undefined" && module.exports) { - module.exports = piSteeringExtension; - module.exports.loadSteeringConfig = loadSteeringConfig; -} +module.exports.loadSteeringConfig = loadSteeringConfig; From 3834ebb6e781a971674bc64f924b730fa3c1dbca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:22:42 +0000 Subject: [PATCH 3/8] feat: automatically inject pi_steering_extension into Pi execution args Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b4d9fcd0-caaf-4d2f-83d9-59b0f3a0652a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- actions/setup/js/pi_steering_extension.cjs | 9 ++------- pkg/workflow/pi_engine.go | 6 +++++- pkg/workflow/pi_engine_test.go | 1 + 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/actions/setup/js/pi_steering_extension.cjs b/actions/setup/js/pi_steering_extension.cjs index 008308556c..e38dc6d9d9 100644 --- a/actions/setup/js/pi_steering_extension.cjs +++ b/actions/setup/js/pi_steering_extension.cjs @@ -7,13 +7,8 @@ * when remaining time falls below configured thresholds. Implements the * steering extension described in the aw-harness specification §8.3. * - * Load this extension by passing it to `pi run` via `engine.args`: - * - * engine: - * id: pi - * args: - * - --extension - * - /tmp/gh-aw/actions/pi_steering_extension.cjs + * This extension is automatically added to every Pi agent invocation by the + * gh-aw compiler. No workflow frontmatter configuration is required. * * Configuration (read from environment variables): * GH_AW_TIMEOUT_MINUTES Total allowed runtime in minutes (default: 30) diff --git a/pkg/workflow/pi_engine.go b/pkg/workflow/pi_engine.go index 7fc6df0cd3..50a49505a0 100644 --- a/pkg/workflow/pi_engine.go +++ b/pkg/workflow/pi_engine.go @@ -163,7 +163,11 @@ func (e *PiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) } // The prompt is piped from a file via stdin substitution. - piCommand := fmt.Sprintf("cat /tmp/gh-aw/aw-prompts/prompt.txt | %s %s", + // The built-in steering extension is automatically loaded so that every Pi session + // receives time-pressure steering messages without requiring workflow configuration. + // ${RUNNER_TEMP} is expanded by the shell at runtime. + piCommand := fmt.Sprintf( + `cat /tmp/gh-aw/aw-prompts/prompt.txt | %s %s --extension "${RUNNER_TEMP}/gh-aw/actions/pi_steering_extension.cjs"`, commandName, shellJoinArgs(piArgs)) modelConfigured := workflowData.EngineConfig != nil && workflowData.EngineConfig.Model != "" diff --git a/pkg/workflow/pi_engine_test.go b/pkg/workflow/pi_engine_test.go index 1fb383bcd9..1d7ca6cfe6 100644 --- a/pkg/workflow/pi_engine_test.go +++ b/pkg/workflow/pi_engine_test.go @@ -133,6 +133,7 @@ func TestPiEngine_GetExecutionSteps_Basic(t *testing.T) { assert.Contains(t, stepText, "pi run", "Step should run `pi run`") assert.Contains(t, stepText, "json-log", "Step should include JSON log flag") assert.Contains(t, stepText, "agentic_execution", "Step should have agentic_execution id") + assert.Contains(t, stepText, "pi_steering_extension.cjs", "Step should automatically load the steering extension") } func TestPiEngine_GetExecutionSteps_WithModel(t *testing.T) { From a672ba2966756ad30aac2ac15f68ef14e1aea222 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:23:56 +0000 Subject: [PATCH 4/8] docs: clarify steering extension auto-injection comment (multiple extensions, shell compat) Agent-Logs-Url: https://github.com/github/gh-aw/sessions/b4d9fcd0-caaf-4d2f-83d9-59b0f3a0652a Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- pkg/workflow/pi_engine.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pkg/workflow/pi_engine.go b/pkg/workflow/pi_engine.go index 50a49505a0..58d27f6f01 100644 --- a/pkg/workflow/pi_engine.go +++ b/pkg/workflow/pi_engine.go @@ -165,7 +165,11 @@ func (e *PiEngine) GetExecutionSteps(workflowData *WorkflowData, logFile string) // The prompt is piped from a file via stdin substitution. // The built-in steering extension is automatically loaded so that every Pi session // receives time-pressure steering messages without requiring workflow configuration. - // ${RUNNER_TEMP} is expanded by the shell at runtime. + // Pi CLI supports multiple --extension flags; user-specified extensions (via engine.args) + // are appended before this flag so the built-in extension loads last, consistent with the + // aw-harness spec's "built-in extensions after user extensions" ordering. + // ${RUNNER_TEMP} is a Linux shell variable expanded by bash at runtime; gh-aw container + // environments are Linux-only so this is safe across all supported runner configurations. piCommand := fmt.Sprintf( `cat /tmp/gh-aw/aw-prompts/prompt.txt | %s %s --extension "${RUNNER_TEMP}/gh-aw/actions/pi_steering_extension.cjs"`, commandName, shellJoinArgs(piArgs)) From 6646b4fb83edaa46e48dc8bd4ead9a9d029791de Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:44:03 +0000 Subject: [PATCH 5/8] wip: create chi.yml placeholder Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7f09e465-6320-4aac-a7be-94ec441fdcae Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/dev.lock.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev.lock.yml b/.github/workflows/dev.lock.yml index 57215e8302..224c1f4d2d 100644 --- a/.github/workflows/dev.lock.yml +++ b/.github/workflows/dev.lock.yml @@ -710,7 +710,7 @@ jobs: printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/schemas/awf-config.v1.json","network":{"allowDomains":["api.pi.ai","api.snapcraft.io","archive.ubuntu.com","azure.archive.ubuntu.com","crl.geotrust.com","crl.globalsign.com","crl.identrust.com","crl.sectigo.com","crl.thawte.com","crl.usertrust.com","crl.verisign.com","crl3.digicert.com","crl4.digicert.com","crls.ssl.com","github.com","host.docker.internal","json-schema.org","json.schemastore.org","keyserver.ubuntu.com","ocsp.digicert.com","ocsp.geotrust.com","ocsp.globalsign.com","ocsp.identrust.com","ocsp.sectigo.com","ocsp.ssl.com","ocsp.thawte.com","ocsp.usertrust.com","ocsp.verisign.com","packagecloud.io","packages.cloud.google.com","packages.microsoft.com","ppa.launchpad.net","raw.githubusercontent.com","registry.npmjs.org","s.symcb.com","s.symcd.com","security.ubuntu.com","ts-crl.ws.symantec.com","ts-ocsp.ws.symantec.com","www.googleapis.com"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.29,squid=sha256:8a71ad9e40454051672312917e51567abfb8251d7c294d086c48f63d84e4cb53,agent=sha256:e68f37e36962dcb3f3d1de680a49bc2302cefd001b941a7dc377155ec7ce42f4,agent-act=sha256:97b4cc14dc2123a45b9d5b9927489f66882dec5857de6afc0e5bab257be92ef1,api-proxy=sha256:d1219e4110684402aabbeb5a43858f26790c9d0be210581cf3f7a521bd2c87b6,cli-proxy=sha256:29917488eb90a01ff9544ffeeb5cc26434a8ea16d69ae8972f5f6be0e567e276"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env GH_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --exclude-env PI_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull --difc-proxy-host host.docker.internal:18443 --difc-proxy-ca-cert /tmp/gh-aw/difc-proxy-tls/ca.crt \ - -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cat /tmp/gh-aw/aw-prompts/prompt.txt | pi run --json-log /tmp/gh-aw/pi-streaming.jsonl' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log + -- /bin/bash -c 'export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cat /tmp/gh-aw/aw-prompts/prompt.txt | pi run --json-log /tmp/gh-aw/pi-streaming.jsonl --extension "${RUNNER_TEMP}/gh-aw/actions/pi_steering_extension.cjs"' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log env: GH_AW_PHASE: agent GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt @@ -1159,7 +1159,7 @@ jobs: printf '%s\n' '{"$schema":"https://github.com/github/gh-aw-firewall/schemas/awf-config.v1.json","network":{"allowDomains":["api.pi.ai","github.com","host.docker.internal","raw.githubusercontent.com","registry.npmjs.org"]},"apiProxy":{"enabled":true},"container":{"imageTag":"0.25.29,squid=sha256:8a71ad9e40454051672312917e51567abfb8251d7c294d086c48f63d84e4cb53,agent=sha256:e68f37e36962dcb3f3d1de680a49bc2302cefd001b941a7dc377155ec7ce42f4,agent-act=sha256:97b4cc14dc2123a45b9d5b9927489f66882dec5857de6afc0e5bab257be92ef1,api-proxy=sha256:d1219e4110684402aabbeb5a43858f26790c9d0be210581cf3f7a521bd2c87b6,cli-proxy=sha256:29917488eb90a01ff9544ffeeb5cc26434a8ea16d69ae8972f5f6be0e567e276"}}' > "${RUNNER_TEMP}/gh-aw/awf-config.json" && cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json # shellcheck disable=SC1003 sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" --env-all --exclude-env PI_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \ - -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cat /tmp/gh-aw/aw-prompts/prompt.txt | pi run --json-log /tmp/gh-aw/pi-streaming.jsonl' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log + -- /bin/bash -c 'export PATH="$(find /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 4 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && cat /tmp/gh-aw/aw-prompts/prompt.txt | pi run --json-log /tmp/gh-aw/pi-streaming.jsonl --extension "${RUNNER_TEMP}/gh-aw/actions/pi_steering_extension.cjs"' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log env: GH_AW_PHASE: detection GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt From de440a39b54dea0b1deb54ae84f51cd0280fbdcf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:45:19 +0000 Subject: [PATCH 6/8] feat: add chi.yml integration test for Pi steering extension using Anthropic key Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7f09e465-6320-4aac-a7be-94ec441fdcae Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/chi.yml | 98 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 .github/workflows/chi.yml diff --git a/.github/workflows/chi.yml b/.github/workflows/chi.yml new file mode 100644 index 0000000000..5ac9541e77 --- /dev/null +++ b/.github/workflows/chi.yml @@ -0,0 +1,98 @@ +name: CHI + +on: + pull_request: + types: [opened, synchronize, reopened, ready_for_review] + paths: + - 'actions/setup/js/pi_*.cjs' + - '.github/workflows/chi.yml' + workflow_dispatch: + +jobs: + pi-extension-integration: + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + concurrency: + group: chi-${{ github.ref }}-pi-extension-integration + cancel-in-progress: true + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Node.js + id: setup-node + uses: actions/setup-node@395ad3262231945c25e8478fd5baf05154b1d79f # v6 + with: + node-version: "24" + cache: npm + cache-dependency-path: actions/setup/js/package-lock.json + + - name: Report Node cache status + run: | + if [ "${{ steps.setup-node.outputs.cache-hit }}" == "true" ]; then + echo "✅ Node cache hit" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ Node cache miss" >> $GITHUB_STEP_SUMMARY + fi + + - name: Install npm dependencies + run: cd actions/setup/js && npm ci + + - name: Install Pi CLI + run: npm install -g @pi/cli + + - name: Run Pi steering extension integration test + id: pi_integration + timeout-minutes: 5 + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + # Use a short total timeout and low thresholds so the steering + # extension fires quickly, validating end-to-end injection. + GH_AW_TIMEOUT_MINUTES: "3" + GH_AW_STEERING_TIME_WARNING_MINUTES: "2.5" + GH_AW_STEERING_TIME_CRITICAL_MINUTES: "0.5" + run: | + set -o pipefail + + if [ -z "$ANTHROPIC_API_KEY" ]; then + echo "⚠️ ANTHROPIC_API_KEY not available — skipping Pi integration test" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ Set the ANTHROPIC_API_KEY secret to enable live Pi agent integration tests" >> $GITHUB_STEP_SUMMARY + exit 0 + fi + + echo "## Pi Steering Extension Integration Test" >> $GITHUB_STEP_SUMMARY + echo "" >> $GITHUB_STEP_SUMMARY + + LOG_FILE=/tmp/pi-streaming.json + + echo "Say 'Hello from Pi — steering extension loaded.' in exactly one sentence." | \ + pi run \ + --json-log "$LOG_FILE" \ + --extension "${{ github.workspace }}/actions/setup/js/pi_steering_extension.cjs" \ + 2>&1 | tee /tmp/pi-output.log + + echo "" >> $GITHUB_STEP_SUMMARY + echo "✅ Pi ran successfully with the steering extension loaded" >> $GITHUB_STEP_SUMMARY + + - name: Verify steering extension was loaded + if: ${{ env.ANTHROPIC_API_KEY != '' }} + run: | + if grep -q "\[gh-aw/steering\]" /tmp/pi-output.log; then + echo "✅ Steering extension initialized (found [gh-aw/steering] in output)" >> $GITHUB_STEP_SUMMARY + else + echo "⚠️ [gh-aw/steering] marker not found in output — extension may not have been loaded" >> $GITHUB_STEP_SUMMARY + fi + env: + ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} + + - name: Upload Pi logs + if: always() + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: pi-extension-logs + path: | + /tmp/pi-output.log + /tmp/pi-streaming.json + if-no-files-found: ignore From 551fc17ed3e6df1d8bf7d8bda58c1e04a81bde6d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 21:46:17 +0000 Subject: [PATCH 7/8] fix: use step output for verify condition; rename log to .jsonl Agent-Logs-Url: https://github.com/github/gh-aw/sessions/7f09e465-6320-4aac-a7be-94ec441fdcae Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/chi.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/chi.yml b/.github/workflows/chi.yml index 5ac9541e77..68b25c0d16 100644 --- a/.github/workflows/chi.yml +++ b/.github/workflows/chi.yml @@ -65,7 +65,7 @@ jobs: echo "## Pi Steering Extension Integration Test" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY - LOG_FILE=/tmp/pi-streaming.json + LOG_FILE=/tmp/pi-streaming.jsonl echo "Say 'Hello from Pi — steering extension loaded.' in exactly one sentence." | \ pi run \ @@ -73,19 +73,18 @@ jobs: --extension "${{ github.workspace }}/actions/setup/js/pi_steering_extension.cjs" \ 2>&1 | tee /tmp/pi-output.log + echo "ran=true" >> "$GITHUB_OUTPUT" echo "" >> $GITHUB_STEP_SUMMARY echo "✅ Pi ran successfully with the steering extension loaded" >> $GITHUB_STEP_SUMMARY - name: Verify steering extension was loaded - if: ${{ env.ANTHROPIC_API_KEY != '' }} + if: ${{ steps.pi_integration.outputs.ran == 'true' }} run: | if grep -q "\[gh-aw/steering\]" /tmp/pi-output.log; then echo "✅ Steering extension initialized (found [gh-aw/steering] in output)" >> $GITHUB_STEP_SUMMARY else echo "⚠️ [gh-aw/steering] marker not found in output — extension may not have been loaded" >> $GITHUB_STEP_SUMMARY fi - env: - ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }} - name: Upload Pi logs if: always() @@ -94,5 +93,5 @@ jobs: name: pi-extension-logs path: | /tmp/pi-output.log - /tmp/pi-streaming.json + /tmp/pi-streaming.jsonl if-no-files-found: ignore From 83c4f47830771ca2e664d0e135f80fa50a4d6ffe Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 2 May 2026 22:38:37 +0000 Subject: [PATCH 8/8] chore: rename chi.yml to cpi.yml Agent-Logs-Url: https://github.com/github/gh-aw/sessions/faf0ae2d-3c89-453a-a2c5-1c2111acfaf1 Co-authored-by: pelikhan <4175913+pelikhan@users.noreply.github.com> --- .github/workflows/{chi.yml => cpi.yml} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename .github/workflows/{chi.yml => cpi.yml} (100%) diff --git a/.github/workflows/chi.yml b/.github/workflows/cpi.yml similarity index 100% rename from .github/workflows/chi.yml rename to .github/workflows/cpi.yml