diff --git a/docs/tasks-and-automation.md b/docs/tasks-and-automation.md
index 11958ff..ccfaf38 100644
--- a/docs/tasks-and-automation.md
+++ b/docs/tasks-and-automation.md
@@ -13,6 +13,8 @@ Task IDs and records are actor/workspace/session scoped. The web task API expose
Simple work does not require a plan, and a plan is optional for medium work. Complex direct work uses a durable Working Plan with runtime-owned stable IDs and explicit acceptance criteria. The typed `update_plan` boundary applies revision-checked batches of add, update, cancel, reorder, dependency, split, merge, replace, block, and unblock operations; legacy `complexity`/`tasks` input is adapted through the same validation path. Splitting, merging, or replacing retains superseded tasks as cancelled history and records both directions of the relationship. Blocking requires a reason and prevents scheduling until explicitly unblocked with a reason. Each structural revision and its optional rationale are written atomically with a complete plan/task snapshot in the event history. A changed task must still be pending and unexecuted; executed task records and evidence are preserved. Scheduling follows plan order and waits for every dependency to succeed. A plan completes only when every task succeeds with acceptance evidence; recovery also reconciles a crash between the final task write and the plan status write. Clearing an active plan cancels its unfinished steps. The foreground scheduler runs consecutive calls concurrently only when every call is catalog-marked safe and read-only; all other calls preserve response order, and task receipts are reconciled in that order. Assistant answers remain available when a complex turn ends with unfinished work; its runtime outcome is reported separately as completed, incomplete, blocked, or cancelled. This outcome uses only that turn's Working Plan, so an unrelated task ledger cannot make a simple answer appear unfinished. The native foreground loop uses typed provider calls; the legacy text protocol remains available through its compatibility adapter.
+The TUI and Web chat show the current Working Plan revision, its user-facing revision reason, and tasks in scheduler order. Progress is updated from typed task events; successful tasks are shown as verified, and cancelled replacements remain marked as superseded. The Web view restores the active plan snapshot when a saved session is reopened. These observer projections contain task descriptions, statuses, supersession links, and rationale only; execution evidence and tool arguments remain private.
+
The tool executor makes at most one automatic retry, and only when a typed error marks a normal-risk read or an allowlisted built-in network lookup as retriable. It rechecks task binding and child cancellation before dispatching the retry. Filesystem, process, Git, stateful, custom network, and other mutation-capable calls are never automatically retried. After interruption or restart, unfinished side effects remain uncertain; inspect the current workspace and recorded receipts before requesting the mutation again.
Plan Mode can produce a versioned Strategic Plan before execution. Acceptance records the exact objectives, acceptance criteria, workspace paths, capabilities, effect categories, and risk ceiling alongside its linked Working Plan. A step may also carry a visible, fingerprinted `execution` contract for one exact `write_file`, `edit_file`, or `apply_patch` call; delegated file mutations require this explicit contract and still pass assigned-file, workspace-bound, and tool-approval checks. `apply_patch` requires the hash from a structured read and ordered `{old_text, new_text}` hunks, each matching exactly once without overlap. Each criterion should include a `verification` action and exact object arguments. A `read_file` verifier must also include one case-sensitive `expect` predicate (`equals` or nonempty `contains`); the returned text must satisfy it before the criterion is recorded. Plain-text criteria remain readable for legacy proposals but cannot satisfy the per-criterion completion gate without a verifier. Runtime tool dispatch checks each tool's catalog policy and supplied paths against those accepted bounds; shell processes are denied because this runtime cannot confine arbitrary commands to those paths. An unbounded legacy proposal receives read-only defaults. Strategic acceptance does not grant capabilities, bypass tool approvals, or establish completion evidence.
diff --git a/openkyrozen/agent/events.py b/openkyrozen/agent/events.py
index 5368813..31b310a 100644
--- a/openkyrozen/agent/events.py
+++ b/openkyrozen/agent/events.py
@@ -35,7 +35,19 @@ def runtime_event_as_legacy(event: "RuntimeEvent") -> dict[str, Any] | None:
if event.kind == "tool.result":
return {"event": "tool_receipt", "tool_receipt": payload}
if event.kind == "task.snapshot":
- return {"event": "tasks", "tasks": payload.get("tasks", [])}
+ return {"event": "tasks", "tasks": payload.get("tasks", []),
+ "working_plan": payload.get("working_plan")}
+ if event.kind.startswith("working_plan."):
+ return {"event": "working_plan", "session_id": event.session_id, "working_plan": payload}
+ if event.kind.startswith("task.") and event.task_id:
+ name = event.kind.partition(".")[2]
+ status = {"created": "pending", "claimed": "running", "recovered": "pending",
+ "resumed": "pending"}.get(name, name)
+ if status not in {"pending", "running", "succeeded", "failed", "blocked", "cancelled"}:
+ status = None
+ return {"event": "task_update", "task_id": event.task_id,
+ "session_id": event.session_id, "status": status, "description": payload.get("description"),
+ "reason": payload.get("reason", "")}
if event.kind == "interaction.updated":
return {"event": "interaction", "interaction": payload.get("interaction", payload)}
if event.kind == "security.permission_check":
@@ -199,6 +211,9 @@ def publish_legacy(self, legacy_event: dict[str, Any], *, model_request_id: str
from .types import _bounded_redacted
payload = _bounded_redacted(dict(fields.get("tool_receipt") or {}), budget=12_000)
legacy_event = {"event": "tool_receipt", "tool_receipt": payload}
+ elif name == "tasks":
+ legacy_event = {"event": "tasks", "tasks": fields.get("tasks", []),
+ "working_plan": fields.get("working_plan")}
call_id = payload.get("call_id") if isinstance(payload.get("call_id"), str) else None
task_id = payload.get("task_id") if isinstance(payload.get("task_id"), str) else None
event = self.publish(kind_map.get(name, f"legacy.{name}"), payload,
diff --git a/openkyrozen/agent/executor.py b/openkyrozen/agent/executor.py
index 8cfd211..dd9840f 100644
--- a/openkyrozen/agent/executor.py
+++ b/openkyrozen/agent/executor.py
@@ -885,7 +885,7 @@ def _record_turn_receipt(self, receipt: ExecutionReceipt, *, allow_legacy_task_m
self._emit_stream_event({"event": "tasks", "tasks": [
{"id": item["id"], "description": item["description"], "status": item["status"]}
for item in self.tasks.tasks
- ]})
+ ], "working_plan": self.tasks.working_plan_snapshot()})
return result
diff --git a/openkyrozen/interfaces/tui/commands.py b/openkyrozen/interfaces/tui/commands.py
index f46182c..a56053b 100644
--- a/openkyrozen/interfaces/tui/commands.py
+++ b/openkyrozen/interfaces/tui/commands.py
@@ -133,7 +133,7 @@ def _command(self, name: str, args: Any, request_id: str) -> None:
self.emit("tasks", request_id, tasks=[
{"id": task["id"], "description": task["description"], "status": task["status"]}
for task in runtime.tasks.tasks
- ])
+ ], working_plan=runtime.tasks.working_plan_snapshot())
self.emit("response", request_id, text=runtime.tasks.format())
elif command in {"/agents", "agents"}:
coordinator = runtime.delegation()
diff --git a/openkyrozen/interfaces/tui/navigation.py b/openkyrozen/interfaces/tui/navigation.py
index adb838b..f61e1f3 100644
--- a/openkyrozen/interfaces/tui/navigation.py
+++ b/openkyrozen/interfaces/tui/navigation.py
@@ -180,7 +180,7 @@ def _emit_bound_state(self, request_id: str) -> None:
self.emit("tasks", request_id, tasks=[
{"id": item["id"], "description": item["description"], "status": item["status"]}
for item in runtime.tasks.tasks
- ])
+ ], working_plan=runtime.tasks.working_plan_snapshot())
self.interaction(request_id)
self.graph_state(request_id)
self.emit("ready", request_id, configured=runtime.llm_provider is not None,
diff --git a/openkyrozen/interfaces/tui/onboarding.py b/openkyrozen/interfaces/tui/onboarding.py
index 1e32637..bd17cfd 100644
--- a/openkyrozen/interfaces/tui/onboarding.py
+++ b/openkyrozen/interfaces/tui/onboarding.py
@@ -56,7 +56,7 @@ def start(self, payload: dict[str, Any], request_id: str) -> None:
self.emit("tasks", request_id, tasks=[
{"id": item["id"], "description": item["description"], "status": item["status"]}
for item in runtime.tasks.tasks
- ])
+ ], working_plan=runtime.tasks.working_plan_snapshot())
self.emit(
"ready", request_id, configured=configured,
provider=getattr(config, "provider", ""),
diff --git a/openkyrozen/interfaces/tui/turns.py b/openkyrozen/interfaces/tui/turns.py
index bcab0f9..4e804ed 100644
--- a/openkyrozen/interfaces/tui/turns.py
+++ b/openkyrozen/interfaces/tui/turns.py
@@ -144,7 +144,15 @@ def callback(event: dict[str, Any] | RuntimeEvent) -> None:
elif kind == "tool_receipt":
self.emit("tool_receipt", request_id, receipt=event.get("tool_receipt", {}))
elif kind == "tasks":
- self.emit("tasks", request_id, tasks=event.get("tasks", []))
+ self.emit("tasks", request_id, tasks=event.get("tasks", []),
+ working_plan=event.get("working_plan"))
+ elif kind == "working_plan":
+ self.emit("working_plan", request_id, session_id=event.get("session_id"),
+ working_plan=event.get("working_plan"))
+ elif kind == "task_update":
+ self.emit("task_update", request_id, session_id=event.get("session_id"),
+ task_id=event.get("task_id"),
+ status=event.get("status"), reason=event.get("reason", ""))
elif kind == "interaction":
self.emit("interaction", request_id, interaction=event.get("interaction", {}))
elif kind == "permission_check":
@@ -310,7 +318,7 @@ def _run_submit(self, text: str, request_id: str, plan_acceptance: dict[str, Any
self.emit("tasks", request_id, tasks=[
{"id": task["id"], "description": task["description"], "status": task["status"]}
for task in runtime.tasks.tasks
- ])
+ ], working_plan=runtime.tasks.working_plan_snapshot())
self.interaction(request_id)
self.usage(request_id)
self.status("ready", "Ready", request_id)
diff --git a/openkyrozen/interfaces/web/chat.py b/openkyrozen/interfaces/web/chat.py
index 40d492a..4a38b12 100644
--- a/openkyrozen/interfaces/web/chat.py
+++ b/openkyrozen/interfaces/web/chat.py
@@ -555,7 +555,9 @@ async def generate():
elif kind == "tool_receipt":
yield f"data: {json.dumps({'event': 'tool_receipt', 'tool_receipt': event.get('tool_receipt')}, ensure_ascii=False)}\n\n"
elif kind == "tasks":
- yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', [])}, ensure_ascii=False)}\n\n"
+ yield f"data: {json.dumps({'event': 'tasks', 'tasks': event.get('tasks', []), 'working_plan': event.get('working_plan')}, ensure_ascii=False)}\n\n"
+ elif kind in {"working_plan", "task_update"}:
+ yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
elif kind == "subagent":
yield f"data: {json.dumps(event, ensure_ascii=False)}\n\n"
elif kind == "runtime_event":
diff --git a/openkyrozen/interfaces/web/history.py b/openkyrozen/interfaces/web/history.py
index b7c7ebf..6c752ef 100644
--- a/openkyrozen/interfaces/web/history.py
+++ b/openkyrozen/interfaces/web/history.py
@@ -79,5 +79,8 @@ async def api_v2_session(self, session_id: str):
session_id = service._normalise_session_id(session_id)
with service._chat_lock:
session = service._get_or_create_session(session_id, service._SERVER_ACTOR_ID)
+ bound = service._agent.open_session(session_id, user_id=service._SERVER_ACTOR_ID)
+ working_plan = bound.tasks.working_plan_snapshot()
return {"session_id": session_id, "messages": session["messages"], "updated": session.get("updated"),
- "interaction": service._interaction_for_session(session).envelope(), "context": session.get("context")}
+ "interaction": service._interaction_for_session(session).envelope(), "context": session.get("context"),
+ "working_plan": working_plan}
diff --git a/openkyrozen/interfaces/web/templates/chat.html b/openkyrozen/interfaces/web/templates/chat.html
index 61d322c..e6ac212 100644
--- a/openkyrozen/interfaces/web/templates/chat.html
+++ b/openkyrozen/interfaces/web/templates/chat.html
@@ -22,6 +22,7 @@
#chat:empty::before{content:'Start a conversation or type / for commands.';display:block;color:var(--muted);text-align:center;padding:12vh 0}
#interaction-card{width:min(100%,1024px);margin:14px auto 0;padding:14px 17px;background:var(--surface-hi);border:1px solid var(--brand);border-radius:10px}
#interaction-card[hidden]{display:none}#interaction-card h2{font-size:15px;color:var(--brand);margin-bottom:8px}#interaction-card fieldset{border:0;margin:12px 0}#interaction-card legend{font-weight:700;margin-bottom:6px}#interaction-card label{display:block;margin:6px 0;color:var(--text)}#interaction-card input[type=text]{width:100%;background:var(--ink);border:1px solid var(--line);border-radius:6px;color:var(--text);padding:8px;margin-top:5px}#interaction-card ol{padding-left:22px;margin:8px 0}#interaction-actions{display:flex;gap:8px;flex-wrap:wrap;margin-top:12px}
+#working-plan{width:min(100%,1024px);margin:14px auto 0;padding:14px 17px;background:var(--surface);border:1px solid var(--line);border-radius:10px}#working-plan[hidden]{display:none}#working-plan h2{font-size:15px;color:var(--brand)}#working-plan p{color:var(--muted);margin:5px 0}#working-plan ol{padding-left:24px;margin-top:8px}#working-plan li{padding:3px 0}#working-plan li.cancelled,#working-plan li.superseded{color:var(--muted);text-decoration:line-through}#working-plan .plan-status{font-size:12px;color:var(--muted)}
.msg{margin-bottom:22px;max-width:min(88%,860px)}
.msg.user{margin-left:auto}
.msg .role{font-size:11px;letter-spacing:.08em;color:var(--muted);margin-bottom:6px}
@@ -95,6 +96,7 @@
Conversation history
+
@@ -111,6 +113,7 @@
Conversation history
let interactionState = {preference_mode: 'auto', effective_mode: 'ask', system_one_backend: 'off', fast_backend: 'off', pending_question: null, pending_plan: null};
let decisionAssistState = {backend: 'off', kev_private_consent: false};
let systemOneStatus = {system_one_backend: 'off'};
+let workingPlan = null;
class AuthRequiredError extends Error {}
@@ -220,6 +223,44 @@