diff --git a/.agents/skills/add-public-service/agents/openai.yaml b/.agents/skills/add-public-service/agents/openai.yaml
index 9745ec16..8084bcff 100644
--- a/.agents/skills/add-public-service/agents/openai.yaml
+++ b/.agents/skills/add-public-service/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "Add Public Service"
- short_description: "Add stack public services safely"
- default_prompt: "Use $add-public-service to add a new SSO-protected public app."
+ display_name: 'Add Public Service'
+ short_description: 'Add stack public services safely'
+ default_prompt: 'Use $add-public-service to add a new SSO-protected public app.'
diff --git a/.agents/skills/agent-session-bootstrap/agents/openai.yaml b/.agents/skills/agent-session-bootstrap/agents/openai.yaml
index 15d36838..23a7f072 100644
--- a/.agents/skills/agent-session-bootstrap/agents/openai.yaml
+++ b/.agents/skills/agent-session-bootstrap/agents/openai.yaml
@@ -1,11 +1,11 @@
interface:
- display_name: "Agent Session Bootstrap"
- short_description: "Bootstrap durable agent context"
- default_prompt: "Use $agent-session-bootstrap to set up agent hooks, skills, and durable instructions."
+ display_name: 'Agent Session Bootstrap'
+ short_description: 'Bootstrap durable agent context'
+ default_prompt: 'Use $agent-session-bootstrap to set up agent hooks, skills, and durable instructions.'
dependencies:
tools:
- - type: "mcp"
- value: "knowledge"
- description: "Knowledge-base MCP server"
- transport: "streamable_http"
- url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp"
+ - type: 'mcp'
+ value: 'knowledge'
+ description: 'Knowledge-base MCP server'
+ transport: 'streamable_http'
+ url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp'
diff --git a/.agents/skills/fleet-change/agents/openai.yaml b/.agents/skills/fleet-change/agents/openai.yaml
index 4fab0fad..1ce188f2 100644
--- a/.agents/skills/fleet-change/agents/openai.yaml
+++ b/.agents/skills/fleet-change/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "Fleet Change"
- short_description: "Render and validate fleet edits"
- default_prompt: "Use $fleet-change to make this fleet.yaml update safely."
+ display_name: 'Fleet Change'
+ short_description: 'Render and validate fleet edits'
+ default_prompt: 'Use $fleet-change to make this fleet.yaml update safely.'
diff --git a/.agents/skills/kb-first/agents/openai.yaml b/.agents/skills/kb-first/agents/openai.yaml
index 0b161efe..95344b38 100644
--- a/.agents/skills/kb-first/agents/openai.yaml
+++ b/.agents/skills/kb-first/agents/openai.yaml
@@ -1,11 +1,11 @@
interface:
- display_name: "KB First"
- short_description: "Recall and capture KB context"
- default_prompt: "Use $kb-first to gather compact KB context before this task."
+ display_name: 'KB First'
+ short_description: 'Recall and capture KB context'
+ default_prompt: 'Use $kb-first to gather compact KB context before this task.'
dependencies:
tools:
- - type: "mcp"
- value: "knowledge"
- description: "Knowledge-base MCP server"
- transport: "streamable_http"
- url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp"
+ - type: 'mcp'
+ value: 'knowledge'
+ description: 'Knowledge-base MCP server'
+ transport: 'streamable_http'
+ url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp'
diff --git a/.agents/skills/open-pr/agents/openai.yaml b/.agents/skills/open-pr/agents/openai.yaml
index 58ecf2de..2d2fdba5 100644
--- a/.agents/skills/open-pr/agents/openai.yaml
+++ b/.agents/skills/open-pr/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "Open PR"
- short_description: "Create repo-standard pull requests"
- default_prompt: "Use $open-pr to open a personal-stack pull request."
+ display_name: 'Open PR'
+ short_description: 'Create repo-standard pull requests'
+ default_prompt: 'Use $open-pr to open a personal-stack pull request.'
diff --git a/.agents/skills/run-tests/agents/openai.yaml b/.agents/skills/run-tests/agents/openai.yaml
index 0decebe1..22d6d7b1 100644
--- a/.agents/skills/run-tests/agents/openai.yaml
+++ b/.agents/skills/run-tests/agents/openai.yaml
@@ -1,4 +1,4 @@
interface:
- display_name: "Run Tests"
- short_description: "Choose monorepo validation commands"
- default_prompt: "Use $run-tests to validate the files touched by this change."
+ display_name: 'Run Tests'
+ short_description: 'Choose monorepo validation commands'
+ default_prompt: 'Use $run-tests to validate the files touched by this change.'
diff --git a/.agents/skills/token-economy/agents/openai.yaml b/.agents/skills/token-economy/agents/openai.yaml
index 16e8c0f7..d2e98a71 100644
--- a/.agents/skills/token-economy/agents/openai.yaml
+++ b/.agents/skills/token-economy/agents/openai.yaml
@@ -1,11 +1,11 @@
interface:
- display_name: "Token Economy"
- short_description: "Reduce agent token usage cleanly"
- default_prompt: "Use $token-economy to keep this agent workflow token-efficient."
+ display_name: 'Token Economy'
+ short_description: 'Reduce agent token usage cleanly'
+ default_prompt: 'Use $token-economy to keep this agent workflow token-efficient.'
dependencies:
tools:
- - type: "mcp"
- value: "knowledge"
- description: "Knowledge-base MCP server"
- transport: "streamable_http"
- url: "http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp"
+ - type: 'mcp'
+ value: 'knowledge'
+ description: 'Knowledge-base MCP server'
+ transport: 'streamable_http'
+ url: 'http://knowledge-api.knowledge-system.svc.cluster.local:8080/mcp'
diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt
index 541f810b..cf6f0be1 100644
--- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt
+++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/config/GatewayProperties.kt
@@ -8,6 +8,7 @@ data class GatewayProperties(
val tmux: Tmux,
val cli: Cli,
val git: Git,
+ val stagedInputs: StagedInputs = StagedInputs(),
) {
data class Tmux(
val socketName: String,
@@ -36,4 +37,9 @@ data class GatewayProperties(
data class Git(
val deployKeyDir: String,
)
+
+ data class StagedInputs(
+ val dirName: String = ".agent-inputs",
+ val maxBytes: Long = 5L * 1024 * 1024,
+ )
}
diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt
index c3be986e..15ec6e91 100644
--- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt
+++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/AgentSessionManager.kt
@@ -9,6 +9,8 @@ import java.nio.file.Files
import java.nio.file.Path
import java.nio.file.StandardOpenOption
import java.time.Instant
+import java.time.ZoneOffset
+import java.time.format.DateTimeFormatter
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Executors
@@ -116,6 +118,33 @@ class AgentSessionManager(
tmux.sendKeys(session.tmuxSession, input, enter = enter)
}
+ fun stageInput(
+ id: String,
+ content: String,
+ requestedName: String?,
+ ): StagedInput {
+ val session = sessions[id] ?: error("unknown agent: $id")
+ 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)
+ return StagedInput(path = target.toString(), bytes = bytes.size.toLong(), name = safeName)
+ }
+
fun capture(
id: String,
historyLines: Int = 1_000,
@@ -190,8 +219,27 @@ class AgentSessionManager(
AgentKind.SHELL -> listOf("/bin/bash", "-l") to null
}
+ 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())
+
companion object {
/** Sentinel value for [resumeCliSessionId] that tells the gateway to run `codex resume --last`. */
const val CODEX_RESUME_LAST = "LATEST"
+ private const val DEFAULT_STAGED_INPUT_NAME = "input.txt"
+ private const val ID_PREVIEW_CHARS = 8
+ private const val MAX_STAGED_INPUT_NAME_CHARS = 80
+ private val SAFE_NAME_CHARS = Regex("[^A-Za-z0-9._-]+")
+ private val STAGED_INPUT_TIMESTAMP =
+ DateTimeFormatter.ofPattern("yyyyMMdd-HHmmss-SSS").withZone(ZoneOffset.UTC)
}
}
diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt
new file mode 100644
index 00000000..920101d3
--- /dev/null
+++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/tmux/StagedInput.kt
@@ -0,0 +1,7 @@
+package com.jorisjonkers.personalstack.agentgateway.tmux
+
+data class StagedInput(
+ val path: String,
+ val bytes: Long,
+ val name: String,
+)
diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt
index 10fa16e5..843d55b7 100644
--- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt
+++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/AgentController.kt
@@ -5,6 +5,8 @@ import com.jorisjonkers.personalstack.agentgateway.tmux.AgentSessionManager
import com.jorisjonkers.personalstack.agentgateway.web.dto.AgentResponse
import com.jorisjonkers.personalstack.agentgateway.web.dto.SendInputRequest
import com.jorisjonkers.personalstack.agentgateway.web.dto.SpawnAgentRequest
+import com.jorisjonkers.personalstack.agentgateway.web.dto.StageInputRequest
+import com.jorisjonkers.personalstack.agentgateway.web.dto.StagedInputResponse
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.DeleteMapping
@@ -47,6 +49,17 @@ class AgentController(
return ResponseEntity.accepted().build()
}
+ @PostMapping("/{id}/staged-inputs")
+ fun stageInput(
+ @PathVariable id: String,
+ @RequestBody req: StageInputRequest,
+ ): ResponseEntity {
+ val staged = sessions.stageInput(id, req.content, req.name)
+ return ResponseEntity
+ .status(HttpStatus.CREATED)
+ .body(StagedInputResponse(path = staged.path, bytes = staged.bytes, name = staged.name))
+ }
+
@GetMapping("/{id}/capture")
fun capture(
@PathVariable id: String,
diff --git a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt
index a3156f30..a96d4358 100644
--- a/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt
+++ b/services/agent-gateway/src/main/kotlin/com/jorisjonkers/personalstack/agentgateway/web/ErrorAdvice.kt
@@ -30,6 +30,10 @@ class ErrorAdvice {
fun onIllegalState(e: IllegalStateException): ResponseEntity
+
+
+
+
+