From bc7da9e2d0d63f0d200dc962a0a7dcfc52ab1764 Mon Sep 17 00:00:00 2001 From: Oz Date: Sun, 21 Jun 2026 11:47:31 -0400 Subject: [PATCH] Add oz-harness-support plugin to public marketplace Bring Oz cloud agent support plugin into claude-code-warp so external is single source (toward deprecating claude-code-warp-internal). Add plugin-local README + top README pointer. Co-Authored-By: Oz --- .claude-plugin/marketplace.json | 8 + README.md | 4 + .../.claude-plugin/plugin.json | 10 + plugins/oz-harness-support/README.md | 28 ++ plugins/oz-harness-support/hooks/hooks.json | 56 +++ .../scripts/drain-mailbox.sh | 21 + .../scripts/on-session-end.sh | 10 + .../scripts/on-session-start.sh | 11 + plugins/oz-harness-support/scripts/on-stop.sh | 8 + .../scripts/oz-parent-common.sh | 421 ++++++++++++++++++ .../scripts/oz-parent-listener.sh | 38 ++ .../oz-child-agent-orchestration/SKILL.md | 64 +++ .../skills/oz-finish-task/SKILL.md | 11 + .../skills/oz-notify-user/SKILL.md | 11 + .../skills/oz-report-pr/SKILL.md | 11 + .../skills/oz-upload-file/SKILL.md | 15 + .../oz-harness-support/tests/test-hooks.sh | 233 ++++++++++ 17 files changed, 960 insertions(+) create mode 100644 plugins/oz-harness-support/.claude-plugin/plugin.json create mode 100644 plugins/oz-harness-support/README.md create mode 100644 plugins/oz-harness-support/hooks/hooks.json create mode 100755 plugins/oz-harness-support/scripts/drain-mailbox.sh create mode 100755 plugins/oz-harness-support/scripts/on-session-end.sh create mode 100755 plugins/oz-harness-support/scripts/on-session-start.sh create mode 100755 plugins/oz-harness-support/scripts/on-stop.sh create mode 100755 plugins/oz-harness-support/scripts/oz-parent-common.sh create mode 100755 plugins/oz-harness-support/scripts/oz-parent-listener.sh create mode 100644 plugins/oz-harness-support/skills/oz-child-agent-orchestration/SKILL.md create mode 100644 plugins/oz-harness-support/skills/oz-finish-task/SKILL.md create mode 100644 plugins/oz-harness-support/skills/oz-notify-user/SKILL.md create mode 100644 plugins/oz-harness-support/skills/oz-report-pr/SKILL.md create mode 100644 plugins/oz-harness-support/skills/oz-upload-file/SKILL.md create mode 100755 plugins/oz-harness-support/tests/test-hooks.sh diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index f891a3a..c898bff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -13,6 +13,14 @@ "version": "2.1.0", "category": "productivity", "tags": ["notifications", "terminal", "warp"] + }, + { + "name": "oz-harness-support", + "description": "Warp integration for Claude Code in Oz cloud agent environments, including parent-message delivery hooks", + "source": "./plugins/oz-harness-support", + "version": "1.1.2", + "category": "productivity", + "tags": ["terminal", "warp", "oz"] } ] } diff --git a/README.md b/README.md index 6bc7f70..e767524 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,10 @@ Notifications work out of the box. To customize Warp's notification behavior (so The plugin version in `plugins/warp/.claude-plugin/plugin.json` is checked by the Warp client to detect outdated installations. When bumping the version here, also update `MINIMUM_PLUGIN_VERSION` in the Warp client. +## Oz Cloud Agent Support + +Running Claude Code inside Warp's Oz cloud agents? See the [`oz-harness-support` plugin](plugins/oz-harness-support/README.md), installed automatically in those environments. + ## License MIT License — see [LICENSE](LICENSE) for details. diff --git a/plugins/oz-harness-support/.claude-plugin/plugin.json b/plugins/oz-harness-support/.claude-plugin/plugin.json new file mode 100644 index 0000000..e2c9d3e --- /dev/null +++ b/plugins/oz-harness-support/.claude-plugin/plugin.json @@ -0,0 +1,10 @@ +{ + "name": "oz-harness-support", + "description": "Warp terminal integration for Claude Code in Oz cloud agent environments, including parent-message delivery hooks", + "version": "1.1.2", + "author": { + "name": "Warp", + "url": "https://warp.dev" + }, + "homepage": "https://github.com/warpdotdev/claude-code-warp" +} diff --git a/plugins/oz-harness-support/README.md b/plugins/oz-harness-support/README.md new file mode 100644 index 0000000..f9d461b --- /dev/null +++ b/plugins/oz-harness-support/README.md @@ -0,0 +1,28 @@ +# oz-harness-support + +Warp integration for [Claude Code](https://docs.anthropic.com/en/docs/claude-code) running inside Warp's **Oz cloud agent** environments. + +This plugin is installed automatically in Oz cloud agent environments — there is no manual setup for end users. + +## Hooks + +The plugin registers an Oz parent-message delivery bridge: +- **SessionStart** — starts the parent-message listener for the run +- **UserPromptSubmit** / **PostToolUse** — drain the mailbox, surfacing queued parent messages into the session as additional context +- **Stop** — keeps the session active when parent messages are still pending delivery +- **SessionEnd** — tears down the listener and cleans up hook state + +## Skills + +The plugin ships skills the agent uses to talk to the Oz platform: +- **oz-child-agent-orchestration** — coordinate with a lead run via the Oz CLI (`OZ_CLI`, `OZ_RUN_ID`, `OZ_PARENT_RUN_ID`) +- **oz-finish-task** — report task completion or failure +- **oz-notify-user** — send a progress notification to the triggering user +- **oz-report-pr** — report a created pull request back to Oz +- **oz-upload-file** — upload a local file as a conversation artifact + +## Requirements + +- Warp's Oz cloud agent environment (provides the `oz` CLI and `OZ_*` environment variables) +- [Claude Code](https://docs.anthropic.com/en/docs/claude-code) CLI +- `jq` for JSON parsing diff --git a/plugins/oz-harness-support/hooks/hooks.json b/plugins/oz-harness-support/hooks/hooks.json new file mode 100644 index 0000000..eda3ef2 --- /dev/null +++ b/plugins/oz-harness-support/hooks/hooks.json @@ -0,0 +1,56 @@ +{ + "description": "Oz parent-message delivery bridge for Claude Code child runs", + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-session-start.sh" + } + ] + } + ], + "UserPromptSubmit": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/drain-mailbox.sh UserPromptSubmit" + } + ] + } + ], + "PostToolUse": [ + { + "matcher": "*", + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/drain-mailbox.sh PostToolUse" + } + ] + } + ], + "Stop": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-stop.sh" + } + ] + } + ], + "SessionEnd": [ + { + "hooks": [ + { + "type": "command", + "command": "${CLAUDE_PLUGIN_ROOT}/scripts/on-session-end.sh" + } + ] + } + ] + } +} diff --git a/plugins/oz-harness-support/scripts/drain-mailbox.sh b/plugins/oz-harness-support/scripts/drain-mailbox.sh new file mode 100755 index 0000000..7268026 --- /dev/null +++ b/plugins/oz-harness-support/scripts/drain-mailbox.sh @@ -0,0 +1,21 @@ +#!/bin/bash + +set -euo pipefail + +HOOK_EVENT="${1:?hook event name is required}" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/oz-parent-common.sh" +load_hook_state || exit 0 +STATE_DIR="$(hook_state_dir)" + +if listener_lifecycle_managed_externally; then + emit_driver_hook_additional_context "$HOOK_EVENT" "$STATE_DIR" || exit 0 + acknowledge_driver_hook_output "$STATE_DIR" + exit 0 +fi + +MAX_CONTEXT_CHARS="${OZ_PARENT_MAX_CONTEXT_CHARS:-6000}" +build_parent_context_from_staged_messages "$STATE_DIR" "$MAX_CONTEXT_CHARS" || exit 0 +deliver_and_remove_staged_messages "$STATE_DIR" "${OZ_PARENT_SURFACED_IDS[@]}" +emit_hook_additional_context "$HOOK_EVENT" "$OZ_PARENT_RENDERED_CONTEXT" diff --git a/plugins/oz-harness-support/scripts/on-session-end.sh b/plugins/oz-harness-support/scripts/on-session-end.sh new file mode 100755 index 0000000..783c2d5 --- /dev/null +++ b/plugins/oz-harness-support/scripts/on-session-end.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/oz-parent-common.sh" +resolve_hook_state || exit 0 +listener_lifecycle_managed_externally && exit 0 + +cleanup_hook_state diff --git a/plugins/oz-harness-support/scripts/on-session-start.sh b/plugins/oz-harness-support/scripts/on-session-start.sh new file mode 100755 index 0000000..a784f4e --- /dev/null +++ b/plugins/oz-harness-support/scripts/on-session-start.sh @@ -0,0 +1,11 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/oz-parent-common.sh" +resolve_hook_state || exit 0 +listener_lifecycle_managed_externally && exit 0 +ensure_state_dir "$OZ_PARENT_STATE_DIR" + +start_listener_if_needed "$SCRIPT_DIR/oz-parent-listener.sh" diff --git a/plugins/oz-harness-support/scripts/on-stop.sh b/plugins/oz-harness-support/scripts/on-stop.sh new file mode 100755 index 0000000..ad13d8d --- /dev/null +++ b/plugins/oz-harness-support/scripts/on-stop.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/oz-parent-common.sh" +load_hook_state || exit 0 +emit_stop_block_if_pending || exit 0 diff --git a/plugins/oz-harness-support/scripts/oz-parent-common.sh b/plugins/oz-harness-support/scripts/oz-parent-common.sh new file mode 100755 index 0000000..a30d48a --- /dev/null +++ b/plugins/oz-harness-support/scripts/oz-parent-common.sh @@ -0,0 +1,421 @@ +#!/bin/bash + +set -euo pipefail + +OZ_PARENT_HOOK_INPUT="" +OZ_PARENT_STATE_DIR="" +OZ_PARENT_RENDERED_CONTEXT="" +OZ_PARENT_REMAINING_COUNT=0 +OZ_PARENT_SURFACED_IDS=() + +read_hook_input() { + cat +} +# Only child Claude runs have a lead-agent parent to bridge messages from. +# When OZ_PARENT_RUN_ID is absent, leave the hooks inert so they do not affect +# regular Oz harness sessions that are not running as child agents. + +oz_harness_available() { + [ -n "${OZ_CLI:-}" ] && + [ -n "${OZ_RUN_ID:-}" ] && + [ -n "${OZ_PARENT_RUN_ID:-}" ] && + command -v "$OZ_CLI" >/dev/null 2>&1 +} + +json_field() { + local input="$1" + local query="$2" + printf '%s' "$input" | jq -r "$query // empty" 2>/dev/null +} + +json_file_field() { + local path="$1" + local query="$2" + jq -r "$query // empty" "$path" 2>/dev/null +} + +session_id_from_input() { + json_field "$1" '.session_id' +} + +state_root() { + printf '%s\n' "${OZ_PARENT_STATE_ROOT:-$HOME/.claude-code/oz-parent-bridge}" +} + +state_dir_from_session_id() { + local session_id="$1" + printf '%s/%s\n' "$(state_root)" "$session_id" +} + +state_dir_from_input() { + local session_id + session_id=$(session_id_from_input "$1") + [ -n "$session_id" ] || return 1 + state_dir_from_session_id "$session_id" +} + +ensure_state_dir() { + local state_dir="$1" + mkdir -p "$state_dir/staged" +} + +staged_dir() { + local state_dir="$1" + printf '%s/staged\n' "$state_dir" +} +surfaced_dir() { + local state_dir="$1" + printf '%s/surfaced\n' "$state_dir" +} + +hook_output_file() { + local state_dir="$1" + printf '%s/pending-hook-output.json\n' "$state_dir" +} + +hook_output_ack_file() { + local state_dir="$1" + printf '%s/pending-hook-output.ack\n' "$state_dir" +} + +listener_pid_file() { + local state_dir="$1" + printf '%s/listener.pid\n' "$state_dir" +} + +listener_log_file() { + local state_dir="$1" + printf '%s/listener.log\n' "$state_dir" +} + +last_sequence_file() { + local state_dir="$1" + printf '%s/last-sequence\n' "$state_dir" +} + +staged_message_path() { + local state_dir="$1" + local sequence="$2" + local message_id="$3" + printf '%s/%020d-%s.json\n' "$(staged_dir "$state_dir")" "$sequence" "$message_id" +} + +sorted_staged_messages() { + local state_dir="$1" + local dir + dir=$(staged_dir "$state_dir") + [ -d "$dir" ] || return 0 + find "$dir" -type f -name '*.json' -print | sort +} +sorted_surfaced_messages() { + local state_dir="$1" + local dir + dir=$(surfaced_dir "$state_dir") + [ -d "$dir" ] || return 0 + find "$dir" -type f -name '*.json' -print | sort +} + +staged_message_count() { + local state_dir="$1" + sorted_staged_messages "$state_dir" | wc -l | tr -d ' ' +} +surfaced_message_count() { + local state_dir="$1" + sorted_surfaced_messages "$state_dir" | wc -l | tr -d ' ' +} + +driver_hook_output_available() { + local state_dir="$1" + [ -f "$(hook_output_file "$state_dir")" ] && + [ ! -f "$(hook_output_ack_file "$state_dir")" ] +} + +wait_for_driver_hook_output() { + local state_dir="$1" + local attempt + + for ((attempt = 0; attempt < 40; attempt++)); do + if driver_hook_output_available "$state_dir"; then + return 0 + fi + if [ -f "$(hook_output_ack_file "$state_dir")" ]; then + return 1 + fi + sleep 0.05 + done + + driver_hook_output_available "$state_dir" +} + +emit_driver_hook_additional_context() { + local hook_event="$1" + local state_dir="${2:-$OZ_PARENT_STATE_DIR}" + local output_file additional_context + + wait_for_driver_hook_output "$state_dir" || return 1 + output_file=$(hook_output_file "$state_dir") + additional_context=$(json_file_field "$output_file" '.additional_context') + [ -n "$additional_context" ] || return 1 + emit_hook_additional_context "$hook_event" "$additional_context" +} + +acknowledge_driver_hook_output() { + local state_dir="${1:-$OZ_PARENT_STATE_DIR}" + : >"$(hook_output_ack_file "$state_dir")" +} + +driver_pending_parent_message_count() { + local state_dir="$1" + local pending_count surfaced_count + + pending_count="$(staged_message_count "$state_dir")" + if [ ! -f "$(hook_output_ack_file "$state_dir")" ]; then + surfaced_count="$(surfaced_message_count "$state_dir")" + pending_count=$((pending_count + surfaced_count)) + fi + + printf '%s\n' "$pending_count" +} + +pending_parent_message_count() { + local state_dir="$1" + if listener_lifecycle_managed_externally; then + driver_pending_parent_message_count "$state_dir" + else + staged_message_count "$state_dir" + fi +} + +# The driver-owned bridge can stage a parent message just after Claude emits +# Stop. Keep polling for a short window so late-arriving parent work still +# blocks completion instead of letting the child exit too early. + +stop_linger_attempts() { + local attempts="${OZ_PARENT_STOP_LINGER_ATTEMPTS:-240}" + if ! [[ "$attempts" =~ ^[0-9]+$ ]]; then + attempts=240 + fi + printf '%s\n' "$attempts" +} + +stop_linger_poll_seconds() { + local poll_seconds="${OZ_PARENT_STOP_LINGER_POLL_SECONDS:-0.25}" + if ! [[ "$poll_seconds" =~ ^([0-9]+([.][0-9]+)?|[.][0-9]+)$ ]]; then + poll_seconds="0.25" + fi + printf '%s\n' "$poll_seconds" +} + +# Poll the pending-message count instead of sampling it only once. This gives +# the bridge time to finish writing newly arrived parent messages into the +# session state before the stop hook decides whether to block completion. + +wait_for_pending_parent_messages() { + local state_dir="$1" + local attempts poll_seconds attempt pending_count + + attempts="$(stop_linger_attempts)" + poll_seconds="$(stop_linger_poll_seconds)" + + for ((attempt = 0; attempt <= attempts; attempt++)); do + pending_count="$(pending_parent_message_count "$state_dir")" + if [ "$pending_count" -gt 0 ]; then + printf '%s\n' "$pending_count" + return 0 + fi + + [ "$attempt" -lt "$attempts" ] || break + sleep "$poll_seconds" + done + + return 1 +} + +listener_running() { + local state_dir="$1" + local pid_file pid + pid_file=$(listener_pid_file "$state_dir") + [ -f "$pid_file" ] || return 1 + pid=$(cat "$pid_file" 2>/dev/null || true) + [ -n "$pid" ] || return 1 + kill -0 "$pid" 2>/dev/null +} + +kill_listener() { + local state_dir="$1" + local pid_file pid + pid_file=$(listener_pid_file "$state_dir") + [ -f "$pid_file" ] || return 0 + pid=$(cat "$pid_file" 2>/dev/null || true) + if [ -n "$pid" ]; then + kill "$pid" 2>/dev/null || true + fi + rm -f "$pid_file" +} + +ensure_last_sequence_file() { + local state_dir="$1" + local path + path=$(last_sequence_file "$state_dir") + [ -f "$path" ] || : >"$path" +} + +# Set by the Warp Rust driver when it owns the parent-bridge listener and +# surfaces hook output itself. In that mode the shell hooks should reuse the +# driver's state directory instead of starting or cleaning up their own +# listener lifecycle. +listener_lifecycle_managed_externally() { + [ "${OZ_PARENT_LISTENER_MANAGED_EXTERNALLY:-0}" = "1" ] +} + +load_hook_state() { + resolve_hook_state || return 1 + if listener_lifecycle_managed_externally; then + [ -d "$OZ_PARENT_STATE_DIR" ] || return 1 + else + ensure_state_dir "$OZ_PARENT_STATE_DIR" + fi +} + +resolve_hook_state() { + OZ_PARENT_HOOK_INPUT="$(read_hook_input)" + oz_harness_available || return 1 + + OZ_PARENT_STATE_DIR="$(state_dir_from_input "$OZ_PARENT_HOOK_INPUT" || true)" + [ -n "$OZ_PARENT_STATE_DIR" ] || return 1 +} + +hook_state_dir() { + printf '%s\n' "$OZ_PARENT_STATE_DIR" +} + +start_listener_if_needed() { + local listener_script="$1" + local state_dir="${2:-$OZ_PARENT_STATE_DIR}" + + listener_running "$state_dir" && return 0 + + kill_listener "$state_dir" + nohup "$listener_script" "$state_dir" \ + >>"$(listener_log_file "$state_dir")" 2>&1 & + printf '%s\n' "$!" >"$(listener_pid_file "$state_dir")" +} + +cleanup_hook_state() { + local state_dir="${1:-$OZ_PARENT_STATE_DIR}" + kill_listener "$state_dir" + rm -rf "$state_dir" +} + +emit_stop_block_if_pending() { + local state_dir="${1:-$OZ_PARENT_STATE_DIR}" + local pending_count reason + + # Re-check during the linger window so a parent message that arrives right + # after Stop can still keep the child alive until the next safe hook drain. + pending_count="$(wait_for_pending_parent_messages "$state_dir")" || return 1 + [ "$pending_count" -gt 0 ] || return 1 + + reason="There are ${pending_count} pending parent message(s) from the lead Oz run. Continue so the next safe boundary can surface them." + jq -nc --arg reason "$reason" '{decision:"block", reason:$reason}' +} + +mark_message_delivered() { + local message_id="$1" + "$OZ_CLI" run message mark-delivered "$message_id" >/dev/null 2>&1 || + "$OZ_CLI" run message delivered "$message_id" >/dev/null 2>&1 || + true +} + +remove_staged_message() { + local state_dir="$1" + local message_id="$2" + find "$(staged_dir "$state_dir")" -type f -name "*-${message_id}.json" -delete 2>/dev/null || true +} + +build_parent_context_from_staged_messages() { + local state_dir="$1" + local max_context_chars="${2:-6000}" + local staged_file total_staged=0 + local message_id sender_run_id subject body sequence + local block separator candidate remaining note + + OZ_PARENT_RENDERED_CONTEXT=$'Lead-agent updates arrived from Oz. Treat the latest parent instructions below as authoritative.\n' + OZ_PARENT_REMAINING_COUNT=0 + OZ_PARENT_SURFACED_IDS=() + + while IFS= read -r staged_file; do + [ -n "$staged_file" ] || continue + total_staged=$((total_staged + 1)) + + message_id="$(json_file_field "$staged_file" '.message_id')" + sender_run_id="$(json_file_field "$staged_file" '.sender_run_id')" + subject="$(json_file_field "$staged_file" '.subject')" + body="$(json_file_field "$staged_file" '.body')" + sequence="$(json_file_field "$staged_file" '.sequence')" + + [ -n "$message_id" ] || continue + [ -n "$subject" ] || subject="(no subject)" + + block=$'---\nParent message' + if [ -n "$sequence" ]; then + block="$block #$sequence" + fi + if [ -n "$sender_run_id" ]; then + block="$block from $sender_run_id" + fi + block="$block"$'\n'"Subject: $subject"$'\n\n'"$body" + + separator="" + if [ "${#OZ_PARENT_SURFACED_IDS[@]}" -gt 0 ]; then + separator=$'\n\n' + fi + + candidate="${OZ_PARENT_RENDERED_CONTEXT}${separator}${block}" + if [ "${#candidate}" -gt "$max_context_chars" ]; then + remaining=$((max_context_chars - ${#OZ_PARENT_RENDERED_CONTEXT} - ${#separator})) + if [ "$remaining" -le 3 ] && [ "${#OZ_PARENT_SURFACED_IDS[@]}" -gt 0 ]; then + break + fi + if [ "$remaining" -gt 3 ] && [ "${#block}" -gt "$remaining" ]; then + block="${block:0:$((remaining - 3))}..." + elif [ "${#OZ_PARENT_SURFACED_IDS[@]}" -gt 0 ]; then + break + fi + fi + + OZ_PARENT_RENDERED_CONTEXT="${OZ_PARENT_RENDERED_CONTEXT}${separator}${block}" + OZ_PARENT_SURFACED_IDS+=("$message_id") + done < <(sorted_staged_messages "$state_dir") + + [ "${#OZ_PARENT_SURFACED_IDS[@]}" -gt 0 ] || return 1 + + OZ_PARENT_REMAINING_COUNT=$((total_staged - ${#OZ_PARENT_SURFACED_IDS[@]})) + if [ "$OZ_PARENT_REMAINING_COUNT" -gt 0 ]; then + note=$'\n\nMore parent messages are still staged and will be surfaced on a later turn.' + if [ $(( ${#OZ_PARENT_RENDERED_CONTEXT} + ${#note} )) -le "$max_context_chars" ]; then + OZ_PARENT_RENDERED_CONTEXT="${OZ_PARENT_RENDERED_CONTEXT}${note}" + fi + fi +} + +deliver_and_remove_staged_messages() { + local state_dir="$1" + local message_id + shift + + for message_id in "$@"; do + mark_message_delivered "$message_id" + remove_staged_message "$state_dir" "$message_id" + done +} + +emit_hook_additional_context() { + local hook_event="$1" + local additional_context="${2:-$OZ_PARENT_RENDERED_CONTEXT}" + + jq -nc \ + --arg event "$hook_event" \ + --arg ctx "$additional_context" \ + '{hookSpecificOutput:{hookEventName:$event, additionalContext:$ctx}}' +} diff --git a/plugins/oz-harness-support/scripts/oz-parent-listener.sh b/plugins/oz-harness-support/scripts/oz-parent-listener.sh new file mode 100755 index 0000000..fc33e1f --- /dev/null +++ b/plugins/oz-harness-support/scripts/oz-parent-listener.sh @@ -0,0 +1,38 @@ +#!/bin/bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +source "$SCRIPT_DIR/oz-parent-common.sh" + +STATE_DIR="${1:?state directory is required}" + +stage_message_from_watch_record() { + local state_dir="$1" + local line="$2" + local sequence message_id target + + sequence="$(json_field "$line" '.sequence')" + message_id="$(json_field "$line" '.message_id')" + + [ -n "$sequence" ] || return 0 + [ -n "$message_id" ] || return 0 + + target="$(staged_message_path "$state_dir" "$sequence" "$message_id")" + if [ ! -f "$target" ]; then + printf '%s\n' "$line" >"$target" + fi + + printf '%s\n' "$sequence" >"$(last_sequence_file "$state_dir")" +} + +ensure_state_dir "$STATE_DIR" +ensure_last_sequence_file "$STATE_DIR" +LAST_SEQUENCE="$(cat "$(last_sequence_file "$STATE_DIR")" 2>/dev/null || true)" +[ -n "$LAST_SEQUENCE" ] || LAST_SEQUENCE=0 + +"$OZ_CLI" run message watch "$OZ_RUN_ID" --since-sequence "$LAST_SEQUENCE" --output-format ndjson | + while IFS= read -r line; do + [ -n "$line" ] || continue + stage_message_from_watch_record "$STATE_DIR" "$line" + done diff --git a/plugins/oz-harness-support/skills/oz-child-agent-orchestration/SKILL.md b/plugins/oz-harness-support/skills/oz-child-agent-orchestration/SKILL.md new file mode 100644 index 0000000..57db332 --- /dev/null +++ b/plugins/oz-harness-support/skills/oz-child-agent-orchestration/SKILL.md @@ -0,0 +1,64 @@ +--- +name: oz-child-agent-orchestration +description: Internal Oz child-agent orchestration playbook for Claude Code child runs. Invoke only when the Oz harness system prompt tells you to coordinate with a lead run through the Oz CLI in `OZ_CLI`, using `OZ_RUN_ID` and `OZ_PARENT_RUN_ID`. +disable-model-invocation: false +user-invocable: false +--- +# Oz Child Agent Orchestration +Use this skill when you are a spawned child agent running inside Claude Code in an Oz cloud agent environment. + +# Team agent context +You are a team agent started by another agent. + +Your agent ID: the value of `OZ_RUN_ID` +Your parent agent ID: the value of `OZ_PARENT_RUN_ID` +Lead agent ID (the agent that started you): the value of `OZ_PARENT_RUN_ID` +Your Oz CLI command: the value of `OZ_CLI` + +## How to work +- Focus on the subtask you are given. Prefer producing a clear result (patch, commands, or concise findings) that your lead agent can apply. +- Minimize direct interaction with the user. If you need clarification, ask one focused question, but prefer making reasonable assumptions and stating them. +- Keep your output concise and operational (e.g., exact file paths, commands to run, and concrete next steps). +- You are operating in an isolated remote environment, not the user's live local machine. +- Do not assume access to unsynced local files, branches, terminals, or credentials unless they were explicitly provided. +- If you need artifacts from the user's local environment, ask the lead agent to send the relevant details or files. + +## Coordination +Coordinate progress and status through the lead agent. If you complete your task, provide a short summary and any validation steps for the lead agent to run. + +## Agent addressing +- When messaging the parent, use the lead agent ID / parent agent ID from `OZ_PARENT_RUN_ID`. +- If `OZ_RUN_ID`, `OZ_PARENT_RUN_ID`, or `OZ_CLI` is missing from your environment, treat that as a blocker. Do not guess one; wait for corrected context before attempting to message the parent. + +## Messaging +IMPORTANT: You MUST use `"$OZ_CLI" run message send --sender-run-id "$OZ_RUN_ID" --to "$OZ_PARENT_RUN_ID" --subject "" --body ""` to keep the lead agent informed. Do not silently complete your work. +IMPORTANT: Sending a message is not a terminal action. After every `"$OZ_CLI" run message send --sender-run-id "$OZ_RUN_ID" --to "$OZ_PARENT_RUN_ID" --subject "" --body ""`, you MUST check your inbox before deciding whether to continue, wait, or stop. + +Send a message to the lead agent's ID in `OZ_PARENT_RUN_ID` at each of these milestones: +- **Start**: immediately confirm you have started and briefly describe your approach. +- **Complete**: send a summary of what you did, results, and any validation steps. +- **Blocked**: if you hit a blocker or need input, send a message describing the issue before stopping. + +You have access to messaging commands for communicating with other agents: +- `"$OZ_CLI" run message send --sender-run-id \"$OZ_RUN_ID\" --to \"$OZ_PARENT_RUN_ID\" --subject \"\" --body \"\"`: Send a message to one or more agents by their run IDs. Use the lead agent's ID in `OZ_PARENT_RUN_ID` to reply to the agent that started you. `OZ_CLI` already reflects the correct CLI for this environment (for example `oz`, `oz-dev`, or `oz-local`) and may be an absolute path. +- `"$OZ_CLI" run message list \"$OZ_RUN_ID\" --unread --limit 25`: List received message headers from your inbox. +- `"$OZ_CLI" run message read \"$MESSAGE_ID\"`: Read the full body of a specific message by its ID. +- `"$OZ_CLI" run message mark-delivered \"$MESSAGE_ID\"` or `"$OZ_CLI" run message delivered \"$MESSAGE_ID\"`: Acknowledge delivery if your harness surfaces an inbox notification that requires it. +Use all of these parameters on `"$OZ_CLI" run message send`: `--sender-run-id "$OZ_RUN_ID"`, `--to "$OZ_PARENT_RUN_ID"`, `--subject ""`, and `--body ""`. If `OZ_RUN_ID`, `OZ_PARENT_RUN_ID`, or `OZ_CLI` is missing, that is a blocker. + +### Required polling workflow +Do not assume your harness will surface parent messages automatically. +Use this workflow whenever you coordinate with the parent: +1. Send your milestone message with `"$OZ_CLI" run message send --sender-run-id "$OZ_RUN_ID" --to "$OZ_PARENT_RUN_ID" --subject "" --body ""`. +2. Immediately run `"$OZ_CLI" run message list "$OZ_RUN_ID" --unread --limit 25`. +3. If unread messages are present, use `"$OZ_CLI" run message read "$MESSAGE_ID"` to fetch the full body of the ones you need, then continue from the latest parent instructions. +4. Only after that inbox check may you decide to keep working, wait, or stop. + +Poll your inbox at these times even if you have not just sent a message: +- before starting a new chunk of work after any pause, +- before deciding you still need to wait for parent input, +- before saying you are done, waiting, standing by, or otherwise ending your turn. + +### Waiting for replies +If you are waiting on the parent, periodically poll for unread messages yourself instead of assuming the harness will wake you automatically. Use a modest cadence rather than a tight loop — for example, wait a short interval between checks, then run `"$OZ_CLI" run message list "$OZ_RUN_ID" --unread --limit 25` again. Once a reply arrives, read it and resume work. +Never say you are standing by until a post-send or pre-idle inbox check has shown no unread parent messages that require action. diff --git a/plugins/oz-harness-support/skills/oz-finish-task/SKILL.md b/plugins/oz-harness-support/skills/oz-finish-task/SKILL.md new file mode 100644 index 0000000..71564a7 --- /dev/null +++ b/plugins/oz-harness-support/skills/oz-finish-task/SKILL.md @@ -0,0 +1,11 @@ +--- +name: oz-finish-task +description: Report task completion or failure back to the Oz platform when done with a task. +--- +When your task is complete, report the outcome by running: + +```sh +"$OZ_CLI" harness-support finish-task --status --summary '' +``` + +Replace `` with either `success` or `failure`, and `` with a brief description of what was accomplished (or what went wrong). diff --git a/plugins/oz-harness-support/skills/oz-notify-user/SKILL.md b/plugins/oz-harness-support/skills/oz-notify-user/SKILL.md new file mode 100644 index 0000000..4644873 --- /dev/null +++ b/plugins/oz-harness-support/skills/oz-notify-user/SKILL.md @@ -0,0 +1,11 @@ +--- +name: oz-notify-user +description: Send a progress notification to the user who triggered this Oz task (e.g., via Slack or Linear). +--- +When you want to send a progress update to the user (for example, after completing a significant milestone), run: + +```sh +"$OZ_CLI" harness-support notify-user --message '' +``` + +Replace `` with a short, informative status update describing what you've accomplished or what you're working on next. diff --git a/plugins/oz-harness-support/skills/oz-report-pr/SKILL.md b/plugins/oz-harness-support/skills/oz-report-pr/SKILL.md new file mode 100644 index 0000000..1f0a616 --- /dev/null +++ b/plugins/oz-harness-support/skills/oz-report-pr/SKILL.md @@ -0,0 +1,11 @@ +--- +name: oz-report-pr +description: Report a pull request back to the Oz platform after creating one. +--- +After creating a pull request, report it to Oz by running: + +```sh +"$OZ_CLI" harness-support report-artifact pull-request --url '' --branch '' +``` + +Replace `` with the full pull request URL and `` with the branch name. diff --git a/plugins/oz-harness-support/skills/oz-upload-file/SKILL.md b/plugins/oz-harness-support/skills/oz-upload-file/SKILL.md new file mode 100644 index 0000000..1369cee --- /dev/null +++ b/plugins/oz-harness-support/skills/oz-upload-file/SKILL.md @@ -0,0 +1,15 @@ +--- +name: oz-upload-file +description: Upload a local file to the Oz platform as a conversation artifact. +--- +Use this for supplemental files that should be attached to the task but should NOT be committed to the repo or included in the PR — e.g. screenshots, logs, generated reports, or other large or derived outputs. + +Do NOT use this for source code, tests, docs, or any change that should be reviewed and merged through a PR. Those belong in a commit, not as an artifact. + +Only call this after the file already exists on disk. + +```sh +"$OZ_CLI" artifact upload '' --run-id "$OZ_RUN_ID" --description '' +``` + +Replace `` with the absolute path to the file. Include a `--description` when it adds useful context about what the file is or why it is attached (e.g. "screenshot of failing login page", "profiler output for slow query"). Omit when the file name alone is self-explanatory. diff --git a/plugins/oz-harness-support/tests/test-hooks.sh b/plugins/oz-harness-support/tests/test-hooks.sh new file mode 100755 index 0000000..96fc289 --- /dev/null +++ b/plugins/oz-harness-support/tests/test-hooks.sh @@ -0,0 +1,233 @@ +#!/bin/bash + +set -uo pipefail + +PLUGIN_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SCRIPT_DIR="$PLUGIN_ROOT/scripts" + +PASSED=0 +FAILED=0 + +assert_eq() { + local test_name="$1" + local expected="$2" + local actual="$3" + if [ "$expected" = "$actual" ]; then + echo " ✓ $test_name" + PASSED=$((PASSED + 1)) + else + echo " ✗ $test_name" + echo " expected: $expected" + echo " actual: $actual" + FAILED=$((FAILED + 1)) + fi +} + +assert_contains() { + local test_name="$1" + local haystack="$2" + local needle="$3" + if printf '%s' "$haystack" | grep -Fq "$needle"; then + echo " ✓ $test_name" + PASSED=$((PASSED + 1)) + else + echo " ✗ $test_name" + echo " expected to find: $needle" + echo " actual: $haystack" + FAILED=$((FAILED + 1)) + fi +} + +assert_json_field() { + local test_name="$1" + local json="$2" + local field="$3" + local expected="$4" + local actual + actual=$(echo "$json" | jq -r "$field" 2>/dev/null) + assert_eq "$test_name" "$expected" "$actual" +} + +TEST_TMP="$(mktemp -d)" +trap 'rm -rf "$TEST_TMP"' EXIT + +FAKE_OZ="$TEST_TMP/fake-oz.sh" +FAKE_OZ_LOG="$TEST_TMP/fake-oz.log" +FAKE_OZ_WATCH="$TEST_TMP/watch.ndjson" +STATE_ROOT="$TEST_TMP/state" +HOOK_INPUT='{"session_id":"sess-123","cwd":"/tmp/project"}' +STATE_DIR="$STATE_ROOT/sess-123" + +cat >"$FAKE_OZ" <<'EOF' +#!/bin/bash +set -euo pipefail + +printf '%s\n' "$*" >> "${FAKE_OZ_LOG:?}" + +if [ "$#" -ge 3 ] && [ "$1" = "run" ] && [ "$2" = "message" ] && [ "$3" = "watch" ]; then + if [ -n "${FAKE_OZ_WATCH_FILE:-}" ] && [ -f "${FAKE_OZ_WATCH_FILE:-}" ]; then + cat "$FAKE_OZ_WATCH_FILE" + fi + exit 0 +fi + +if [ "$#" -ge 4 ] && [ "$1" = "run" ] && [ "$2" = "message" ] && [ "$3" = "mark-delivered" ]; then + exit 0 +fi + +if [ "$#" -ge 4 ] && [ "$1" = "run" ] && [ "$2" = "message" ] && [ "$3" = "delivered" ]; then + exit 0 +fi + +exit 0 +EOF +chmod +x "$FAKE_OZ" + +export OZ_CLI="$FAKE_OZ" +export OZ_RUN_ID="child-run-123" +export OZ_PARENT_RUN_ID="parent-run-456" +export OZ_PARENT_STATE_ROOT="$STATE_ROOT" +export FAKE_OZ_LOG +export FAKE_OZ_WATCH_FILE="$FAKE_OZ_WATCH" + +mkdir -p "$STATE_ROOT" +: >"$FAKE_OZ_LOG" + +echo "=== on-session-start.sh ===" +rm -rf "$STATE_DIR" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-session-start.sh") +assert_eq "session start emits no output" "" "$OUTPUT" +assert_eq "session start writes listener pid" "true" "$([ -f "$STATE_DIR/listener.pid" ] && echo true || echo false)" +assert_eq "session start creates state directory" "true" "$([ -d "$STATE_DIR/staged" ] && echo true || echo false)" +kill_listener_pid="$(cat "$STATE_DIR/listener.pid" 2>/dev/null || true)" +if [ -n "$kill_listener_pid" ]; then + kill "$kill_listener_pid" 2>/dev/null || true +fi +rm -rf "$STATE_DIR" + +echo "" +echo "=== oz-parent-listener.sh ===" +cat >"$FAKE_OZ_WATCH" <<'EOF' +{"sequence":42,"message_id":"msg-123","sender_run_id":"parent-run-456","subject":"Please pivot","body":"Inspect the failing tests before editing code.","occurred_at":"2026-04-17T15:46:00Z"} +EOF + +bash "$SCRIPT_DIR/oz-parent-listener.sh" "$STATE_DIR" + +LISTENER_FILE="$STATE_DIR/staged/00000000000000000042-msg-123.json" +assert_eq "listener stages message" "true" "$([ -f "$LISTENER_FILE" ] && echo true || echo false)" +assert_json_field "listener writes stored subject" "$(cat "$LISTENER_FILE")" ".subject" "Please pivot" +assert_eq "listener updates last sequence" "42" "$(cat "$STATE_DIR/last-sequence")" +assert_contains "listener invokes message watch" "$(cat "$FAKE_OZ_LOG")" "run message watch child-run-123 --since-sequence 0 --output-format ndjson" + +echo "" +echo "=== drain-mailbox.sh ===" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/drain-mailbox.sh" UserPromptSubmit) +assert_json_field "drain outputs hook event name" "$OUTPUT" ".hookSpecificOutput.hookEventName" "UserPromptSubmit" +assert_contains "drain includes subject" "$OUTPUT" "Please pivot" +assert_contains "drain includes body" "$OUTPUT" "Inspect the failing tests before editing code." +assert_eq "drain removes surfaced message" "false" "$([ -f "$LISTENER_FILE" ] && echo true || echo false)" +assert_contains "drain marks message delivered" "$(cat "$FAKE_OZ_LOG")" "run message mark-delivered msg-123" + +echo "" +echo "=== on-stop.sh ===" +mkdir -p "$STATE_DIR/staged" +cat >"$STATE_DIR/staged/00000000000000000043-msg-456.json" <<'EOF' +{"sequence":43,"message_id":"msg-456","sender_run_id":"parent-run-456","subject":"Another update","body":"There is still more work to do.","occurred_at":"2026-04-17T15:47:00Z"} +EOF + +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +assert_json_field "stop blocks when staged messages remain" "$OUTPUT" ".decision" "block" +assert_contains "stop reason references pending parent messages" "$OUTPUT" "pending parent message" + +rm -f "$STATE_DIR/staged/"*.json +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +assert_eq "stop exits silently when no staged messages remain" "" "$OUTPUT" + +echo "" +echo "=== on-session-end.sh ===" +printf '999999\n' >"$STATE_DIR/listener.pid" +mkdir -p "$STATE_DIR/staged" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-session-end.sh") +assert_eq "session end emits no output" "" "$OUTPUT" +assert_eq "session end removes state directory" "false" "$([ -d "$STATE_DIR" ] && echo true || echo false)" + +echo "" +echo "=== non-child sessions are ignored ===" +rm -rf "$STATE_DIR" +unset OZ_PARENT_RUN_ID +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-session-start.sh") +assert_eq "non-child session start emits no output" "" "$OUTPUT" +assert_eq "non-child session start does not create state directory" "false" "$([ -d "$STATE_DIR" ] && echo true || echo false)" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/drain-mailbox.sh" UserPromptSubmit) +assert_eq "non-child drain emits no output" "" "$OUTPUT" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +assert_eq "non-child stop emits no output" "" "$OUTPUT" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-session-end.sh") +assert_eq "non-child session end emits no output" "" "$OUTPUT" +export OZ_PARENT_RUN_ID="parent-run-456" + +echo "" +echo "=== externally managed lifecycle and drain ===" +rm -rf "$STATE_DIR" +mkdir -p "$STATE_DIR/staged" "$STATE_DIR/surfaced" +export OZ_PARENT_LISTENER_MANAGED_EXTERNALLY=1 +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-session-start.sh") +assert_eq "externally managed session start emits no output" "" "$OUTPUT" +assert_eq "externally managed session start does not create pid file" "false" "$([ -f "$STATE_DIR/listener.pid" ] && echo true || echo false)" + +cat >"$STATE_DIR/pending-hook-output.json" <<'EOF' +{"additional_context":"Lead-agent updates arrived from Oz. Treat the latest parent instructions below as authoritative.\n\n---\nParent message #44 from parent-run-456\nSubject: Driver-owned update\n\nSwitch to the new debugging plan.","remaining_staged_count":0,"surfaced_count":1} +EOF +cat >"$STATE_DIR/surfaced/00000000000000000044-msg-789.json" <<'EOF' +{"sequence":44,"message_id":"msg-789","sender_run_id":"parent-run-456","subject":"Driver-owned update","body":"Switch to the new debugging plan.","occurred_at":"2026-04-17T15:48:00Z"} +EOF + +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/drain-mailbox.sh" PostToolUse) +assert_json_field "externally managed drain outputs hook event name" "$OUTPUT" ".hookSpecificOutput.hookEventName" "PostToolUse" +assert_contains "externally managed drain includes driver-rendered context" "$OUTPUT" "Driver-owned update" +assert_eq "externally managed drain writes ack file" "true" "$([ -f "$STATE_DIR/pending-hook-output.ack" ] && echo true || echo false)" + +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +assert_eq "externally managed stop ignores already-acked surfaced messages" "" "$OUTPUT" + +rm -f "$STATE_DIR/pending-hook-output.ack" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +assert_json_field "externally managed stop blocks when surfaced messages are unacked" "$OUTPUT" ".decision" "block" + +: >"$STATE_DIR/pending-hook-output.ack" +cat >"$STATE_DIR/staged/00000000000000000045-msg-790.json" <<'EOF' +{"sequence":45,"message_id":"msg-790","sender_run_id":"parent-run-456","subject":"Queued update","body":"There is still another staged instruction.","occurred_at":"2026-04-17T15:49:00Z"} +EOF +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +assert_json_field "externally managed stop still blocks for newly staged messages" "$OUTPUT" ".decision" "block" +rm -f "$STATE_DIR/pending-hook-output.json" "$STATE_DIR/pending-hook-output.ack" +rm -f "$STATE_DIR/staged/"*.json "$STATE_DIR/surfaced/"*.json + +export OZ_PARENT_STOP_LINGER_ATTEMPTS=10 +export OZ_PARENT_STOP_LINGER_POLL_SECONDS=0.05 +( + sleep 0.1 + cat >"$STATE_DIR/staged/00000000000000000046-msg-791.json" <<'EOF' +{"sequence":46,"message_id":"msg-791","sender_run_id":"parent-run-456","subject":"Late update","body":"This parent message arrived during the linger window.","occurred_at":"2026-04-17T15:50:00Z"} +EOF +) & +linger_writer_pid=$! +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-stop.sh") +wait "$linger_writer_pid" +assert_json_field "externally managed stop lingers for late-arriving staged messages" "$OUTPUT" ".decision" "block" +assert_contains "externally managed linger reason references pending parent messages" "$OUTPUT" "pending parent message" +unset OZ_PARENT_STOP_LINGER_ATTEMPTS +unset OZ_PARENT_STOP_LINGER_POLL_SECONDS + +rm -rf "$STATE_DIR" +OUTPUT=$(printf '%s' "$HOOK_INPUT" | bash "$SCRIPT_DIR/on-session-end.sh") +assert_eq "externally managed session end emits no output" "" "$OUTPUT" +assert_eq "externally managed session end does not recreate state directory" "false" "$([ -d "$STATE_DIR" ] && echo true || echo false)" +unset OZ_PARENT_LISTENER_MANAGED_EXTERNALLY + +echo "" +echo "=== Results: $PASSED passed, $FAILED failed ===" + +if [ "$FAILED" -gt 0 ]; then + exit 1 +fi