Building StareBrain, Part 20: Session Handoff — Resuming Mid-Task Without Repeating Yourself
Parts 1–19 built the execution pipeline, the overlay UI, and the input layer. Today we close the loop on something that affects every session: what happens when the user closes and reopens StareBrain mid-task.
The goal is simple to state and hard to get right — the user should be able to pick up exactly where they left off, without being asked to repeat anything.
What Needs to Survive a Session Close
Three things matter for resumption:
- The task itself — which steps completed, which failed, where the cursor is
- The agent context — what app was active, what the user was doing, what confidence level was inferred
- The user's last intent — the instruction that started the task
If any of these is missing on reopen, the agent either has to ask the user to repeat themselves or makes a guess it shouldn't be making silently.
Persisting AgentTask
AgentTask is serialized to disk when the task enters IN_PROGRESS or AWAITING_CONFIRMATION, and updated after every step:
class TaskPersistence(private val context: Context) {
private val file = File(context.filesDir, "pending_task.json")
private val gson = Gson()
fun save(task: AgentTask) {
file.writeText(gson.toJson(task))
}
fun load(): AgentTask? {
if (!file.exists()) return null
return try {
gson.fromJson(file.readText(), AgentTask::class.java)
} catch (e: Exception) {
null
}
}
fun clear() {
file.delete()
}
}
We write after every step completion, not just at close — if the process is killed mid-step, we recover from the last completed step rather than the start.
Persisting AgentContext
AgentContext is also serialized, but with one modification: confidenceLevel is downgraded to LOW on save, because we can't know how much time will pass before the user reopens:
fun AgentContext.forPersistence(): AgentContext {
return copy(
confidenceLevel = ContextConfidence.LOW,
capturedAt = System.currentTimeMillis()
)
}
On load, isStale() will check the timestamp. If the gap was more than 5 minutes, the context is treated as stale regardless — the downgrade to LOW is a belt-and-suspenders measure.
The Handoff on Reopen
In StareBrainService.onServiceConnected(), we check for a persisted task before doing anything else:
override fun onServiceConnected() {
overlayManager.show()
val pendingTask = taskPersistence.load()
if (pendingTask != null) {
resumeFromPersisted(pendingTask)
} else {
observeTaskState()
}
}
private fun resumeFromPersisted(task: AgentTask) {
val restoredContext = contextPersistence.load()
val effectiveContext = restoredContext
?.forPersistence()
?.let { if (it.isStale()) it.copy(confidenceLevel = ContextConfidence.LOW) else it }
?: AgentContext.empty()
when (task.status) {
TaskStatus.AWAITING_CONFIRMATION -> {
// Surface the confirmation prompt immediately
val step = task.steps[task.currentStepIndex]
overlayManager.enterConfirmationMode(
message = "Resume: confirm ${step.instruction}?",
onApprove = { lifecycleScope.launch { taskExecutor.resume(task, true) } },
onReject = { lifecycleScope.launch { taskExecutor.resume(task, false) } }
)
}
TaskStatus.IN_PROGRESS -> {
// Resume from the current step
lifecycleScope.launch {
taskExecutor.resumeFromTask(task, effectiveContext)
}
}
TaskStatus.COMPLETED, TaskStatus.FAILED -> {
// Task finished before close — clear and start fresh
taskPersistence.clear()
observeTaskState()
}
else -> observeTaskState()
}
}
What the User Sees
For AWAITING_CONFIRMATION: the confirmation prompt reappears immediately on open. The user sees "Resume: confirm X?" — they don't need to remember what they were doing.
For IN_PROGRESS: the overlay shows the running state with the task description and current step. The task continues from the last completed step without asking anything.
For a stale context: the step executes, but the agent treats its environmental assumptions as low-confidence and will replan rather than proceed blindly if the screen state doesn't match expectations.
Clearing State
Task state is cleared in three cases:
// 1. Task completed
TaskStatus.COMPLETED -> {
taskPersistence.clear()
contextPersistence.clear()
}
// 2. Task failed with no replan available
TaskStatus.FAILED -> {
taskPersistence.clear()
contextPersistence.clear()
}
// 3. User explicitly cancels
fun cancelCurrentTask() {
taskExecutor.cancel()
taskPersistence.clear()
contextPersistence.clear()
overlayManager.setState(OverlayState.Idle)
}
We never clear on close — only on resolution. Close is just a pause.
What's Next
Part 21 will cover the natural language planning layer: how StareBrain takes a raw user instruction ("order the same thing I got last time") and breaks it into an AgentTask with concrete AgentSteps — before any execution begins.
Top comments (0)