Skip to content

Commit 04d6c8a

Browse files
Miley Chandonnetclaude
authored andcommitted
AMPR-170 #499: wire AMPR-169 bridge into CLI, add --headless flag
Authored by Claude (Opus 4.7) on Miley's direction. Implements AMPR-170 against the current ampere-cli architecture, where the Lumos TUI is already the default experience — so the opt-in `--with-lumos` flag from the original ticket is replaced by an inverse `--headless` flag with auto-fallback when stdout is not a TTY or the terminal is smaller than 40x15. Wires the AMPR-169 :ampere-phosphor bridge into the running CognitiveSceneRuntime via a new LumosBridgeController: lazy-starts once the scene exists, drives onFrameTick from the render loop, stops idempotently in finally. Bumps :ampere-cli to phosphor-core 0.5.0 to match :ampere-phosphor, and promotes :ampere-phosphor's phosphor deps to api so consumers see the types in the bridge's public constructor. A new runHeadless() path in AmpereCommand subscribes to the bus, prints every event as one text line, and stays resident until interrupted — except for true one-shots (`--issue N`, `--use-arc-phases`) which exit after the work returns. TuiAvailability is extracted as a pure function so each fallback branch (user-requested, non-TTY, too-small) is unit-testable. Test coverage: TuiAvailabilityTest (7 branches), LumosBridgeControllerTest (lazy-start, PROPEL phase event → atmosphere routing, idempotent stop), and a --headless flag-presence test. All :ampere-cli:jvmTest and :ampere-phosphor:jvmTest pass; ktlintFormat clean. Note: the bridge queues glyphs via VoxelFrameBuilder, but the existing waveform pane does not yet render VoxelFrame.glyph overlays. Atmosphere transitions are fully wired; glyph visuals are a documented follow-up. Closes #499 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 848a951 commit 04d6c8a

11 files changed

Lines changed: 543 additions & 6 deletions

File tree

ampere-cli/README.md

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,29 @@ ampere --auto-work # Start with background issue work
8383

8484
The TUI shows:
8585
- **Left pane (35%)**: Event stream (filtered by significance)
86-
- **Middle pane (40%)**: Cognitive cycle progress / system vitals
86+
- **Middle pane (40%)**: Cognitive cycle progress / system vitals (Phosphor waveform; atmosphere shifts with the active PROPEL phase)
8787
- **Right pane (25%)**: Agent memory stats (or logs in verbose mode)
8888

89+
The cognitive scene is driven by an [AMPERE Phosphor bridge](../ampere-phosphor/README.md) that subscribes to the agent's `EventSerialBus`: every PROPEL `PhaseEntered` event re-targets the Lumos atmosphere, `EscalationFired` snaps to `UNCERTAIN`, and `TaskCompleted` / `TaskFailed` / `ToolExecutionCompleted` / `MilestoneReached` queue glyph cues on the runtime.
90+
91+
**Terminal requirements:** the TUI needs at least **40 columns × 15 rows** and a connected TTY. If either condition is missing, the CLI auto-falls-back to headless mode with a one-line notice on stderr.
92+
93+
### Headless mode
94+
95+
Use `--headless` to disable the dashboard and stream plain-text event lines to stdout — useful in CI, log pipes, or environments without a usable terminal:
96+
97+
```bash
98+
ampere --goal "Implement FizzBuzz" --headless
99+
ampere --issue 42 --headless
100+
ampere --headless | tee run.log
101+
```
102+
103+
Auto-detection enables headless mode in the same conditions:
104+
- `stdout` is not a TTY (e.g. the output is piped or redirected).
105+
- The terminal is smaller than 40×15.
106+
107+
In headless mode `ampere` exits after synchronous one-shot work (`--issue N`, `--use-arc-phases`); other modes stay resident streaming events until you `Ctrl+C`.
108+
89109
**Keyboard controls:**
90110
- `d` - Dashboard mode (system vitals, agent status)
91111
- `e` - Event stream mode (filtered events)

ampere-cli/build.gradle.kts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,8 @@ kotlin {
8484
val jvmMain by getting {
8585
dependencies {
8686
implementation(project(":ampere-core"))
87-
implementation("link.socket:phosphor-core:0.4.0")
87+
implementation(project(":ampere-phosphor"))
88+
implementation("link.socket:phosphor-core:0.5.0")
8889

8990
// CLI argument parsing
9091
implementation("com.github.ajalt.clikt:clikt:4.4.0")

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/AmpereCommand.kt

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ import link.socket.ampere.cli.layout.RichEventPane
3030
import link.socket.ampere.cli.layout.StatusBar
3131
import link.socket.ampere.cli.layout.TaskChecklistPane
3232
import link.socket.ampere.cli.hybrid.HybridDashboardRenderer
33+
import link.socket.ampere.cli.launch.HeadlessReason
34+
import link.socket.ampere.cli.launch.TuiDecision
35+
import link.socket.ampere.cli.launch.decideTuiUsage
36+
import link.socket.ampere.cli.launch.userMessage
37+
import link.socket.ampere.cli.render.LumosBridgeController
38+
import link.socket.ampere.agents.events.api.EventHandler
39+
import link.socket.ampere.agents.events.subscription.Subscription
40+
import link.socket.ampere.agents.domain.event.Event
41+
import kotlinx.coroutines.CompletableDeferred
3342
import link.socket.ampere.cli.watch.CommandExecutor
3443
import link.socket.ampere.cli.watch.CommandResult
3544
import link.socket.ampere.cli.watch.presentation.EventSignificance
@@ -129,6 +138,11 @@ class AmpereCommand(
129138
help = "Execute goal using Arc phases (Charge -> Flow -> Pulse) instead of direct agent execution"
130139
).flag(default = false)
131140

141+
private val headless: Boolean by option(
142+
"--headless",
143+
help = "Disable the Lumos TUI dashboard and stream plain-text agent events to stdout (auto-enabled when stdout is not a TTY or the terminal is too small)"
144+
).flag(default = false)
145+
132146
override fun run() = runBlocking {
133147
// Handle --list-arcs flag (non-TUI, prints and exits)
134148
if (listArcs) {
@@ -174,12 +188,32 @@ class AmpereCommand(
174188

175189
val context = contextProvider()
176190
val agentScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
191+
192+
val tuiDecision = decideTuiUsage(
193+
userRequestedHeadless = headless,
194+
capabilities = TerminalFactory.getCapabilities(),
195+
)
196+
if (tuiDecision.useHeadless) {
197+
runHeadless(
198+
context = context,
199+
agentScope = agentScope,
200+
selectedArc = selectedArc,
201+
hasActiveWork = hasActiveWork,
202+
decision = tuiDecision,
203+
)
204+
return@runBlocking
205+
}
206+
177207
val terminal = TerminalFactory.createTerminal()
178208
val presenter = WatchPresenter(context.eventRelayService)
179209
val workspaceStateStore = context.environmentService.workspaceStateStore
180210

181211
// Create TUI components
182212
val hybridRenderer = HybridDashboardRenderer(terminal).also { it.initialize() }
213+
val lumosBridgeController = LumosBridgeController(
214+
bus = context.environmentService.eventBus,
215+
sceneProvider = { hybridRenderer.waveformPane?.runtimeAdapter?.scene },
216+
)
183217
val eventPane = RichEventPane(terminal)
184218
val jazzPane = CognitiveProgressPane(terminal)
185219
val memoryPane = AgentMemoryPane(terminal)
@@ -447,6 +481,11 @@ class AmpereCommand(
447481
)
448482
}
449483

484+
// Bridge AMPERE bus → Phosphor atmosphere/glyphs. Lazy-starts once
485+
// the waveform pane has built its CognitiveSceneRuntime; subsequent
486+
// ticks flush coalesced atmosphere targets.
487+
lumosBridgeController.tick()
488+
450489
// Write every frame (animation state changes even when pane content doesn't)
451490
val out = LogCapture.getOriginalOut() ?: System.out
452491
out.print(output)
@@ -470,6 +509,7 @@ class AmpereCommand(
470509
out.flush()
471510
throw e
472511
} finally {
512+
lumosBridgeController.stop()
473513
LogCapture.stop()
474514
presenter.stop()
475515
inputHandler.close()
@@ -635,4 +675,156 @@ class AmpereCommand(
635675
}
636676
}
637677
}
678+
679+
/**
680+
* Run AMPERE without the Lumos TUI dashboard.
681+
*
682+
* Streams every event from the bus as a single text line so the run is
683+
* usable in CI, pipes, and minimal terminals. Blocks until the user
684+
* interrupts (Ctrl+C) — matching the TUI's stay-resident behavior.
685+
*/
686+
private suspend fun runHeadless(
687+
context: AmpereContext,
688+
agentScope: CoroutineScope,
689+
selectedArc: ArcConfig,
690+
hasActiveWork: Boolean,
691+
decision: TuiDecision,
692+
) {
693+
val capabilities = TerminalFactory.getCapabilities()
694+
val reason = decision.reason
695+
if (reason != null && reason != HeadlessReason.USER_REQUESTED) {
696+
System.err.println(reason.userMessage(capabilities))
697+
}
698+
println("AMPERE headless mode. Press Ctrl+C to exit.")
699+
700+
context.subscribeToAll(
701+
agentId = HEADLESS_AGENT_ID,
702+
handler = EventHandler<Event, Subscription> { event, _ ->
703+
println(formatHeadlessEvent(event))
704+
},
705+
)
706+
707+
// Headless goal activation uses GoalHandler unchanged. The progress and
708+
// memory panes only hold internal state when nothing renders them, so a
709+
// dangling Terminal here is harmless — we never draw.
710+
val unrenderedTerminal = TerminalFactory.createTerminal()
711+
val noopProgressPane = CognitiveProgressPane(unrenderedTerminal)
712+
val noopMemoryPane = AgentMemoryPane(unrenderedTerminal)
713+
714+
try {
715+
if (autoWork) {
716+
context.startAutonomousWork()
717+
}
718+
719+
var isOneShot = false
720+
when {
721+
goal != null -> {
722+
val effectiveGoal = goal!!
723+
if (useArcPhases) {
724+
executeArcPhasesHeadless(effectiveGoal, selectedArc)
725+
isOneShot = true
726+
} else {
727+
val goalHandler = GoalHandler(
728+
context = context,
729+
agentScope = agentScope,
730+
progressPane = noopProgressPane,
731+
memoryPane = noopMemoryPane,
732+
aiConfiguration = context.aiConfiguration,
733+
)
734+
val result = goalHandler.activateGoal(effectiveGoal)
735+
if (result.isFailure) {
736+
System.err.println(
737+
"Goal activation failed: ${result.exceptionOrNull()?.message}"
738+
)
739+
}
740+
}
741+
}
742+
issues -> context.startAutonomousWork()
743+
issue != null -> {
744+
val availableIssues = context.codeIssueWorkflow.queryAvailableIssues()
745+
val targetIssue = availableIssues.find { it.number == issue }
746+
if (targetIssue == null) {
747+
System.err.println("Issue #$issue not found or not available")
748+
} else {
749+
val issueResult = context.codeIssueWorkflow.workOnIssue(
750+
targetIssue,
751+
context.codeAgent,
752+
)
753+
if (issueResult.isFailure) {
754+
System.err.println("Failed: ${issueResult.exceptionOrNull()?.message}")
755+
}
756+
}
757+
isOneShot = true
758+
}
759+
else -> {
760+
val configGoal = context.userConfig?.goal
761+
if (configGoal != null) {
762+
val goalHandler = GoalHandler(
763+
context = context,
764+
agentScope = agentScope,
765+
progressPane = noopProgressPane,
766+
memoryPane = noopMemoryPane,
767+
aiConfiguration = context.aiConfiguration,
768+
)
769+
val result = goalHandler.activateGoal(configGoal)
770+
if (result.isFailure) {
771+
System.err.println(
772+
"Goal activation failed: ${result.exceptionOrNull()?.message}"
773+
)
774+
}
775+
}
776+
}
777+
}
778+
779+
// `--issue N` and `--use-arc-phases` are synchronous one-shots: exit
780+
// after the work returns. All other modes stay resident so the user
781+
// can keep observing the event stream (matches TUI behavior).
782+
if (!isOneShot) {
783+
CompletableDeferred<Unit>().await()
784+
}
785+
} catch (e: CancellationException) {
786+
throw e
787+
} catch (e: Exception) {
788+
System.err.println("Error: ${e.message}")
789+
throw e
790+
} finally {
791+
agentScope.cancel()
792+
println("AMPERE stopped")
793+
}
794+
}
795+
796+
private suspend fun executeArcPhasesHeadless(goal: String, arcConfig: ArcConfig) {
797+
val projectDirPath = File(System.getProperty("user.dir")).absolutePath
798+
val runtime = AmpereRuntime.create(
799+
arcConfig = arcConfig,
800+
projectDirPath = projectDirPath,
801+
)
802+
val chargeResult = runtime.executeChargeOnly(goal)
803+
println(
804+
"Charge complete. Project: ${chargeResult.projectContext.projectId}, " +
805+
"${chargeResult.agents.size} agents"
806+
)
807+
val result = runtime.execute(goal)
808+
println(
809+
"Pulse: ${result.pulseResult.evaluationReport.goalsCompleted}/" +
810+
"${result.pulseResult.evaluationReport.goalsTotal} goals"
811+
)
812+
if (!result.success) {
813+
System.err.println("Arc completed with failures")
814+
}
815+
}
816+
817+
companion object {
818+
const val HEADLESS_AGENT_ID: String = "ampere-headless"
819+
820+
internal fun formatHeadlessEvent(event: Event): String {
821+
val summary = runCatching {
822+
event.getSummary(
823+
formatUrgency = { it.name },
824+
formatSource = { it.toString() },
825+
)
826+
}.getOrElse { event.eventType }
827+
return "[${event.timestamp}] ${event.eventType} $summary"
828+
}
829+
}
638830
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package link.socket.ampere.cli.launch
2+
3+
import link.socket.ampere.repl.TerminalFactory
4+
5+
/**
6+
* Decides whether the Lumos TUI dashboard is usable in the current environment,
7+
* or whether the CLI should fall back to plain-text headless output.
8+
*
9+
* The TUI is the default experience. We only fall back when:
10+
* - The user explicitly asks for headless mode via `--headless`.
11+
* - stdout is not a TTY (e.g. CI, piped runs).
12+
* - The terminal is too small for the dashboard to be legible.
13+
*/
14+
data class TuiDecision(
15+
val useHeadless: Boolean,
16+
val reason: HeadlessReason?,
17+
) {
18+
val useTui: Boolean get() = !useHeadless
19+
}
20+
21+
enum class HeadlessReason {
22+
USER_REQUESTED,
23+
NON_TTY,
24+
TERMINAL_TOO_SMALL,
25+
}
26+
27+
const val MIN_TUI_WIDTH: Int = 40
28+
const val MIN_TUI_HEIGHT: Int = 15
29+
30+
fun decideTuiUsage(
31+
userRequestedHeadless: Boolean,
32+
capabilities: TerminalFactory.TerminalCapabilities,
33+
minWidth: Int = MIN_TUI_WIDTH,
34+
minHeight: Int = MIN_TUI_HEIGHT,
35+
): TuiDecision = when {
36+
userRequestedHeadless -> TuiDecision(true, HeadlessReason.USER_REQUESTED)
37+
!capabilities.isInteractive -> TuiDecision(true, HeadlessReason.NON_TTY)
38+
capabilities.width < minWidth || capabilities.height < minHeight ->
39+
TuiDecision(true, HeadlessReason.TERMINAL_TOO_SMALL)
40+
else -> TuiDecision(false, null)
41+
}
42+
43+
fun HeadlessReason.userMessage(
44+
capabilities: TerminalFactory.TerminalCapabilities,
45+
minWidth: Int = MIN_TUI_WIDTH,
46+
minHeight: Int = MIN_TUI_HEIGHT,
47+
): String = when (this) {
48+
HeadlessReason.USER_REQUESTED ->
49+
"Running in headless mode (--headless)."
50+
HeadlessReason.NON_TTY ->
51+
"stdout is not a TTY; falling back to headless mode."
52+
HeadlessReason.TERMINAL_TOO_SMALL ->
53+
"Terminal is too small for the Lumos TUI " +
54+
"(${capabilities.width}x${capabilities.height}; need at least ${minWidth}x$minHeight); " +
55+
"falling back to headless mode."
56+
}

ampere-cli/src/jvmMain/kotlin/link/socket/ampere/cli/render/CognitiveSceneRuntimeAdapter.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ class CognitiveSceneRuntimeAdapter {
3535
val agents: AgentLayer?
3636
get() = runtime?.agents
3737

38+
val scene: CognitiveSceneRuntime?
39+
get() = runtime
40+
3841
fun update(
3942
width: Int,
4043
height: Int,

0 commit comments

Comments
 (0)