Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
package com.jorisjonkers.personalstack.agentgateway.tmux

import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties
import org.slf4j.LoggerFactory
import java.nio.file.Path
import java.util.UUID

internal class AgentCommandFactory(
private val props: GatewayProperties,
private val claudeTranscriptLocator: ClaudeTranscriptLocator,
) {
private val log = LoggerFactory.getLogger(AgentCommandFactory::class.java)

/**
* Build the CLI command and return the native session id alongside it.
*
* Claude gets an explicit `--session-id` on fresh starts and `--resume`
* only when the matching local transcript still exists. Codex runs under
* an isolated CODEX_HOME so fresh rollout capture has no cross-session
* ambiguity; shell has no native session id.
*/
fun commandAndSessionIdFor(
kind: AgentKind,
cwd: String,
resumeCliSessionId: String? = null,
codexHome: Path? = null,
): AgentCommand =
when (kind) {
AgentKind.CLAUDE -> claudeCommand(cwd, resumeCliSessionId)
AgentKind.CODEX -> codexCommand(cwd, resumeCliSessionId, codexHome)
AgentKind.SHELL -> AgentCommand(command = listOf("/bin/bash", "-l"), cliSessionId = null, cwd = cwd)
}

private fun claudeCommand(
cwd: String,
resumeCliSessionId: String?,
): AgentCommand {
val cliSessionId = resumeCliSessionId ?: UUID.randomUUID().toString()
val transcript = resumeCliSessionId?.let { claudeTranscriptLocator.findTranscript(cwd, it) }
val sessionArgs =
if (resumeCliSessionId == null) {
listOf("--session-id", cliSessionId)
} else if (transcript != null) {
log.info(
"claude revival selected resume sessionId={} reason=transcript-exists " +
"requestedCwd={} transcriptCwd={}",
resumeCliSessionId,
cwd,
transcript.cwd,
)
listOf("--resume", resumeCliSessionId)
} else {
log.info(
"claude revival selected fresh-with-stable-id sessionId={} reason=transcript-missing",
resumeCliSessionId,
)
listOf("--session-id", resumeCliSessionId)
}
return AgentCommand(
command = listOf(props.cli.claude) + props.cli.claudeArgs + sessionArgs,
cliSessionId = cliSessionId,
cwd = transcript?.cwd ?: cwd,
)
}

private fun codexCommand(
cwd: String,
resumeCliSessionId: String?,
codexHome: Path?,
): AgentCommand {
val envPrefix = codexHome?.let { listOf("env", "CODEX_HOME=$it") }.orEmpty()
val resumeArgs = resumeCliSessionId?.let { listOf("resume", it) }.orEmpty()
return AgentCommand(
command = envPrefix + listOf(props.cli.codex) + props.cli.codexArgs + resumeArgs,
cliSessionId = resumeCliSessionId,
cwd = cwd,
)
}
}

internal data class AgentCommand(
val command: List<String>,
val cliSessionId: String?,
val cwd: String,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
package com.jorisjonkers.personalstack.agentgateway.tmux

import com.jorisjonkers.personalstack.agentgateway.config.GatewayProperties
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayAgentKindLabel
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayFailureReasonLabel
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayOperationLabel
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayOutcomeLabel
import io.micrometer.observation.Observation
import org.slf4j.LoggerFactory
import java.nio.file.Files
import java.nio.file.StandardOpenOption
import java.time.Instant
import java.time.ZoneOffset
import java.time.format.DateTimeFormatter
import java.util.UUID
import kotlin.io.path.Path

internal class AgentSessionControls(
private val tmux: TmuxClient,
private val props: GatewayProperties,
private val registry: AgentSessionRegistry,
private val telemetry: AgentSessionTelemetry,
) : AgentSessionControlOperations {
private val log = LoggerFactory.getLogger(AgentSessionControls::class.java)

override fun send(
id: String,
input: String,
enter: Boolean,
) = withSessionOperation(id, GatewayOperationLabel.INPUT, observed = true) { session ->
tmux.sendKeys(session.tmuxSession, input, enter = enter)
}

override fun stageInput(
id: String,
content: String,
requestedName: String?,
): StagedInput =
withSessionOperation(id, GatewayOperationLabel.INPUT, observed = false) { session ->
val bytes = content.toByteArray(Charsets.UTF_8)
require(bytes.isNotEmpty()) { "staged input content is empty" }
require(bytes.size.toLong() <= props.stagedInputs.maxBytes) {
"staged input exceeds ${props.stagedInputs.maxBytes} bytes"
}

val root = Path(session.cwd).toAbsolutePath().normalize()
val dir = root.resolve(props.stagedInputs.dirName).normalize()
require(dir.startsWith(root)) { "staged input directory must stay inside the workspace" }

Files.createDirectories(dir)
val safeName = safeFileName(requestedName)
val fileName = "${timestamp()}-${UUID.randomUUID().toString().take(ID_PREVIEW_CHARS)}-$safeName"
val target = dir.resolve(fileName).normalize()
require(target.startsWith(dir)) { "staged input path must stay inside the staging directory" }

Files.write(target, bytes, StandardOpenOption.CREATE_NEW, StandardOpenOption.WRITE)
log.info("staged {} bytes for agent {} at {}", bytes.size, id, target)
StagedInput(path = target.toString(), bytes = bytes.size.toLong(), name = safeName)
}

override fun capture(
id: String,
historyLines: Int,
): String =
withSessionOperation(id, GatewayOperationLabel.REPLAY, observed = false) { session ->
tmux.capture(session.tmuxSession, historyLines)
}

override fun captureWithEscapes(id: String): String =
withSessionOperation(id, GatewayOperationLabel.REPLAY, observed = false) { session ->
tmux.captureWithEscapes(session.tmuxSession)
}

override fun resize(
id: String,
cols: Int,
rows: Int,
) = withSessionOperation(id, GatewayOperationLabel.RESIZE, observed = true) { session ->
tmux.resize(session.tmuxSession, cols, rows)
}

private fun <T> withSessionOperation(
id: String,
operation: GatewayOperationLabel,
observed: Boolean,
block: (AgentSession) -> T,
): T {
val startedAt = Instant.now()
var kind = GatewayAgentKindLabel.OTHER
var outcome = GatewayOutcomeLabel.SUCCESS
var reason = GatewayFailureReasonLabel.NONE
var failure: Throwable? = null
var observation: Observation? = null
try {
return runCatching {
val session = registry.get(id) ?: error("unknown agent: $id")
kind = with(telemetry) { session.kind.toTelemetryKind() }
if (observed) observation = telemetry.startSessionObservation(operation, kind)
block(session)
}.getOrElse { error ->
outcome = GatewayOutcomeLabel.FAILURE
reason = telemetry.failureReasonLabel(error)
failure = error
throw error
}
} finally {
telemetry.recordSessionOperation(
SessionOperationRecord(
operation = operation,
kind = kind,
outcome = outcome,
reason = reason,
startedAt = startedAt,
observed = observed,
observation = observation,
error = failure,
),
)
}
}

private fun safeFileName(requestedName: String?): String {
val raw = requestedName?.trim()?.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME
val leaf = raw.replace('\\', '/').substringAfterLast('/')
val safe =
SAFE_NAME_CHARS
.replace(leaf, "-")
.trim('.', '-', '_')
.take(MAX_STAGED_INPUT_NAME_CHARS)
return safe.takeIf { it.isNotBlank() } ?: DEFAULT_STAGED_INPUT_NAME
}

private fun timestamp(): String = STAGED_INPUT_TIMESTAMP.format(Instant.now())

private companion object {
const val DEFAULT_STAGED_INPUT_NAME = "input.txt"
const val ID_PREVIEW_CHARS = 8
const val MAX_STAGED_INPUT_NAME_CHARS = 80
val SAFE_NAME_CHARS = Regex("[^A-Za-z0-9._-]+")
val STAGED_INPUT_TIMESTAMP: DateTimeFormatter =
DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS").withZone(ZoneOffset.UTC)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package com.jorisjonkers.personalstack.agentgateway.tmux

import com.jorisjonkers.personalstack.agentgateway.observability.GatewayAgentKindLabel
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayFailureReasonLabel
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayOperationLabel
import com.jorisjonkers.personalstack.agentgateway.observability.GatewayOutcomeLabel
import io.micrometer.observation.Observation
import org.slf4j.LoggerFactory
import java.time.Instant

internal class AgentSessionLifecycle(
private val tmux: TmuxClient,
private val transcriptStore: TranscriptStore,
private val registry: AgentSessionRegistry,
private val telemetry: AgentSessionTelemetry,
) : AgentSessionLifecycleOperations {
private val log = LoggerFactory.getLogger(AgentSessionLifecycle::class.java)

override fun stop(id: String): Boolean {
val startedAt = Instant.now()
var kind = GatewayAgentKindLabel.OTHER
var outcome = GatewayOutcomeLabel.FAILURE
var reason = GatewayFailureReasonLabel.NOT_FOUND
var failure: Throwable? = null
var observation: Observation? = null
try {
return runCatching {
val session = registry.remove(id) ?: return@runCatching false
kind = with(telemetry) { session.kind.toTelemetryKind() }
observation = telemetry.startSessionObservation(GatewayOperationLabel.STOP, kind)
tmux.killSession(session.tmuxSession)
session.stableSessionId?.let {
transcriptStore.seal(it)
session.transcriptLease?.let(transcriptStore::releaseLease)
}
outcome = GatewayOutcomeLabel.SUCCESS
reason = GatewayFailureReasonLabel.NONE
log.info("stopped agent {}", id)
true
}.getOrElse { error ->
outcome = GatewayOutcomeLabel.FAILURE
reason = telemetry.failureReasonLabel(error)
failure = error
throw error
}
} finally {
telemetry.recordActiveSessionCounts()
telemetry.recordSessionOperation(
SessionOperationRecord(
operation = GatewayOperationLabel.STOP,
kind = kind,
outcome = outcome,
reason = reason,
startedAt = startedAt,
observation = observation,
error = failure,
),
)
}
}

fun stopAll(onFailure: (String, Throwable) -> Unit) {
registry.ids().forEach { id ->
runCatching { stop(id) }.onFailure { onFailure(id, it) }
}
}
}
Loading
Loading