Skip to content
97 changes: 97 additions & 0 deletions .github/workflows/cpi.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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.jsonl

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 "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: ${{ 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

- 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.jsonl
if-no-files-found: ignore
4 changes: 2 additions & 2 deletions .github/workflows/dev.lock.yml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

95 changes: 95 additions & 0 deletions actions/setup/js/pi_steering_extension.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
// @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.
*
* 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)
* 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;
Comment on lines +35 to +37
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;
module.exports.loadSteeringConfig = loadSteeringConfig;
Loading
Loading