feat: add in-sandbox orchestrator binary with watch mode#85
feat: add in-sandbox orchestrator binary with watch mode#85robbycochran wants to merge 1 commit into
Conversation
Add a standalone Go binary (harness-orchestrator) that runs inside the OpenShell sandbox as the entrypoint, replacing the generated run.sh. Provides typed config, watch mode with cycle management, structured sentinel parsing (OPENSHELL_AGENT_RESULT protocol), and session history as append-only JSONL. This is the code-based alternative to the bash supervisor scripts in NVIDIA/OpenShell#1826. The binary sits between the OpenShell supervisor and the AI agent harness, managing the observe-decide-act-record loop with proper signal handling, heartbeats, retry with backoff, and lifecycle hooks. New package: internal/orchestrator/ (6 source files, 25 tests) New binary: cmd/orchestrator/main.go (2.4MB, statically linked) Host-side: mode/sentinel/watch fields on AgentConfig, --mode/--watch flags on harness apply, orchestrator.yaml written to payload dir. Backwards compatible — images without the binary fall back to run.sh.
WalkthroughAdds a new harness-orchestrator binary and internal/orchestrator package implementing config loading, harness adapters (claude/codex/opencode), sentinel-based cycle result parsing, and JSONL session recording for once/watch agent execution. Wires this into AgentConfig YAML rendering, apply/executor CLI flags, Makefile build targets, and the sandbox Dockerfile. ChangesOrchestrator Feature
Sequence Diagram(s)sequenceDiagram
participant CLI as cmd/apply or executor
participant Orchestrator
participant HarnessAdapter
participant SessionWriter
CLI->>Orchestrator: Run(ctx)
loop until complete or cancelled
Orchestrator->>HarnessAdapter: BuildCommand(task, headless)
HarnessAdapter-->>Orchestrator: cmd output / exit code
Orchestrator->>Orchestrator: ParseSentinel or SyntheticResult
Orchestrator->>SessionWriter: Write(SessionRecord)
Orchestrator->>Orchestrator: sleepWithHeartbeat, runHooks
end
Orchestrator-->>CLI: nil or error
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
There was a problem hiding this comment.
Actionable comments posted: 8
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
cmd/executor.go (1)
119-128: 🩺 Stability & Availability | 🔴 Critical | 🏗️ Heavy lift
harness-orchestratorwill rejectentrypoint=bash, butapply.goadvertises bash as a valid override.
cmd/apply.godocuments--entrypointas acceptingclaude, opencode, bash, andBuildOrchestratorConfig(internal/agent/agent.go) writesEffectiveEntrypoint()straight intoorchestrator.yamlwith no allow-list check.OrchestratorConfig.Validate()(internal/orchestrator/config.go) only acceptsclaude/codex/opencodeand errors on anything else.Since the Dockerfile change makes
harness-orchestratoralways present in the built image, thissh -csnippet will now always chooseexec harness-orchestrator ...over therun.shfallback (the fallback only triggers when the binary is absent, not when the config is incompatible). Any sandbox configured withentrypoint: bashwill therefore fail to start:harness-orchestratorexits 1 onLoadConfigvalidation before ever running anything, with no fallback torun.sh.Please gate the orchestrator path on entrypoint compatibility (e.g. only prefer
harness-orchestratorwhen entrypoint isclaude/codex/opencode), or extend the orchestrator's allow-list to support a "bash"/passthrough entrypoint.🐛 Possible fix direction
- } else { - sandboxCmd = []string{ - "sh", "-c", - `if command -v harness-orchestrator >/dev/null 2>&1; then exec harness-orchestrator --config /sandbox/.config/openshell/orchestrator.yaml; else exec bash /sandbox/.config/openshell/run.sh; fi`, - } - } + } else if agentCfg.EffectiveEntrypoint() == "bash" { + sandboxCmd = []string{"bash", "/sandbox/.config/openshell/run.sh"} + } else { + sandboxCmd = []string{ + "sh", "-c", + `if command -v harness-orchestrator >/dev/null 2>&1; then exec harness-orchestrator --config /sandbox/.config/openshell/orchestrator.yaml; else exec bash /sandbox/.config/openshell/run.sh; fi`, + } + }🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@cmd/executor.go` around lines 119 - 128, The Sandbox command selection in cmd/executor.go currently prefers harness-orchestrator whenever it exists, but that path cannot start with entrypoint=bash because OrchestratorConfig.Validate only allows claude, codex, and opencode. Update the sandboxCmd logic to check entrypoint compatibility before choosing harness-orchestrator, so bash (and any other unsupported entrypoint) falls back to run.sh instead of failing at LoadConfig. If you want orchestrator to handle bash directly, then also extend the allow-list in internal/orchestrator/config.go and keep BuildOrchestratorConfig in internal/agent/agent.go aligned with that support.
♻️ Duplicate comments (1)
internal/orchestrator/adapter_test.go (1)
49-63: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick winTests encode the incorrect Codex CLI invocation.
These assertions (
"codex --print review code","codex -p review code") lock in the flags flagged as incorrect inadapter.go. Once theCodexAdapter.BuildCommandfix (usecodex exec) lands, these expectations need to be updated accordingly.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/orchestrator/adapter_test.go` around lines 49 - 63, Update the Codex adapter test expectations so they match the corrected CLI built by CodexAdapter.BuildCommand; the current assertions in TestCodexAdapterCommands still hard-code the old print flags. Adjust the expected command strings to reflect the new codex exec invocation for both headless and tty cases, using the BuildCommand and CodexAdapter symbols to verify the updated args.
🧹 Nitpick comments (3)
internal/orchestrator/config.go (1)
61-73: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win
Validatedoesn't bound-check numeric fields.Only
ModeandEntrypointare validated;PollInterval,MaxFailures, andHeartbeatcan carry negative or absurd values from YAML and still pass validation (compounded by theApplyDefaultsgap above). Consider validating these are positive after defaults are applied.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/orchestrator/config.go` around lines 61 - 73, Validate the numeric fields in OrchestratorConfig.Validate in addition to Mode and Entrypoint: add bounds checks for PollInterval, MaxFailures, and Heartbeat so negative or otherwise invalid values are rejected after defaults are applied. Update Validate to return an error when these fields are non-positive or out of range, keeping the existing checks for Mode and Entrypoint intact.internal/orchestrator/orchestrator_test.go (1)
99-170: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd coverage for mid-cycle cancellation and terminal-failure status.
Existing watch tests only exercise ctx cancellation during the sleep phase (mockAdapter's script finishes instantly via
echo), and only exercise"complete"as a terminal status. Consider adding:
- A case where the mock command sleeps for a while so a context cancellation must interrupt an in-flight
cmd.Run().- A case asserting
Run()returns an error when the sentinel reports"terminal_failure"(matchingmultiMockAdapter's fallback fixture, which currently isn't asserted against).This would lock in the fixes proposed for
internal/orchestrator/orchestrator.go.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@internal/orchestrator/orchestrator_test.go` around lines 99 - 170, The watch tests in TestRunWatchStopsOnComplete and TestRunWatchContextCancel only cover cancellation during the sleep interval and do not verify terminal-failure handling. Add a test around Orchestrator.Run that uses the mockAdapter path with a long-running command so context cancellation interrupts an in-flight cmd.Run(), and add another assertion using multiMockAdapter’s terminal-failure sentinel to confirm Run() returns an error for "terminal_failure". Keep the coverage focused on the watch flow and the status handling in Run, without relying on the instant-echo behavior.Makefile (1)
99-104: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winError suppression on
cpcan mask failures that the mandatory DockerfileCOPYcan't tolerate.
dev-sandboxdepends onorchestrator, so by the timecpruns,bin/harness-orchestratorshould reliably exist — the2>/dev/null || truemainly hides real failures (e.g. permission issues writing intoprofiles/images/sandbox-default/). Since the Dockerfile'sCOPY harness-orchestrator ...is unconditional, a maskedcpfailure surfaces later as a confusing "file not found" error fromdocker buildrather than a clear message at thecpstep.♻️ Proposed fix
- cp bin/harness-orchestrator profiles/images/sandbox-default/harness-orchestrator 2>/dev/null || true + cp bin/harness-orchestrator profiles/images/sandbox-default/harness-orchestrator🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@Makefile` around lines 99 - 104, The dev-sandbox target currently masks cp failures with 2>/dev/null || true, which can hide real write or permission issues before docker build. Update the dev-sandbox recipe to let cp fail visibly and surface the problem at the copy step, using the dev-sandbox and orchestrator target flow to locate it. Keep the binary copy into profiles/images/sandbox-default/ explicit so any missing bin/harness-orchestrator or destination write failure is reported immediately rather than later by the Dockerfile COPY.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@cmd/apply.go`:
- Around line 55-60: The mode selection in the apply command is bypassing the
standard flag resolution and allowing conflicting inputs to silently override
each other. Update the apply flow around mode/watch handling to use the same
Explicit flag > OPENSHELL_* env var > config file > default pattern used by
gatewayName, and validate the resolved mode in the apply command itself before
assigning it to agentCfg.Mode. Also, when both the mode flag and watch flag are
provided, detect the conflict explicitly and return an error instead of letting
watch override the user’s chosen mode.
In `@cmd/orchestrator/main.go`:
- Around line 38-50: Treat SIGINT/SIGTERM as a clean shutdown at the CLI
boundary by updating cmd/orchestrator/main.go so the error returned from
orch.Run(ctx) is mapped to nil when it is context.Canceled; otherwise keep the
existing non-zero exit path. Use the ctx from signal.NotifyContext and the
orch.Run entrypoint to locate the change, and ensure only real failures still
print an error and exit 1 while user-initiated cancellation exits successfully.
In `@internal/orchestrator/adapter.go`:
- Around line 40-52: CodexAdapter.BuildCommand still uses unsupported flags for
task execution, so switch the task-run path to the documented codex exec
subcommand instead of passing --print or -p. Update BuildCommand in CodexAdapter
so non-empty tasks are invoked via codex exec with the prompt argument, while
preserving the empty-task behavior if needed.
In `@internal/orchestrator/config.go`:
- Around line 40-59: The defaulting logic in OrchestratorConfig.ApplyDefaults is
inconsistent because Heartbeat only resets on 0 while PollInterval and
MaxFailures also handle negative values. Update ApplyDefaults to treat Heartbeat
the same way as the other numeric fields so malformed negative values are
replaced with the default, and keep the behavior aligned with Validate and
downstream timer use such as sleepWithHeartbeat.
In `@internal/orchestrator/orchestrator.go`:
- Around line 206-222: The runHooks method blocks the watch loop because it
executes each hook synchronously with cmd.Run() and no timeout or cancellation
path. Update Orchestrator.runHooks to accept and use a context or derive a
timeout when creating the exec.Command, so hung hooks can be interrupted instead
of blocking indefinitely. Keep the existing hook selection logic for
result.Status and preserve logging in logf, but ensure each hook execution is
bounded and can honor shutdown/cancellation signals.
- Around line 38-106: Update Orchestrator.Run and runWatch so terminal failures
are surfaced as errors instead of success. In runWatch, keep treating
IsTerminal() as a stop condition, but return an error when result.Status is
"terminal_failure" (using result.Reason for context) and only return nil for
successful terminal states. In the "once" branch of Run, also check
result.Status alongside ExitCode so a sentinel terminal_failure with exit code 0
is rejected; use the existing runCycle, IsTerminal, and result.Status/Reason
fields to locate the logic.
- Around line 108-167: runCycle ignores ctx while launching the agent
subprocess, so cancellation and SIGTERM/Ctrl+C won’t interrupt an active cycle.
Update Orchestrator.runCycle to thread ctx into the command creation path,
ideally through adapter.BuildCommand, so cmd.Run() is context-aware. If
BuildCommand cannot accept ctx, add explicit handling for ctx.Done() that
terminates the subprocess (or its process group) during the active run. Keep the
existing sentinel parsing and SessionRecord flow unchanged.
In `@Makefile`:
- Around line 37-41: The orchestrator build target is hardcoding a linux/amd64
binary, which breaks native-arch sandbox builds on arm64 hosts. Update the
Makefile’s orchestrator target so it builds for the host architecture instead of
forcing GOARCH=amd64, and keep the output path/name used by dev-sandbox and
executor.go unchanged so the existing command lookup still works. Use the
orchestrator target as the fix point and align it with the native-arch behavior
implied by the Dockerfile and dev-sandbox flow.
---
Outside diff comments:
In `@cmd/executor.go`:
- Around line 119-128: The Sandbox command selection in cmd/executor.go
currently prefers harness-orchestrator whenever it exists, but that path cannot
start with entrypoint=bash because OrchestratorConfig.Validate only allows
claude, codex, and opencode. Update the sandboxCmd logic to check entrypoint
compatibility before choosing harness-orchestrator, so bash (and any other
unsupported entrypoint) falls back to run.sh instead of failing at LoadConfig.
If you want orchestrator to handle bash directly, then also extend the
allow-list in internal/orchestrator/config.go and keep BuildOrchestratorConfig
in internal/agent/agent.go aligned with that support.
---
Duplicate comments:
In `@internal/orchestrator/adapter_test.go`:
- Around line 49-63: Update the Codex adapter test expectations so they match
the corrected CLI built by CodexAdapter.BuildCommand; the current assertions in
TestCodexAdapterCommands still hard-code the old print flags. Adjust the
expected command strings to reflect the new codex exec invocation for both
headless and tty cases, using the BuildCommand and CodexAdapter symbols to
verify the updated args.
---
Nitpick comments:
In `@internal/orchestrator/config.go`:
- Around line 61-73: Validate the numeric fields in OrchestratorConfig.Validate
in addition to Mode and Entrypoint: add bounds checks for PollInterval,
MaxFailures, and Heartbeat so negative or otherwise invalid values are rejected
after defaults are applied. Update Validate to return an error when these fields
are non-positive or out of range, keeping the existing checks for Mode and
Entrypoint intact.
In `@internal/orchestrator/orchestrator_test.go`:
- Around line 99-170: The watch tests in TestRunWatchStopsOnComplete and
TestRunWatchContextCancel only cover cancellation during the sleep interval and
do not verify terminal-failure handling. Add a test around Orchestrator.Run that
uses the mockAdapter path with a long-running command so context cancellation
interrupts an in-flight cmd.Run(), and add another assertion using
multiMockAdapter’s terminal-failure sentinel to confirm Run() returns an error
for "terminal_failure". Keep the coverage focused on the watch flow and the
status handling in Run, without relying on the instant-echo behavior.
In `@Makefile`:
- Around line 99-104: The dev-sandbox target currently masks cp failures with
2>/dev/null || true, which can hide real write or permission issues before
docker build. Update the dev-sandbox recipe to let cp fail visibly and surface
the problem at the copy step, using the dev-sandbox and orchestrator target flow
to locate it. Keep the binary copy into profiles/images/sandbox-default/
explicit so any missing bin/harness-orchestrator or destination write failure is
reported immediately rather than later by the Dockerfile COPY.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Enterprise
Run ID: 3d89c83f-2679-4311-976c-d7c513ddd390
📒 Files selected for processing (16)
Makefilecmd/apply.gocmd/executor.gocmd/orchestrator/main.gointernal/agent/agent.gointernal/orchestrator/adapter.gointernal/orchestrator/adapter_test.gointernal/orchestrator/config.gointernal/orchestrator/config_test.gointernal/orchestrator/orchestrator.gointernal/orchestrator/orchestrator_test.gointernal/orchestrator/sentinel.gointernal/orchestrator/sentinel_test.gointernal/orchestrator/session.gointernal/orchestrator/session_test.goprofiles/images/sandbox-default/Dockerfile
| if watch { | ||
| mode = "watch" | ||
| } | ||
| if mode != "" { | ||
| agentCfg.Mode = mode | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
--mode/--watch skip the env-var tier and silently override conflicting input.
Two issues here:
- Other flags in this file (e.g.
gatewayNameat line 149) resolve viaenvOr("OPENSHELL_GATEWAY", ""), giving the order Explicit flag >OPENSHELL_*env var > config file > default. The newmode/watchflags default to""/falsewith no env-var fallback, breaking that contract. - If a user passes
--mode once --watchtogether,watchsilently overrides the explicit--modevalue with no error, and an invalid--modevalue (anything other thanonce/watch) is not validated here — it will only fail much later inside the sandbox whenharness-orchestratorcallsValidate().
As per coding guidelines, "Use flag resolution order: Explicit flag > OPENSHELL_* env var > config file > default."
🔧 Proposed fix
- cmd.Flags().StringVar(&mode, "mode", "", "Orchestrator mode: once or watch")
+ cmd.Flags().StringVar(&mode, "mode", envOr("OPENSHELL_MODE", ""), "Orchestrator mode: once or watch")
cmd.Flags().BoolVar(&watch, "watch", false, "Shorthand for --mode=watch") if watch {
- mode = "watch"
+ if mode != "" && mode != "watch" {
+ return fmt.Errorf("--mode=%s conflicts with --watch", mode)
+ }
+ mode = "watch"
}
if mode != "" {
+ if mode != "once" && mode != "watch" {
+ return fmt.Errorf("--mode must be \"once\" or \"watch\", got %q", mode)
+ }
agentCfg.Mode = mode
}Also applies to: 154-155
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmd/apply.go` around lines 55 - 60, The mode selection in the apply command
is bypassing the standard flag resolution and allowing conflicting inputs to
silently override each other. Update the apply flow around mode/watch handling
to use the same Explicit flag > OPENSHELL_* env var > config file > default
pattern used by gatewayName, and validate the resolved mode in the apply command
itself before assigning it to agentCfg.Mode. Also, when both the mode flag and
watch flag are provided, detect the conflict explicitly and return an error
instead of letting watch override the user’s chosen mode.
Source: Coding guidelines
| ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) | ||
| defer cancel() | ||
|
|
||
| orch, err := orchestrator.New(cfg, configDir) | ||
| if err != nil { | ||
| fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err) | ||
| os.Exit(1) | ||
| } | ||
|
|
||
| if err := orch.Run(ctx); err != nil { | ||
| fmt.Fprintf(os.Stderr, "harness-orchestrator: %v\n", err) | ||
| os.Exit(1) | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
ast-grep run --pattern 'func (o *Orchestrator) runWatch($_) $_' --lang go internal/orchestrator/orchestrator.go
rg -n -A20 'func \(o \*Orchestrator\) runWatch' internal/orchestrator/orchestrator.goRepository: stackrox/harness-openshell
Length of output: 4110
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Inspect the orchestrator entrypoint and Run implementation.
grep -n -A40 -B10 'func (o \*Orchestrator) Run' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,180p' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,120p' cmd/orchestrator/main.goRepository: stackrox/harness-openshell
Length of output: 6619
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Check whether any code already treats context cancellation specially at the CLI boundary.
rg -n 'context\.Canceled|context\.DeadlineExceeded|errors\.Is\(.*ctx|signal\.NotifyContext|os\.Exit\(1\)' cmd internalRepository: stackrox/harness-openshell
Length of output: 407
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Run a small read-only probe to show the exact error propagated by runWatch on cancellation.
python3 - <<'PY'
from pathlib import Path
p = Path('internal/orchestrator/orchestrator.go')
text = p.read_text()
for needle in [
'func (o *Orchestrator) Run',
'func (o *Orchestrator) runWatch',
'if ctx.Err() != nil {',
'return ctx.Err()',
]:
print(f'== {needle} ==')
for i, line in enumerate(text.splitlines(), 1):
if needle in line:
start = max(1, i-3)
end = min(len(text.splitlines()), i+6)
for j in range(start, end+1):
print(f'{j}:{text.splitlines()[j-1]}')
break
PYRepository: stackrox/harness-openshell
Length of output: 1456
🏁 Script executed:
#!/bin/bash
set -euo pipefail
# Show the relevant CLI exit path in main.go with line numbers.
nl -ba cmd/orchestrator/main.go | sed -n '1,120p'Repository: stackrox/harness-openshell
Length of output: 204
Treat SIGINT/SIGTERM as a clean shutdown.
runWatch returns ctx.Err() on cancellation, and main exits 1 on any non-nil error. Map context.Canceled to nil at the CLI boundary so a requested stop exits 0 instead of looking like a failure.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@cmd/orchestrator/main.go` around lines 38 - 50, Treat SIGINT/SIGTERM as a
clean shutdown at the CLI boundary by updating cmd/orchestrator/main.go so the
error returned from orch.Run(ctx) is mapped to nil when it is context.Canceled;
otherwise keep the existing non-zero exit path. Use the ctx from
signal.NotifyContext and the orch.Run entrypoint to locate the change, and
ensure only real failures still print an error and exit 1 while user-initiated
cancellation exits successfully.
| type CodexAdapter struct{} | ||
|
|
||
| func (CodexAdapter) Name() string { return "codex" } | ||
|
|
||
| func (CodexAdapter) BuildCommand(task string, headless bool) *exec.Cmd { | ||
| if task == "" { | ||
| return exec.Command("codex") | ||
| } | ||
| if headless { | ||
| return exec.Command("codex", "--print", task) | ||
| } | ||
| return exec.Command("codex", "-p", task) | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
printf '\n## Files mentioning codex / adapter\n'
git ls-files | rg 'internal/orchestrator/adapter\.go|codex|orchestrator' || true
printf '\n## Search for codex references\n'
rg -n --hidden --glob '!**/.git/**' '\bcodex\b|--print|\-p\b|exec subcommand|codex exec|codex e' .
printf '\n## Read target file with line numbers\n'
sed -n '1,140p' internal/orchestrator/adapter.goRepository: stackrox/harness-openshell
Length of output: 5751
🌐 Web query:
OpenAI Codex CLI documentation exec subcommand --print -p non-interactive prompt flags
💡 Result:
The OpenAI Codex CLI (a Rust-based coding agent) uses the exec subcommand to run tasks non-interactively, which is designed for automation, CI/CD pipelines, and scripting [1][2][3]. Key flags and usage patterns for codex exec: Basic Syntax codex exec [OPTIONS] [PROMPT] [3] Core Flags and Options - --json: Enables structured output by streaming newline-delimited JSON events instead of standard formatted text, which is ideal for programmatic processing (e.g., using jq) [1][3]. - --last: Used with the resume subcommand to continue the most recent session from the current working directory [1][4]. - --all: Used with the resume subcommand to search for sessions across all directories [1][4]. - --model (or -m): Specifies the AI model to use for the task [3][5]. - --sandbox: Configures permission levels for the environment, such as workspace-write (allow edits) or danger-full-access (broad access) [2]. Non-interactive Prompt Handling The exec command handles prompts in several ways: - Inline Prompt: Pass the prompt directly as an argument, e.g., codex exec "explain this codebase" [3]. - Stdin: If no prompt is provided, Codex reads from stdin [2][3]. You can force this behavior by explicitly using codex exec - [2][3]. - Input Pipes: You can pipe content into the command, for example, echo "add error handling" | codex exec [3]. Resume Subcommand If a non-interactive task needs to be continued, you can use the resume subcommand [1][3]: codex exec resume [SESSION_ID] [1][4] codex exec resume --last "follow-up prompt" [2][6] For comprehensive documentation, you can refer to the official OpenAI Codex CLI reference [1][7].
Citations:
- 1: https://developers.openai.com/codex/cli/reference
- 2: https://developers.openai.com/codex/noninteractive
- 3: https://openai-codex.mintlify.app/cli/exec
- 4: https://developers.openai.com/codex/cli/reference.md
- 5: https://github.com/openai/codex/blob/9a8730f3/codex-cli/README.md
- 6: https://developers.openai.com/codex/cli/features
- 7: https://developers.openai.com/codex/cli
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,220p' internal/orchestrator/adapter_test.go
printf '\n---\n'
sed -n '1,220p' internal/orchestrator/orchestrator.goRepository: stackrox/harness-openshell
Length of output: 7065
internal/orchestrator/adapter.go:46-51 — Use codex exec for task runs CodexAdapter still passes unsupported --print/-p flags to codex. The documented prompt entrypoint is codex exec [PROMPT], so any tasked run will fail until this adapter switches to that subcommand.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/orchestrator/adapter.go` around lines 40 - 52,
CodexAdapter.BuildCommand still uses unsupported flags for task execution, so
switch the task-run path to the documented codex exec subcommand instead of
passing --print or -p. Update BuildCommand in CodexAdapter so non-empty tasks
are invoked via codex exec with the prompt argument, while preserving the
empty-task behavior if needed.
| func (c *OrchestratorConfig) ApplyDefaults() { | ||
| if c.Mode == "" { | ||
| c.Mode = "once" | ||
| } | ||
| if c.Entrypoint == "" { | ||
| c.Entrypoint = "claude" | ||
| } | ||
| if c.PollInterval <= 0 { | ||
| c.PollInterval = 300 | ||
| } | ||
| if c.MaxFailures <= 0 { | ||
| c.MaxFailures = 5 | ||
| } | ||
| if c.Heartbeat == 0 { | ||
| c.Heartbeat = 60 | ||
| } | ||
| if c.SessionDir == "" { | ||
| c.SessionDir = "/sandbox/.harness" | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Inconsistent zero/negative guard for Heartbeat vs other fields.
PollInterval and MaxFailures use <= 0 so negative values fall back to defaults, but Heartbeat uses == 0 (line 53), letting a negative value pass through unmodified. Validate() (lines 61-73) never checks numeric ranges either, so a negative Heartbeat from a malformed orchestrator.yaml reaches the orchestrator unchecked, which is likely to break the heartbeat/sleep timer downstream (e.g., a negative duration in sleepWithHeartbeat).
🐛 Proposed fix
- if c.Heartbeat == 0 {
+ if c.Heartbeat <= 0 {
c.Heartbeat = 60
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| func (c *OrchestratorConfig) ApplyDefaults() { | |
| if c.Mode == "" { | |
| c.Mode = "once" | |
| } | |
| if c.Entrypoint == "" { | |
| c.Entrypoint = "claude" | |
| } | |
| if c.PollInterval <= 0 { | |
| c.PollInterval = 300 | |
| } | |
| if c.MaxFailures <= 0 { | |
| c.MaxFailures = 5 | |
| } | |
| if c.Heartbeat == 0 { | |
| c.Heartbeat = 60 | |
| } | |
| if c.SessionDir == "" { | |
| c.SessionDir = "/sandbox/.harness" | |
| } | |
| } | |
| func (c *OrchestratorConfig) ApplyDefaults() { | |
| if c.Mode == "" { | |
| c.Mode = "once" | |
| } | |
| if c.Entrypoint == "" { | |
| c.Entrypoint = "claude" | |
| } | |
| if c.PollInterval <= 0 { | |
| c.PollInterval = 300 | |
| } | |
| if c.MaxFailures <= 0 { | |
| c.MaxFailures = 5 | |
| } | |
| if c.Heartbeat <= 0 { | |
| c.Heartbeat = 60 | |
| } | |
| if c.SessionDir == "" { | |
| c.SessionDir = "/sandbox/.harness" | |
| } | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/orchestrator/config.go` around lines 40 - 59, The defaulting logic
in OrchestratorConfig.ApplyDefaults is inconsistent because Heartbeat only
resets on 0 while PollInterval and MaxFailures also handle negative values.
Update ApplyDefaults to treat Heartbeat the same way as the other numeric fields
so malformed negative values are replaced with the default, and keep the
behavior aligned with Validate and downstream timer use such as
sleepWithHeartbeat.
| func (o *Orchestrator) Run(ctx context.Context) error { | ||
| defer o.session.Close() | ||
|
|
||
| switch o.config.Mode { | ||
| case "once": | ||
| result, err := o.runCycle(ctx) | ||
| if err != nil { | ||
| return err | ||
| } | ||
| if result.ExitCode != 0 { | ||
| return fmt.Errorf("agent exited with code %d", result.ExitCode) | ||
| } | ||
| return nil | ||
| case "watch": | ||
| return o.runWatch(ctx) | ||
| default: | ||
| return fmt.Errorf("unknown mode %q", o.config.Mode) | ||
| } | ||
| } | ||
|
|
||
| func (o *Orchestrator) runWatch(ctx context.Context) error { | ||
| for { | ||
| result, err := o.runCycle(ctx) | ||
| if err != nil { | ||
| if ctx.Err() != nil { | ||
| return ctx.Err() | ||
| } | ||
| o.failures++ | ||
| logf("cycle %d failed: %v (failure %d/%d)", o.cycle, err, o.failures, o.config.MaxFailures) | ||
| if o.failures >= o.config.MaxFailures { | ||
| return fmt.Errorf("max transient failures (%d) exceeded", o.config.MaxFailures) | ||
| } | ||
| o.sleepWithHeartbeat(ctx, o.config.PollInterval) | ||
| if ctx.Err() != nil { | ||
| return ctx.Err() | ||
| } | ||
| continue | ||
| } | ||
|
|
||
| o.runHooks(result) | ||
|
|
||
| if result.IsTerminal() { | ||
| logf("cycle %d: terminal status %q (%s)", o.cycle, result.Status, result.Reason) | ||
| return nil | ||
| } | ||
|
|
||
| switch result.Status { | ||
| case "transient_failure": | ||
| o.failures++ | ||
| logf("cycle %d: transient failure (%s), failure %d/%d", o.cycle, result.Reason, o.failures, o.config.MaxFailures) | ||
| if o.failures >= o.config.MaxFailures { | ||
| return fmt.Errorf("max transient failures (%d) exceeded", o.config.MaxFailures) | ||
| } | ||
| default: | ||
| o.failures = 0 | ||
| } | ||
|
|
||
| poll := o.config.PollInterval | ||
| if result.PollSeconds > 0 { | ||
| poll = result.PollSeconds | ||
| } | ||
|
|
||
| logf("cycle %d: %s (%s), next cycle in %ds", o.cycle, result.Status, result.Reason, poll) | ||
| o.sleepWithHeartbeat(ctx, poll) | ||
| if ctx.Err() != nil { | ||
| return ctx.Err() | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Verify which statuses IsTerminal() treats as terminal, and whether failure statuses exist
ast-grep run --pattern 'func (r *CycleResult) IsTerminal() $_ { $$$ }' --lang go internal/orchestrator/sentinel.go
rg -n -A5 'func.*IsTerminal' internal/orchestrator/sentinel.go
rg -n '"terminal_failure"|"transient_failure"' internal/orchestratorRepository: stackrox/harness-openshell
Length of output: 1496
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,220p' internal/orchestrator/sentinel.go
printf '\n--- orchestrator.go ---\n'
sed -n '1,220p' internal/orchestrator/orchestrator.go
printf '\n--- orchestrator_test excerpt ---\n'
sed -n '160,240p' internal/orchestrator/orchestrator_test.goRepository: stackrox/harness-openshell
Length of output: 8039
Propagate terminal failures from the orchestrator
runWatchreturnsnilfor everyIsTerminal()result, includingterminal_failure, so unrecoverable agent failures exit as success.Run(..., "once")only checksExitCode; a sentinelterminal_failurewith exit code0also passes as success.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/orchestrator/orchestrator.go` around lines 38 - 106, Update
Orchestrator.Run and runWatch so terminal failures are surfaced as errors
instead of success. In runWatch, keep treating IsTerminal() as a stop condition,
but return an error when result.Status is "terminal_failure" (using
result.Reason for context) and only return nil for successful terminal states.
In the "once" branch of Run, also check result.Status alongside ExitCode so a
sentinel terminal_failure with exit code 0 is rejected; use the existing
runCycle, IsTerminal, and result.Status/Reason fields to locate the logic.
Source: Path instructions
| func (o *Orchestrator) runCycle(ctx context.Context) (*CycleResult, error) { | ||
| o.cycle++ | ||
| start := time.Now() | ||
|
|
||
| task := o.resolveTask() | ||
| headless := !o.config.TTY | ||
| cmd := o.adapter.BuildCommand(task, headless) | ||
| cmd.Stderr = os.Stderr | ||
|
|
||
| var stdout bytes.Buffer | ||
| if headless { | ||
| cmd.Stdout = &stdout | ||
| } else { | ||
| cmd.Stdin = os.Stdin | ||
| cmd.Stdout = os.Stdout | ||
| } | ||
|
|
||
| logf("cycle %d: running %s", o.cycle, o.adapter.Name()) | ||
| err := cmd.Run() | ||
| duration := time.Since(start) | ||
|
|
||
| var result *CycleResult | ||
| exitCode := 0 | ||
| if err != nil { | ||
| if exitErr, ok := err.(*exec.ExitError); ok { | ||
| exitCode = exitErr.ExitCode() | ||
| } else { | ||
| return nil, fmt.Errorf("running %s: %w", o.adapter.Name(), err) | ||
| } | ||
| } | ||
|
|
||
| if o.config.Sentinel && headless { | ||
| parsed, parseErr := ParseSentinel(stdout.Bytes()) | ||
| if parseErr != nil { | ||
| logf("cycle %d: sentinel parse error: %v", o.cycle, parseErr) | ||
| result = &CycleResult{Status: "transient_failure", Reason: "malformed_sentinel", ExitCode: exitCode} | ||
| } else if parsed != nil { | ||
| result = parsed | ||
| result.ExitCode = exitCode | ||
| } | ||
| } | ||
|
|
||
| if result == nil { | ||
| result = SyntheticResult(exitCode) | ||
| } | ||
|
|
||
| record := SessionRecord{ | ||
| Timestamp: start, | ||
| Cycle: o.cycle, | ||
| Entrypoint: o.adapter.Name(), | ||
| Mode: o.config.Mode, | ||
| DurationSec: duration.Seconds(), | ||
| Result: result, | ||
| } | ||
| if writeErr := o.session.Write(record); writeErr != nil { | ||
| logf("warning: failed to write session record: %v", writeErr) | ||
| } | ||
|
|
||
| return result, nil | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Confirm HarnessAdapter.BuildCommand signature and whether ctx is threaded anywhere into exec.Cmd construction
rg -n -A10 'type HarnessAdapter interface' internal/orchestrator/adapter.go
rg -n 'CommandContext|cmd.Process.Kill|cmd.Process.Signal' internal/orchestratorRepository: stackrox/harness-openshell
Length of output: 457
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== orchestrator outline =="
ast-grep outline internal/orchestrator/orchestrator.go --view expanded || true
echo "== adapter outline =="
ast-grep outline internal/orchestrator/adapter.go --view expanded || true
echo "== search for context-aware process handling =="
rg -n 'CommandContext|Process.Kill|Process.Signal|ctx.Done|WithContext|exec\.Command\(' internal/orchestrator internal -g '!**/*_test.go' || true
echo "== relevant slices =="
sed -n '1,220p' internal/orchestrator/orchestrator.go
printf '\n--- adapter.go ---\n'
sed -n '1,220p' internal/orchestrator/adapter.goRepository: stackrox/harness-openshell
Length of output: 10148
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== file list =="
git ls-files internal/orchestrator
echo "== line counts =="
wc -l internal/orchestrator/orchestrator.go internal/orchestrator/adapter.go
echo "== adapter implementations =="
rg -n 'func .*BuildCommand|type .*Adapter struct' internal/orchestrator -g '!**/*_test.go' || trueRepository: stackrox/harness-openshell
Length of output: 1209
🏁 Script executed:
#!/bin/bash
set -euo pipefail
python3 - <<'PY'
from pathlib import Path
p = Path('internal/orchestrator/orchestrator.go')
text = p.read_text()
for needle in ['ctx context.Context', 'cmd.Run()', 'ParseSentinel', 'sleepWithHeartbeat']:
print(f'-- {needle} --')
for i, line in enumerate(text.splitlines(), 1):
if needle in line:
start = max(1, i-8)
end = min(len(text.splitlines()), i+12)
for j in range(start, end+1):
print(f'{j}:{text.splitlines()[j-1]}')
print()
break
PYRepository: stackrox/harness-openshell
Length of output: 2558
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '1,220p' internal/orchestrator/orchestrator.go
printf '\n---\n'
sed -n '1,220p' internal/orchestrator/adapter.go
printf '\n---\n'
rg -n 'func .*BuildCommand' internal/orchestrator -g '!**/*_test.go' || trueRepository: stackrox/harness-openshell
Length of output: 6806
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n -A12 -B4 'func \(.*\) BuildCommand\(task string, headless bool\) \*exec\.Cmd' internal/orchestrator -g '!**/*_test.go' || true
rg -n 'exec\.CommandContext|exec\.Command\(' internal/orchestrator -g '!**/*_test.go' || trueRepository: stackrox/harness-openshell
Length of output: 3134
Wire ctx into the agent subprocess
runCycle accepts ctx, but the command is still created with exec.Command, so cancellation only takes effect between cycles. A SIGTERM/Ctrl+C during an active run won’t stop cmd.Run(); pass ctx into BuildCommand or kill the process group on ctx.Done().
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/orchestrator/orchestrator.go` around lines 108 - 167, runCycle
ignores ctx while launching the agent subprocess, so cancellation and
SIGTERM/Ctrl+C won’t interrupt an active cycle. Update Orchestrator.runCycle to
thread ctx into the command creation path, ideally through adapter.BuildCommand,
so cmd.Run() is context-aware. If BuildCommand cannot accept ctx, add explicit
handling for ctx.Done() that terminates the subprocess (or its process group)
during the active run. Keep the existing sentinel parsing and SessionRecord flow
unchanged.
Source: Path instructions
| func (o *Orchestrator) runHooks(result *CycleResult) { | ||
| var hooks []string | ||
| switch result.Status { | ||
| case "complete": | ||
| hooks = o.config.OnComplete | ||
| case "propose": | ||
| hooks = o.config.OnPropose | ||
| } | ||
| for _, h := range hooks { | ||
| cmd := exec.Command("sh", "-c", h) | ||
| cmd.Stdout = os.Stdout | ||
| cmd.Stderr = os.Stderr | ||
| if err := cmd.Run(); err != nil { | ||
| logf("hook failed: %v", err) | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
Hooks run with no timeout, and shell out via sh -c.
runHooks runs cmd.Run() synchronously with no timeout (line 218); if a hook script hangs (e.g. a network call), the watch loop is blocked indefinitely with no way to recover via MaxFailures or ctx cancellation, since ctx isn't passed in here either. Static analysis also flags exec.Command("sh", "-c", h) for CWE-78 — h comes from operator-authored OnComplete/OnPropose config rather than agent/runtime output, so injection risk is low in practice, but bounding execution time would still close a real reliability gap.
🐛 Suggested fix
-func (o *Orchestrator) runHooks(result *CycleResult) {
+func (o *Orchestrator) runHooks(ctx context.Context, result *CycleResult) {
var hooks []string
switch result.Status {
case "complete":
hooks = o.config.OnComplete
case "propose":
hooks = o.config.OnPropose
}
for _, h := range hooks {
- cmd := exec.Command("sh", "-c", h)
+ hookCtx, cancel := context.WithTimeout(ctx, o.config.HookTimeout)
+ cmd := exec.CommandContext(hookCtx, "sh", "-c", h)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := cmd.Run(); err != nil {
logf("hook failed: %v", err)
}
+ cancel()
}
}🧰 Tools
🪛 ast-grep (0.44.0)
[error] 214-214: A shell (sh/bash) is invoked with -c and a dynamically built command string (string concatenation, fmt.Sprintf, or a variable holding the command) passed to exec.Command / exec.CommandContext. Untrusted input embedded in the command lets an attacker inject arbitrary shell commands. Avoid the shell: call the target binary directly with exec.Command(name, arg1, arg2, ...) so each argument is passed as a separate, non-interpreted token, and never build a shell command string from external input.
Context: exec.Command("sh", "-c", h)
Note: [CWE-78] Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection').
(command-injection-exec-sh-c-go)
🪛 OpenGrep (1.23.0)
[ERROR] 215-215: Dynamic command passed to exec.Command with a shell invocation. Pass arguments directly to exec.Command without a shell wrapper.
(coderabbit.command-injection.go-exec-command)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@internal/orchestrator/orchestrator.go` around lines 206 - 222, The runHooks
method blocks the watch loop because it executes each hook synchronously with
cmd.Run() and no timeout or cancellation path. Update Orchestrator.runHooks to
accept and use a context or derive a timeout when creating the exec.Command, so
hung hooks can be interrupted instead of blocking indefinitely. Keep the
existing hook selection logic for result.Status and preserve logging in logf,
but ensure each hook execution is bounded and can honor shutdown/cancellation
signals.
Source: Path instructions
| ## Build the in-sandbox orchestrator binary (linux/amd64) | ||
| orchestrator: | ||
| CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \ | ||
| -o bin/harness-orchestrator ./cmd/orchestrator | ||
| @echo "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))" |
There was a problem hiding this comment.
🩺 Stability & Availability | 🟠 Major | ⚡ Quick win
orchestrator target hardcodes GOARCH=amd64, conflicting with native-arch dev builds.
dev-sandbox is documented as "native arch only" (line 98) and the Dockerfile downloads arch-specific binaries via uname -m (e.g. lines 52-54), implying the sandbox image is built for the host's native architecture. But this target unconditionally cross-compiles harness-orchestrator for linux/amd64. On an arm64 dev host (e.g. Apple Silicon), make dev-sandbox will bake an amd64 binary into an arm64 image, causing an exec-format failure at container runtime — and since executor.go only checks command -v harness-orchestrator (existence, not architecture), there is no fallback to run.sh in that case.
🔧 Proposed fix
+ORCH_GOARCH ?= $(shell go env GOARCH)
+
## Build the in-sandbox orchestrator binary (linux/amd64)
orchestrator:
- CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \
+ CGO_ENABLED=0 GOOS=linux GOARCH=$(ORCH_GOARCH) go build -ldflags '$(LDFLAGS)' \
-o bin/harness-orchestrator ./cmd/orchestrator
- `@echo` "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))"
+ `@echo` "Built: bin/harness-orchestrator (linux/$(ORCH_GOARCH), $(VERSION))"📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| ## Build the in-sandbox orchestrator binary (linux/amd64) | |
| orchestrator: | |
| CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags '$(LDFLAGS)' \ | |
| -o bin/harness-orchestrator ./cmd/orchestrator | |
| @echo "Built: bin/harness-orchestrator (linux/amd64, $(VERSION))" | |
| ORCH_GOARCH ?= $(shell go env GOARCH) | |
| ## Build the in-sandbox orchestrator binary (linux/amd64) | |
| orchestrator: | |
| CGO_ENABLED=0 GOOS=linux GOARCH=$(ORCH_GOARCH) go build -ldflags '$(LDFLAGS)' \ | |
| -o bin/harness-orchestrator ./cmd/orchestrator | |
| `@echo` "Built: bin/harness-orchestrator (linux/$(ORCH_GOARCH), $(VERSION))" |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@Makefile` around lines 37 - 41, The orchestrator build target is hardcoding a
linux/amd64 binary, which breaks native-arch sandbox builds on arm64 hosts.
Update the Makefile’s orchestrator target so it builds for the host architecture
instead of forcing GOARCH=amd64, and keep the output path/name used by
dev-sandbox and executor.go unchanged so the existing command lookup still
works. Use the orchestrator target as the fix point and align it with the
native-arch behavior implied by the Dockerfile and dev-sandbox flow.
Add a standalone Go binary (harness-orchestrator) that runs inside the OpenShell sandbox as the entrypoint, replacing the generated run.sh. Provides typed config, watch mode with cycle management, structured sentinel parsing (OPENSHELL_AGENT_RESULT protocol), and session history as append-only JSONL.
The binary sits between the OpenShell supervisor and the AI agent harness, managing the observe-decide-act-record loop with proper signal handling, heartbeats, retry with backoff, and lifecycle hooks.
New package: internal/orchestrator/ (6 source files, 25 tests) New binary: cmd/orchestrator/main.go (2.4MB, statically linked) Host-side: mode/sentinel/watch fields on AgentConfig, --mode/--watch flags on harness apply, orchestrator.yaml written to payload dir.
Backwards compatible — images without the binary fall back to run.sh.
Summary by CodeRabbit
New Features
Bug Fixes