• src/syncterm/Wren.adoc sr

    From Deuc¿@VERT to Git commit to main/sbbs/m on Monday, April 27, 2026 16:09:00
    https://gitlab.synchro.net/main/sbbs/-/commit/4adbdc8d6d1dbafb8877cd67
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/console.wren src/syncterm/scripts/load/wrentest.wren src/syncterm/scripts/syncterm.wren src/syncterm/term.c wren_bind.c wren_host.c wren_host.h wren_host_internal.h
    Log Message:
    SyncTERM: Wren HookHandle + main-loop hook compaction

    Foreign HookHandle returned by Hook.on*/Hook.every Ä no Wren-side
    constructor, so scripts can only remove their own hooks. Carries
    per-entry metrics (callCount, totalRuntime, min/maxRuntime) timed
    through xp_timer().

    Hook + timer entries are now heap-allocated structs reached through
    pointer arrays in state.hooks[]/state.timers[]. HookHandle.remove()
    releases the fn handle and links the entry onto a cleanup queue; wren_host_compact() drains that queue from the doterm() outer loop,
    shifts the entry out of its dispatch array, and frees regex
    resources. The struct itself stays alive until both compaction has
    run and Wren's GC has fired the foreign-class finalizer, so removed
    handles keep returning sensible metric reads until the script drops
    them.

    The unified wren_hook_entry struct discriminates hook vs timer via
    ev (extended with WREN_HOOK_TIMER), letting the dispatch
    infrastructure share one path for the lifetime + cleanup machinery.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 04:20:00
    https://gitlab.synchro.net/main/sbbs/-/commit/e2a8307110280f8f5bef30e7
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/console.wren syncterm.wren src/syncterm/wren_bind.c
    Log Message:
    SyncTERM: Wren console Ä /mods command + REPL.modules

    Adds REPL.modules, a foreign static getter that walks
    modules->entries directly to enumerate every module currently
    loaded into the VM (including core, every embedded module, every
    user script, and anything pulled in via import). Skips empty
    slots and tombstones (key is UNDEFINED_VAL for both); non-string
    keys are filtered defensively.

    console.wren grows a /mods command that calls REPL.modules,
    sorts via a byte-wise stringLT_ helper, and prints the result.
    Wren's String doesn't implement <, so List.sort()'s default
    {|a, b| a < b} comparator aborts on string lists; the helper does
    ASCII-safe byte comparison and is reusable for other string sorts
    that come up later.

    Wren.adoc refreshed: documents the new Hook.dispatch_ contract
    ("hooks must run synchronously; wrap parking work in
    Fiber.new {...}.call()"), corrects the Modal Input section's
    description of nextEvent-from-a-hook (now detected and reported,
    not silently hung), describes REPL.eval's actual statement-keyword pre-classifier (was still describing the old try-expression-first
    flow), and adds REPL.modules + /mods + /? + /q to the command
    tables.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 04:20:00
    https://gitlab.synchro.net/main/sbbs/-/commit/e2528de46b9d76fdceb6ea0d
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/console.wren syncterm.wren src/syncterm/term.c wren_bind.c wren_host.c wren_host.h wren_host_internal.h
    Log Message:
    SyncTERM: Wren API audit + Directory rework + doc completeness pass

    Three threads, committed together because they overlap in the same files:

    API shape audit
    ---------------
    * Input.next, Input.poll, Input.nextEvent Ä converted from getters
    to methods (Input.next(), Input.poll(), Input.nextEvent()). The
    rule "getters are for things that feel like variable access, not
    things that feel like they're doing something" Ä these block,
    poll, or park a fiber, so they're actions.
    * Directory.list Ä converted from method to a foreign Map getter.
    The directory's contents read like a property; indexing
    `Cache.list["RIP"]` returns the File or Directory handle for that
    name (or null), which composes naturally for tree traversal:
    `Cache.list["RIP"].list["icons.dat"]`.

    Directory rework
    ----------------
    * Directory.create(name) Ä now actually creates the file (was a
    no-op File-object factory). Uses C11 exclusive-create
    (fopen("wbx")) for race-free atomic creation. Returns null on
    any failure (file exists, invalid name, path too long, OS reject).
    * Directory.createDir(name) Ä added. Mirrors create() for
    subdirectories via MKDIR (which is naturally exclusive).
    * Directory.delete(name) Ä added. Parent-acts-on-child shape:
    removes the named entry (regular file or empty directory only;
    refuses symlinks, devices, FIFOs). Returns bool.
    * File.delete() Ä removed. The instance-method-that-zombies-its-
    receiver shape was awkward; Directory.delete(name) covers the
    case from the parent.
    * Directory.list now returns Files AND Directories Ä the C
    implementation always built a Map keyed by name with File values
    for regular files; this extends it to also emit Directory values
    for subdirectories, matching the documented intent.

    Live-handle registry
    --------------------
    A successful Directory.delete shouldn't leave outstanding handles
    to the removed entry quietly operating on stale paths. Each
    wren_file and wren_directory now self-registers on a doubly-linked
    list rooted on wren_host_state. Helpers fs_register_*,
    fs_unregister_*, fs_kill_*, fs_invalidate_subtree.

    Two layers of staleness protection on every File / Directory
    operation:

    1. dead flag Ä set by fs_invalidate_subtree when a parent's
    delete removes the entry (or marks an ancestor). file_check /
    dir_check (called at the top of every method) abort the fiber
    on dead.
    2. Per-call fexist() / isdir() Ä catches deletions that bypassed
    Directory.delete (other process, the user, another script).
    On miss: fs_kill_*(handle) (mark dead + unregister) + throw.

    Open-file exemption: a File between open() and close() skips
    the fexist() check (its fd is authoritative Ä Unix lets reads/
    writes continue past unlink, and Windows refuses to delete
    open files at all). fs_invalidate_subtree skips fp != NULL
    entries on the same logic. fn_File_close re-runs fexist()
    after fclose; if the path is gone, the handle becomes dead.

    Wren.adoc completeness pass
    ---------------------------
    Stale "see ciolib.h" references replaced with full reference
    content:
    * Codepage Ä every entry described, _b suffix explained.
    * Key Ä full grouped tables (ASCII / cursor / modified Insert-
    Delete / modified arrows / function keys with all four modifier
    columns / synthetic markers).
    * Font Ä full 46-row table including the 1-31 unnamed-in-Wren
    slots that are still reachable numerically; "thin"/"swiss"
    font-style annotations explained.
    * Screen.supports, Screen.videoFlags Ä every flag described.
    * ConnType, Emulation, BBSListType, ScreenMode, AddressFamily,
    MusicMode, RipVersion, Parity, FlowControl, LogLevel, ExtAttr,
    LastColumnFlag, LogMode, StatusDisplay Ä all converted from
    bare name lists to descriptive tables.

    Corrections to wrong descriptions:
    * sxScroll Ä SIXEL scroll mode (not "smooth scroll" / DECSCLM).
    * blinkAltChars Ä repurposes attribute bit 7 to select the alt
    character set (not "animate alt-char-set on blink interval").
    * StatusDisplay Ä VT320 DECSSDT semantics (host-writable status
    line, not "verbose status showing connected host").

    Worked example replaced. The "auto-respond to a prompt" example
    was using onInput + manual line buffering with a logic bug that
    only checked for prompts on LF (so "Logon: " Ä which has no
    trailing LF Ä never matched). Replaced with a Hook.onMatch
    two-liner; added a smaller per-byte BEL-counter example that
    demonstrates onInput correctly without the broken pattern.

    Anchors added: [[hook-events]], [[modal-input]], [[codepage]], [[filename-policy]], [[directory-handle-staleness]] so the
    existing <<...>> cross-refs resolve.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 06:00:00
    https://gitlab.synchro.net/main/sbbs/-/commit/feab4f3a1cac2087b0260c95
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/auto/connected/console.wren src/syncterm/scripts/syncterm.wren wrentest.wren src/syncterm/term.c wren_bind.c wren_host.c wren_host.h wren_host_internal.h
    Log Message:
    SyncTERM: Wren result-queue framework + CTerm.suspended

    Generic completion queue: callable C-side request data + fiber
    handle + deliver/free callbacks travel through one mutex-protected
    FIFO, drained at the top of each doterm() iteration. The drainer
    walks each entry, skips fibers where Fiber.isDone (cached primitive
    handle), builds the Wren foreign right before wrenCall, and
    releases the handle + frees the data after. Workers can push from
    any thread; delivery is owner-thread only.

    Input.nextEvent now flows through the queue: dispatch_key/dispatch_mouse
    push an input_result carrying the raw key code or mouse_event and
    transfer the parked fiber handle. One-iteration latency on
    delivery, but the wrenCall is no longer fired mid-foreign-stack.

    Replaces the implicit "parking on Input.nextEvent claims the
    screen" behavior with an explicit CTerm.suspended Bool. Backed
    by a doterm() local; while true, the wire pump halts and bytes
    pile up in the conn buffer until the TCP window fills and the
    remote sees backpressure.

    When the suspend flag transitions back to false, doterm() credits
    the byte pump with all the bytes that would have drained at the
    emulated rate during the suspended window. Those bytes burst past
    the speed gate one per pump iteration until the credit runs out;
    the visible output catches up to where it would have been with no
    suspend. No-op when speed emulation is disabled.

    Adds T06 to wrentest.wren that exercises the queue end-to-end:
    parks a fiber on Input.nextEvent, ungets a sentinel key, sets +
    clears CTerm.suspended around the resume, and verifies the fiber
    captured the right KeyEvent. Also flips console.wren's launcher
    hook to the filtered Hook.onKey(Key.wrenConsole) form.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 06:25:00
    https://gitlab.synchro.net/main/sbbs/-/commit/572a7ff6bf903c063edc99a6
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/wrentest.wren src/syncterm/term.c wren_host.c wren_host.h
    Log Message:
    SyncTERM: Hook.onInput String returns + filter spillover

    Extend Hook.onInput so a handler can return a String to replace
    the input byte with the string's bytes (up to 256; bigger
    replacements log a runtime error and the byte passes through).
    Bool true still drops, anything else still passes through.

    Decouple the wire-side buffer from recv_byte_buffer: a separate
    wire_buffer holds raw conn_recv_upto bytes, and the filter runs
    them into recv_byte_buffer until either input exhausts or output
    fills. When a replacement won't fit, the filter pauses on that
    input byte; the unprocessed wire-side tail stays parked in
    wire_buffer until the next recv_bytes() call drains something out
    and frees room for it. Spillover means recv_byte_buffer never has
    to grow past BUFFER_SIZE even with aggressive expansion.

    wren_host_dispatch_input now returns int Ä KEEP/DROP/N Ä and writes
    replacement bytes into a caller-provided buffer. The caller (wren_filter_input) commits N bytes only if they fit, otherwise
    backs out without consuming the input byte.

    Adds an LFCRLF hook to wrentest.wren that ticks a counter when it
    fires; report_() asserts the counter is positive, exercising the
    new WREN_TYPE_STRING dispatch branch end-to-end.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 09:22:00
    https://gitlab.synchro.net/main/sbbs/-/commit/cf86f51e84049319c38da7a8
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/auto/connected/console.wren src/syncterm/scripts/wrentest.wren
    Log Message:
    SyncTERM: WrenConsole.register for module-defined REPL commands

    Modules can plug in their own /<name> entries via
    WrenConsole.register(name, help, fn). The handler runs with the raw
    argument string (everything after the first separating space, or ""
    if none) inside a Fiber so a runtime abort surfaces as a logged
    error rather than tearing the console out from under itself.
    Re-registering a name overwrites; names can't contain spaces.

    /? now lists registered commands as continuation lines under the
    built-in "commands:" row, each annotated with the help text. WrenConsole.unregister(name) drops a registration (idempotent); WrenConsole.commands returns the sorted list of currently-registered
    names for tests + tooling.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 09:22:00
    https://gitlab.synchro.net/main/sbbs/-/commit/b743852183205e720d9cc8f1
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/syncterm.wren wrentest.wren src/syncterm/wren_bind.c
    Log Message:
    SyncTERM: File.readLine + File.writeLine

    readLine() reads from the current offset to the first LF (0x0A) or
    EOF and returns the bytes with any trailing LF removed. Offset
    advances past the LF on a hit, or to EOF if none found. Returns
    null when already at EOF so a typical loop terminates cleanly; a
    blank line is the empty string, distinct from EOF.

    writeLine(s) writes the bytes of s at the current offset, then
    appends an LF. Offset advances past the LF. No special-casing if
    s already ends in LF Ä writeBytes() is the way to opt out of the
    trailing-LF behavior.

    Implementation chunks reads through a 512-byte buffer with
    geometric growth on long lines, so a 100GB file with short lines
    doesn't allocate the whole remainder up front.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Tuesday, April 28, 2026 17:47:00
    https://gitlab.synchro.net/main/sbbs/-/commit/9f1ed0221b7e8d4c67967ede
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/syncterm.wren wrentest.wren src/syncterm/wren_bind.c
    Log Message:
    SyncTERM: File.sha1 / File.md5 + missing docs and tests

    File.sha1 and File.md5 hash the file's full content via xpmap and
    the existing src/hash sha1.c / md5.c. Zero-length files are
    special-cased to an empty buffer because xpmap rejects 0-sized
    maps. Returned as raw digest bytes (Wren strings are byte-safe)
    so they compare directly against SFTPEntry.hash from the
    sha1s@syncterm.net / md5s@syncterm.net SFTP extensions; format hex
    yourself if you need it for display.

    Also catches Wren.adoc + wrentest.wren up to recent work that
    shipped without docs / tests:

    - New Wren.adoc sections for Platform, Timer (+ TimerElapsed), SFTP
    (+ FileFlag, SFTPEntry, SFTPStat, SFTPHandle, SFTPError, and the
    shared async-op pattern used by Timer / SFTP / Input.nextEvent).
    File doc gets the sha1 / md5 row added.

    - wrentest.wren coverage:
    Platform.name returns non-empty String.
    File.sha1 / File.md5 of an empty file (exercises the
    zero-length code path) and of "hello" (exercises xpmap).
    Timer.trigger(ms=0) parks a fiber, the doterm sweep marks it
    past-due, the drain resumes with a TimerElapsed.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Thursday, April 30, 2026 00:47:00
    https://gitlab.synchro.net/main/sbbs/-/commit/5c5edcef06b965f5b689ae14
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/syncterm.wren ui_app.wren ui_demo.wren ui_pane.wren ui_widget.wren ui_widget_test.wren wrentest.wren src/syncterm/wren_bind.c wren_bind_screen.c wren_bind_screen.h
    Log Message:
    SyncTERM: Wren Input.wake + App.post; UI fit-and-finish; doc the language

    New host primitive: Input.wake(fiber, value) Ä queue a fiber resumption
    on the same result queue Input.nextEvent and Timer.trigger drain
    through. Safe to call from Hook.onInput; the resume happens on the
    next main-loop drain, so a network-driven app (IRC, ticker, log
    viewer) can wake a UI fiber parked on Input.nextEvent when remote
    bytes change visible state. If the target is also the parked-fiber
    slot, wake clears it (compared via wrenValuesSame on the underlying
    Value, since handles wrapping the same fiber are distinct pointers
    but equal Values) so the next Input.nextEvent re-arms cleanly.

    App.post() / App.post(value) wraps Input.wake against a captured
    _runFiber; App.onPost=(fn) is the user-visible handler.

    Container.focusStep_ now returns false when the only focusable child
    is already focused, so a Pane wrapping a single ListView (or any
    nested single-focusable Container) no longer traps Tab inside itself
    Ä Tab bubbles up to the parent. New regression test in
    ui_widget_test.

    Pane.helpButtonRect_ suppresses the [?] button when neither onHelp
    nor helpText is wired. A button that does nothing is worse than no
    button. Demos that want the button now set helpText with relevant
    key hints (gallery, Checkbox, RadioGroup, SpinBox, TextInput, Form).

    Wren.adoc gains two top-level chapters before Quick Start: a Wren
    Language Reference (literals, statement-termination rules,
    classes/fields scope, fibers, modules, common pitfalls) and a Wren
    Standard Library reference (System / Object / Class / Bool / Null /
    Num / String / List / Map / Range / Sequence / Fiber / Fn) so other
    LLMs pointed at this doc don't have to chase wren.io fragments.
    Also fixes asciidoctor's `...` -> ellipsis substitution wherever
    three dots are Wren range / slice syntax (escaped via \\...).
    Documents Input.wake, App.post / App.onPost, the popStatus z-order
    (below modals), and the gatesActiveLayer two-axis layer model.
    Updates check.on glyph reference (now û, not þ).

    wrentest gains T07: Input.wake delivers two values (a foreign
    KeyEvent and a String) to fibers parked on plain Fiber.yield (no Input.nextEvent registration), exercising both the result-queue
    plumbing and the WrenHandle pin/release across types.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Sunday, May 03, 2026 21:44:00
    https://gitlab.synchro.net/main/sbbs/-/commit/76a3f347979081df508310b6
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/auto/connected/keys_default.wren src/syncterm/scripts/syncterm.wren ui_list.wren ui_list_test.wren ui_pane.wren ui_widget.wren src/syncterm/term.c wren_bind.c wren_bind_conn.c wren_bind_conn.h
    Log Message:
    SyncTERM: move Alt-M to Wren; add ListView padding + Pane auto-sizing

    The Alt-M music-mode picker is now a Wren Hook.onKey handler in keys_default.wren that opens a Pane + ListView modal. The C
    case-block in term.c is gone; music_control() itself stays for
    the Alt-Z popup-menu's SM_MUSIC entry.

    C primitives added so the script can do its job:
    * CTerm.music = i (setter; clamps to legal range)
    * Host.musicNames (List<String> built from music_names[])
    * Host.musicHelp (returns music_helpbuf)

    UI library changes the picker exposed:
    * ListView always reserves a 1-cell padding between the frame and
    the items on the side that doesn't have a scrollbar (both sides
    when no scrollbar is shown at all). The previous behaviour let
    long items butt against the frame.
    * Widget.preferredWidth / preferredHeight -- new base getters
    returning null ("no preference, fill what's given"). ListView
    overrides them with the smallest cell budget that displays every
    item without truncation.
    * Pane.fitContent() sizes the pane around its single child's
    preferred size, including the title bar's required width and
    the corner-button cluster. titleAsBar mode reserves a 1-cell
    padding around the title (`title + 4`); frameTitle mode uses
    the existing `title + 6` (corners + brackets + spaces).
    * Pane.centerOnScreen() repositions the pane after fitContent.

    The Alt-M handler is now ~15 lines: build the list, add to pane,
    fit + center, run. No hardcoded widths, no manual inner-bounds
    math.

    Wren.adoc updated with the new accessors, the Widget / Pane /
    ListView additions, the title-mode geometry rules, and a small
    auto-sized list-in-pane example mirroring the music picker.

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Monday, May 04, 2026 11:53:00
    https://gitlab.synchro.net/main/sbbs/-/commit/838332e35280aa8865a3ed7f
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/sftp_app.wren ui_list.wren ui_popup.wren
    Log Message:
    SyncTERM: ListView gets type-to-search, Ctrl-F/G, click-activate, tag mode

    Type-to-search: any printable codepoint grows a rolling buffer and
    jumps to the first item whose searchTextFor_ starts with it (case-
    insensitive ASCII fold). No-match falls back to just the new char.
    Buffer resets on any nav / activation key.

    Ctrl-F: prompts via a new compact Find popup (3 rows tall, title in
    the top frame border, full innerBounds row for the input, no buttons), case-insensitive substring search wrapping the list. Ctrl-G repeats
    the last query.

    Click-to-activate: button1Click on a row both selects and fires
    onSelect, matching UIFC's ulist.

    Tag mode: opt-in via selectionMode = "tag". Per-item flags toggled
    by Space; tagged getter returns the indices. 1-cell marker column
    uses theme tag.on / tag.off glyphs.

    searchTextFor_(item) is the subclass hook for what users type
    against; defaults to formatItem(item, 1024). BrowserListView and
    QueueListView override it to point at the bare filename instead of
    the chip-prefixed display line.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net
  • From Deuc¿@VERT to Git commit to main/sbbs/m on Monday, May 04, 2026 13:07:00
    https://gitlab.synchro.net/main/sbbs/-/commit/9480df8001d2548b3d9bbf0b
    Modified Files:
    src/syncterm/Wren.adoc src/syncterm/scripts/auto/connected/keys_default.wren src/syncterm/scripts/syncterm.wren ui_popup.wren src/syncterm/syncterm.c term.c wren_bind.c wren_bind_conn.c wren_bind_conn.h wren_host.c wren_host.h wren_host_internal.h
    Log Message:
    SyncTERM: move disconnect cluster (Alt-X / Alt-H / Ctrl-Q / [X]) to Wren

    The four hangup-and-quit keys are now driven from
    keys_default.wren via a new DisconnectFlow helper that raises a
    Confirm popup ("Disconnect... Are you sure?") and, on yes, calls Conn.endSession(exitApp). doterm() picks the request up at the
    top of its next iteration, runs the (UI-free) C cleanup, and
    either returns to the bbslist (Alt-H / Ctrl-Q) or exits
    syncterm (Alt-X / window-close).

    Ctrl-Q is gated to text-mode terminals (curses / ANSI) at module-
    load time via the new Host.textTerminal predicate; graphical
    backends keep Ctrl-Q as a normal control byte.

    C-side check_hangup is now pure cleanup Ä the confirm popup and
    screen save/restore moved to Wren, the only caller was doterm,
    and the syncmenu's SM_DISCONNECT / SM_EXIT cases are now
    deduped onto the same primitive. check_exit keeps its UIFC
    "Are you sure you want to exit?" popup because bbslist + menu.c
    ESC handlers reach it from outside the disconnect-cluster path
    where Wren has already asked.

    Wren bindings: Conn.endSession(exitApp), Host.textTerminal, Key.ctrlA..Key.ctrlZ (full set; not just the two I happened
    to need), Popup.onDismiss=(fn) so a fresh App can drive a
    standalone Confirm without an enclosing run loop.

    Pending-disconnect drain runs at the top of the doterm outer
    loop after wren_result_drain Ä the parked DisconnectFlow fiber
    resumes during the result drain and calls Conn.endSession from
    there, not from a wren_host_dispatch_key frame, so a single
    post-drain check is what makes the hangup land in the same
    iteration as the user's Yes click.

    Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

    ---
    þ Synchronet þ Vertrauen þ Home of Synchronet þ [vert/cvs/bbs].synchro.net