diff --git a/README.md b/README.md
index 7a61def..5f2c926 100644
--- a/README.md
+++ b/README.md
@@ -92,7 +92,8 @@ In the browser: the task on the left names its host; click `ssh cka0037` (or any
value) to put it on the desktop's clipboard and paste in the terminal with **Ctrl+Shift+V**.
Firefox in the desktop opens only the allowed documentation for the track (`KSIM_OPEN_WEB=1`
lifts that). Submit grades every task on its own host; the hosts stay up for review until the next
-session starts.
+session starts. **Restart clock** in the top bar (`./ksim restart`) puts the session's full time
+back on the clock without touching its hosts or your work; like Pause, it is recorded nowhere.
Everything is also available from a terminal. A command is for the track named with `--track` or
`KSIM_TRACK`; with neither, for the track whose session is under way, and for CKA when there is
@@ -110,6 +111,7 @@ host name such as `ckad0013` only resolves in the desktop's terminal: from your
./ksim skip DESIGN | unskip DESIGN # "I am good with this domain (or objective 2.3)": off the path, and back
./ksim drill OBS:3 | next OBS:3 # three tasks of a domain, each from another objective - alone, or added
./ksim list | show N | grade N | solution N | timer | end
+./ksim pause | resume | restart # stop the clock, or put the session's full time back (hosts and work kept)
./ksim hosts [start] # the session's hosts; `start` brings them back after a reboot
./ksim ssh N # a shell on the host of task N from your own terminal
./ksim reset N # rebuild the host of task N from scratch
diff --git a/ksim b/ksim
index bb161d6..175ef46 100755
--- a/ksim
+++ b/ksim
@@ -22,7 +22,7 @@ from collections.abc import Callable
from datetime import datetime
from typing import Any
-VERSION = "2026.10.15.1"
+VERSION = "2026.10.15.2"
ROOT = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, ROOT)
from sim import desktop, envs # noqa: E402
@@ -299,6 +299,12 @@ def set_paused(st: dict[str, Any], on: bool) -> None:
st["paused_total"] = float(st.get("paused_total", 0)) + (time.time() - float(st["paused_at"])); st["paused_at"] = None
save_state(st)
+def restart_clock(st: dict[str, Any]) -> None:
+ """The session's full time again, from now and running. Hosts, the work on them, results,
+ flags and notes stay as they are."""
+ st["started_at"] = time.time(); st["paused_total"] = 0.0; st["paused_at"] = None
+ save_state(st)
+
def fmt(secs: float | int) -> str:
secs_int = max(0, int(secs))
return f"{secs_int // 3600:02d}:{(secs_int % 3600) // 60:02d}:{secs_int % 60:02d}"
@@ -1643,6 +1649,15 @@ def cmd_resume(args):
st = require_started()
set_paused(st, False); print(status_line(st))
+def cmd_restart(args):
+ """Start the clock again from the session's full time, keeping its hosts and your work. Like
+ pause, not recorded anywhere: the attempt's time used counts from the restart."""
+ st = require_started()
+ if st.get("ended_at"):
+ die("Session already ended - nothing to restart. Start a new one: ./ksim drill, ./ksim start, ./ksim next")
+ restart_clock(st)
+ print(status_line(st) + dim(" clock restarted - the hosts and your work are as they were"))
+
def cmd_end(args):
st = require_started()
if st.get("ended_at"):
@@ -1843,6 +1858,10 @@ class Api(http.server.BaseHTTPRequestHandler):
flags.symmetric_difference_update({qid}); st["flags"] = sorted(flags); save_state(st)
elif p.path == "/api/pause":
set_paused(st, bool(body.get("paused", True)))
+ elif p.path == "/api/restart":
+ if st.get("ended_at"):
+ return self._json({"error": "This session has ended - there is no clock to restart."}, 400)
+ restart_clock(st)
elif p.path == "/api/notes":
st["notes"] = body.get("notes", ""); save_state(st)
elif p.path == "/api/grade":
@@ -2215,6 +2234,7 @@ def cmd_help(args):
grade N|all grade one task (or all) per check - drills only (--force to peek in an exam)
status / timer time remaining (timer = live countdown)
pause / resume stop the clock (not recorded anywhere - but the real exam has no pause)
+ restart the clock from the top: the session's full time again, hosts and work kept (not recorded)
end submit: grade per check, score, log attempt, update weak-spots
weak what you keep getting wrong, with the failing checks
history every attempt, scores, and per-domain trend
@@ -2228,7 +2248,7 @@ def cmd_help(args):
""")
COMMANDS = {"start": cmd_start, "drill": cmd_drill, "list": cmd_list, "show": cmd_show, "grade": cmd_grade,
- "status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
+ "status": cmd_status, "timer": cmd_timer, "pause": cmd_pause, "resume": cmd_resume, "restart": cmd_restart, "end": cmd_end, "weak": cmd_weak, "history": cmd_history,
"reset": cmd_reset, "web": cmd_web, "abort": cmd_abort, "progress": cmd_progress, "solution": cmd_solution, "sim": cmd_sim, "version": cmd_version, "help": cmd_help,
"build": cmd_build, "hosts": cmd_hosts, "ssh": cmd_ssh, "down": cmd_down, "audit": cmd_audit, "coverage": cmd_coverage,
"objectives": cmd_objectives, "curriculum": cmd_objectives, "notes": cmd_notes, "next": cmd_next, "focus": cmd_focus,
diff --git a/web/index.html b/web/index.html
index bfd0795..60b87f0 100644
--- a/web/index.html
+++ b/web/index.html
@@ -170,6 +170,7 @@
--:--:--
+
@@ -365,6 +366,7 @@
+ (sess.objective ? ' ยท objective ' + sess.objective : '') : '';
$('#btnEnd').classList.toggle('hidden', !live);
$('#btnPause').classList.toggle('hidden', !live);
+ $('#btnRestart').classList.toggle('hidden', !live);
if (live) $('#btnPause').textContent = sess.paused ? 'Resume' : 'Pause';
$('#btnCheck').classList.toggle('hidden', !(live && sess.mode === 'drill'));
$('#lobby').classList.toggle('hidden', !!live || (sess && sess.ended_at && !$('#results').classList.contains('hidden')));
@@ -598,6 +600,11 @@
$('#btnNewExam').onclick = () => { $('#results').classList.add('hidden'); $('#lobby').classList.remove('hidden'); $('#lobbyOpts').classList.remove('hidden'); };
$('#btnHistory').onclick = showHistory;
$('#btnPause').onclick = async () => { S = await api('/api/pause', {paused: !S.session.paused}); skew = Date.now()/1000 - S.session.server_time; paint(); };
+ $('#btnRestart').onclick = async () => {
+ if (!confirm(`Restart the clock at ${fmt(S.session.duration)}? The hosts and your work stay as they are.`)) return;
+ try { S = await api('/api/restart', {}); skew = Date.now()/1000 - S.session.server_time; autoSubmitted = false; paint(); }
+ catch (e) { alert(e.message === 'not found' ? 'This ./ksim web was started before "restart" existed. Stop it (Ctrl-C) and start it again.' : e.message); }
+ };
$('#btnCloseHist').onclick = () => $('#histOv').classList.add('hidden');
// draggable split
const handle = $('#handle'), main = document.querySelector('main');