Vancetope — User Progress Channel
Live status for the user while Engines are working. One message class
PROCESS_PROGRESSwith three payload variants, separate from the authoritative chat stream. Ephemeral, not in Conversation History.
Supplement to: client-protocol-extensibility websocket-protocol arthur-engine eddie-engine
1. Rationale
Currently, the user sees nothing during Engine work:
- Tool-Calls (Web-Search, File-Write,
process_create) run as a black box — the user only gets the result after the Lane-Turn ends. - Sub-Engine-Spawn (Eddie spawns Arthur) is invisible to the user until the
ProcessEvent(DONE)returns viapendingMessages. Eddie waits on the Lane, the user waits for Eddie. - Token consumption and LLM roundtrip count are only in the TRACE log (
AiTraceLogger), not visible to the user. - Plan-driven Engines (Marvin’s Task-Tree, Vogon’s Phasen-Gate) track their progress internally — the user only sees the final summary.
The existing chat path (CHAT_MESSAGE_STREAM_CHUNK → CHAT_MESSAGE_APPENDED) is authoritative: what passes through it is part of the Conversation History, goes back into the LLM context, and is persisted. Status pings, tool start markers, and token counters do not belong there — they would contaminate the context and make the chat unreadable.
Therefore: a dedicated side-channel with its own message class, without context feedback, without persistence.
Definition of terms: “Feedback” describes the requirement (“the user needs feedback”). The solution to this requirement is called progress in the system — an ongoing progress channel, both technically and semantically.
2. One Message, Three Variants
| Variant | Frequency | Content | Producer |
|---|---|---|---|
metrics |
high (per LLM roundtrip) | numerical: Tokens, LLM-Calls, Wall-Clock | centrally in the AI-Layer |
plan |
low (on plan mutation) | structured: Tree / Phase snapshot | engine-specific (Marvin, Vogon) |
status |
medium (Tool boundaries, status asides) | free text + Tag | Tool-Layer + Engine |
One MessageType.PROCESS_PROGRESS, an envelope with source and kind, three optional payload fields. The client distinguishes by kind discriminator and renders accordingly (Counter-HUD vs. Plan-Panel vs. Toast-Stream).
Advantages over three separate message types:
- Unified routing/filtering in the client (one subscribe point instead of three).
sourceblock is defined exactly once and sent with every message.- Future variants (e.g.,
quota_warn,model_fallback) are additive without a new top-level type.
3. Envelope
Every PROCESS_PROGRESS message always carries the source block. In a session, the user often sees multiple Processes in parallel (Eddie + spawned Arthur), and the client must be able to attribute which update was produced by whom.
ProcessProgressNotification {
// === Source (Mandatory) ===
processId String // Mongo ObjectId — unique
processName String // technical name (e.g., "security_audit")
processTitle String? // Display name (e.g., "Security Audit Q2")
engine String // "arthur" | "eddie" | "marvin" | "vogon" | ...
sessionId String // Routing key for ClientEventPublisher
parentProcessId String? // if sub-Process — for hierarchy rendering
// === Discriminator ===
kind ProgressKind // METRICS | PLAN | STATUS
// === Payload (exactly one set, depending on kind) ===
metrics MetricsPayload?
plan PlanPayload?
status StatusPayload?
// === Telemetry ===
emittedAt Instant // Server timestamp
}
enum ProgressKind { METRICS, PLAN, STATUS }
processName/processTitle/engine are redundant to the processId lookup but are included so that the client can render without an additional REST call (even for late-join, before it has the process list).
4. METRICS Variant
Trigger
A push per LLM roundtrip, triggered in the AI-Layer (hook in / after AiTraceLogger). Values are maintained on the ThinkProcessDocument anyway (for later quota evaluation); metrics is just the push event on update.
Payload
MetricsPayload {
tokensInTotal long // cumulative since Process start
tokensOutTotal long // cumulative
llmCallCount int // cumulative
elapsedMs long // cumulative (wall-clock sum of roundtrips)
modelAlias String? // which model the last call used
lastCallTokensIn int? // Delta of the last call (for incremental UI)
lastCallTokensOut int?
}
Rendering (Recommendation)
CLI / Web shows a compact status indicator (“Iron-Man-HUD”): arthur · 4 calls · 12k in / 3k out · 8.2s. Updates replace the indicator, no scroll.
5. PLAN Variant
Optional and engine-specific. Engines without a plan (Arthur, Eddie) do not emit this.
Which Engines
- Marvin — Task-Tree (PLAN, WORKER, USER_INPUT, AGGREGATE), pre-order DFS
- Vogon — Phases with Gates / Checkpoints / Loops / Forks
Payload
PlanPayload {
rootNode PlanNode
}
PlanNode {
id String // engine-internal, stable
kind String // engine-specific ("plan", "worker", "phase", "gate")
title String // Display
status String // "pending" | "running" | "done" | "failed" | "blocked"
children List<PlanNode>?
meta Map<String, Object>? // engine-specific (Phase index, Loop iteration, …)
}
Frequency
Snapshot push on every plan mutation (node created, status change, node completed). No diff protocol in v1 — the entire tree is sent. For Marvin with hundreds of nodes, optimization is possible later (patch format), but not now.
Persistence
The plan itself is persisted (Marvin’s taskTreeNodes in Mongo, Vogon’s phase status on the Engine-Doc). The PROCESS_PROGRESS message is only the push that something has changed — late-joiners can reload the current state via REST.
6. STATUS Variant
Payload
StatusPayload {
tag StatusTag // see below
text String // Recommendation: ≤ 120 characters, no hard limit
detail String? // optional, longer context (tooltip / click-to-expand)
operationId String? // correlates _START ↔ _END / NODE_DONE / PHASE_DONE
usage UsageDelta? // only filled for "done" tags (see §6a)
}
enum StatusTag {
TOOL_START, // "Calling tool: web_search"
TOOL_END, // "Tool web_search done (3 results)"
SEARCH, // "Searching for: 'iron man helmet specs'"
FETCH, // "Fetching: https://example.com/article"
FILE_WRITE, // "Writing file: /workspace/notes.md"
FILE_READ,
DELEGATING, // "Spawning sub-process: arthur (security_audit)"
WAITING, // "Waiting for user input on inbox item #42"
NODE_DONE, // Marvin: Task-Tree node completed (with usage)
PHASE_DONE, // Vogon: Phase completed (with usage)
SCRIPT_PROGRESS, // explicit `vance.process.progress(...)` from the script (Hactar)
INFO // catch-all for Engine-specific status asides
}
UsageDelta {
tokensIn int // Delta of this operation
tokensOut int
llmCalls int // 0 for purely local tools (no LLM involved)
elapsedMs long // measured server-side — operation duration
modelAlias String? // dominant model alias of the operation
costMicros long? // 1/1_000_000 EUR, optional (from ai-models.yaml)
}
Important
- Not in the Conversation History. The text does NOT flow into
pendingMessages, NOT into the LLM context on the next roundtrip. Pure side-channel push. - Ephemeral. Whoever misses the moment does not see it. No Mongo persist, no reconnect replay. Past status is irrelevant —
statusis a real-time ping, not an audit log. - Soft-Limit 120 characters. Recommendation in the Engine implementation; no server-side cut. A hard 140-character cut led to cryptic abbreviations, which we want to avoid.
usageis optional. Server effort per operation is a subtraction from the Process totals — no additional measurement. If the values are unknown or uninteresting (e.g., a pure filesystem tool), omit the field. Themetricsvariant remains the high-frequency push per LLM roundtrip;status.usageis the less frequent “stage completed” view.
Tool Reporting in Detail
Local Tools (executed in vance-brain — web_search, process_create, process_steer, etc.):
- A wrapper / listener on the Tool-Executor (analogous to
ArthurEngine.invokeOne) emitsstatus(TOOL_START)before execution,status(TOOL_END)afterwards. ForTOOL_END,usageis calculated from the token delta of the Process totals since the start (see §6a) — for tools with nested LLM calls (Sub-Engines, Summarize, RAG), these are the true costs of this tool; for pure FS tools,tokensIn=tokensOut=llmCalls=0withelapsedMsfilled. - Tool implementation can additionally send engine-specific pings (e.g.,
web_searchpushesstatus(SEARCH, query)with the specific query string, not just the tool name).
Workspace Tools (executed in the client via CLIENT_TOOL_INVOKE — file operations on the user’s machine):
- Brain pushes the
status(TOOL_START)ping as soon as it sends out theCLIENT_TOOL_INVOKE. Rationale: unified code path, the client does not need its own status logic. The cost is an additional WS message — negligible. - On
CLIENT_TOOL_RESULTreturn:status(TOOL_END).usage.elapsedMsis the Brain’s internal work (sending until result) — not the client-side wall-clock.
6a. Operation Lifecycle
operationId links a _START ping to its _END ping. This ensures:
- Tool traces do not get mixed up during parallel operations (Eddie starts three sub-tools sequentially, the TOOL_END pings can arrive in any order);
- The client can measure end-to-end wall-clock time (see below);
- The client can render a spinner per operation (multiple simultaneously, each with its own label).
Assignment
ProgressEmitter assigns a ULID/UUID when creating the _START variant and returns it to the caller. The caller (Tool wrapper, Engine node code) carries the ID until the _END call. For CLIENT_TOOL_INVOKE, the ID is also passed to the client via the CLIENT_TOOL_INVOKE message — the returning CLIENT_TOOL_RESULT carries it so that the Brain can construct the corresponding TOOL_END ping.
Which Tags Open / Close
| Open | Close | Producer |
|---|---|---|
TOOL_START |
TOOL_END |
Tool-Executor-Decorator (Brain) |
DELEGATING |
TOOL_END (or own NODE_DONE) |
Eddie / Marvin (Sub-Process-Spawn) |
(Node status running) |
NODE_DONE |
Marvin (Node transition done/failed) |
(Phase start INFO) |
PHASE_DONE |
Vogon (Phase change) |
SEARCH, FETCH, FILE_*, WAITING, SCRIPT_PROGRESS, INFO have no lifecycle counterpart — they are single pings without operationId. If a lifecycle is needed, use TOOL_START/TOOL_END and describe the tool in text.
Token Delta Calculation (Server)
The Tool-Executor-Decorator takes a snapshot of the Process totals (tokensInTotal, tokensOutTotal, llmCallCount from ThinkProcessDocument — same source as metrics variant) before the tool call. After the tool ends, the difference is calculated and attached to UsageDelta. This ensures that LLM calls that occurred within a sub-engine tool are also correctly counted towards the calling operation.
Marvin and Vogon nodes maintain the same snapshot upon node entry and calculate it for the NODE_DONE/PHASE_DONE ping. Sub-node subtotals roll up via the respective Engine’s tree aggregation — the spec there is authoritative.
Wall-Clock in the Client
The client measures wall-clock itself from _START to _END arrival, keyed by operationId. This is the latency the user feels (including WS roundtrip, render delay). Server usage.elapsedMs shows the Brain’s internal work — both values are useful, but the client primarily renders its own measurement. In verbose mode, it can additionally show the server time (2.8s · brain 2.4s).
Implementation v1 (vance-foot): Map<operationId, Instant started> is populated on _START; on _END, the difference is rendered, and the entry expires after a short fade (~5 s). The spinner repaint loop runs only as long as the map is not empty.
7. Transport — Side-Channel via ClientEventPublisher
Existing infrastructure is sufficient, no new components needed:
Engine / AI-Layer / Tool-Wrapper
│
▼
ProgressEmitter.emit(notification) // thin wrapper, sets source block
│
▼
ClientEventPublisher.publish(sessionId, notification)
│
├─► SessionConnectionRegistry.find(sessionId)
│ │
│ └─► (no client connected) → silent drop
│
└─► WebSocketSender.sendNotification(connection, message) // non-blocking
- Asynchronous (non-blocking) — Engine/Lane-Turn is not blocked.
- Silent-Drop on missing connection — pings are ephemeral, no buffering.
ProgressEmitteris the only caller forPROCESS_PROGRESS. Responsibility: populate the source block (processId,processName,processTitle,engine,sessionId,parentProcessId) based on the current Engine context. Engines/Tools only build the payload; the envelope is central. This ensures we never see aPROCESS_PROGRESSmessage without a source.- Multi-Pod-Routing: as long as
SessionConnectionRegistryis single-pod, cross-pod pings are lost. If Eddie runs on Pod A and the user is connected to Pod B, the registry lookup requires a cluster backplane (Redis-Pub/Sub or similar) — scaling followup, not v1.
New MessageType Entry
In vance-api/src/main/java/de/mhus/vance/api/ws/MessageType.java:
PROCESS_PROGRESS— Server→Client, without request correlation
Exactly one, not three.
8. Eddie & Sub-Engine Topology
Critical Use Case:
User ──"analyze this"──▶ Eddie ──spawn──▶ Arthur (Sub-Process)
│
waits for ProcessEvent(DONE)
via pendingMessages-Lane
Today: While Arthur works (minutes range), the user sees nothing. Eddie is parked on its Lane, Arthur communicates “upwards” via Mongo messages.
Solution: Arthur (and any Sub-Engine) pushes PROCESS_PROGRESS messages directly to the sessionId via ProgressEmitter/ClientEventPublisher, not via the Eddie Lane. Eddie does not hear this — the user does. parentProcessId in the envelope makes the hierarchy visible to the client.
Arthur ──PROCESS_PROGRESS (source=arthur, parent=eddie)──▶ User-WS
│
└──ProcessEvent(DONE)─────────────────────────────────▶ Eddie's pendingMessages
(authoritative, Lane-Turn)
Separation:
- Side-Channel (
PROCESS_PROGRESS): direct to session, ephemeral, for user visibility. - Lane-Channel (
pendingMessages/ProcessEvent): Engine-to-Engine, persistent, authoritative.
Eddie reacts to DONE on the next Lane-Turn and produces its user-facing response as a regular chat stream. The side-channel has no influence on this.
The client can decide, based on parentProcessId, whether to render sub-process updates indented under the parent or as a standalone HUD entry — a rendering question, not a protocol question.
9. Configuration
Per Process via Recipe parameter (see recipes):
params:
progress: normal # off | normal | verbose
off— nometrics, nostatus,planstill included (plan is structurally important)normal—metricsaggregated,statusfor Tool boundaries and explicitSCRIPT_PROGRESSfrom scriptsverbose— additionally Engine-specificINFOpings, every Tool detail
SCRIPT_PROGRESS is intentionally not INFO: an explicit vance.process.progress(...) call in the script is intended by the script author — it does not belong in the filterable Engine-aside bucket.
Default: normal. User-global override later via Settings (ux.progress.default), not v1.
10. v1 Scope
Included:
MessageType.PROCESS_PROGRESS+ Envelope-DTO + three payload classes +UsageDeltainvance-apiProgressEmitterservice invance-brain(central source block filler, assignsoperationIdfor_STARTpings)- AI-Layer hook for
metrics(coupled toAiTraceLoggerextension) - Tool-Executor-Decorator for
status(TOOL_START/END)with token delta snapshot →usageonTOOL_END CLIENT_TOOL_INVOKEpath emitsstatus(TOOL_START)on sending,operationIdtravels viaCLIENT_TOOL_INVOKE/CLIENT_TOOL_RESULT- Marvin & Vogon emit
planon mutation, plusstatus(NODE_DONE)/status(PHASE_DONE)withusageat node/phase end - Arthur emits
statusfor Tool boundaries and Sub-Process spawn - Eddie emits
status(DELEGATING)on Sub-Engine spawn (paired withTOOL_ENDon Sub-Process result) - vance-foot renders all three variants (HUD line, Plan panel, Toast stream) and measures wall-clock per
operationIdclient-side params.progresshonored
Excluded:
- Web-UI rendering (Plan-Panel component, HUD widget) — followup with Web-UI editor work
- Cross-Pod routing via cluster backplane
- Plan diff protocol (full snapshots are sufficient)
- Persistence of
statushistory (ephemeral by definition) - Late-join replay (reconnect shows current
metrics/planstate via REST-pull, no event replay) - Per-user global default setting (
paramsoverride is sufficient for v1)
11. Implementation Steps
MessageType.PROCESS_PROGRESS+ Envelope-DTO (ProcessProgressNotification) + Enums (ProgressKind,StatusTag) + three payload classes (MetricsPayload,PlanPayload,StatusPayload) +UsageDelta+PlanNodeinvance-api/ws. All with@GenerateTypeScriptfor Web-UI.ProgressEmitterinvance-brain/events: central service that populates the source block fromThinkProcessDocument/Engine context and forwards it toClientEventPublisher. Methods:emitMetrics(...),emitPlan(...),emitStatus(...)plusopenOperation(tag, text)(assignsoperationId, pushes_START) /closeOperation(operationId, tag, text, usage)(pushes_ENDvariant withusagefilled).- AI-Layer hook (on
AiTraceLoggerorChatResponsewrapper): accumulates tokens on the Process-Doc, callsProgressEmitter.emitMetrics(...)after each roundtrip. Token totals on theThinkProcessDocumentare the source for theUsageDeltasnapshots in §6a. ToolExecutionInterceptorinvance-brain: Decorator around the Tool-Executor path (Arthur’sinvokeOne). Before execution:openOperation(TOOL_START, …)+ snapshot (tokensInTotal,tokensOutTotal,llmCallCount,Instant.now()). After execution: calculate difference,closeOperation(operationId, TOOL_END, …, usage). For pure FS tools, token fields are 0 —elapsedMsremains filled.CLIENT_TOOL_INVOKEpath: Brain pushesstatus(TOOL_START)on sending, attachesoperationIdto theCLIENT_TOOL_INVOKEmessage; onCLIENT_TOOL_RESULTreturn, the Brain constructs theTOOL_ENDping withusage(elapsedMs= Brain roundtrip time).- Marvin & Vogon:
emitPlanSnapshot()at mutation points, callsProgressEmitter.emitPlan(...)with the current tree. On node entry, hold a snapshot of the Process totals; on node transition todone/failed,closeOperation(NODE_DONE, …, usage)(Marvin) orPHASE_DONE(Vogon). params.progressinRecipeResolveras a default parameter;ProgressEmitterfilters before push (no constant check in every Engine).- vance-foot: three renderers (HUD line with
\r-overwrite, Plan panel via Lanterna, Toast stream as ANSI dim lines below the chat) — all subscribe to the samePROCESS_PROGRESSpoint, dispatch bykind. For eachoperationId,Instant startedis held locally; on_END, wall-clock difference is rendered (spinner tick ~250 ms, runs only if the operation map is not empty).