From 1055d111382b03719459c750282bb9e1780dd9df Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 00:49:09 -0700 Subject: [PATCH 01/20] Document worktree and task terminal plan --- utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md | 352 ++++++++++++++++++ 1 file changed, 352 insertions(+) create mode 100644 utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md diff --git a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md new file mode 100644 index 0000000000..e391f2145d --- /dev/null +++ b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md @@ -0,0 +1,352 @@ +# Worktrees and task terminals + +Status: discussion draft + +This is a focused design document for the first dasHerd workspace application. +It is intentionally separate from `PLAN.md`: the worktree, Git, task-terminal, +and automation contracts have enough nuance that they must be discussed one +section at a time. + +No section below authorizes implementation merely by appearing here. Discuss +and settle each section before implementing the stage that depends on it. + +## Established direction + +- Git observation is asynchronous from the first implementation. No Git + command runs on the UI/render thread. +- The application lists the main checkout and linked worktrees, and inspects + the selected tree. +- The repository base and the main checkout are distinct. For example, a + repository may use `origin/master` as its base while its main checkout is on + a feature branch. The UI must show both values rather than label the main + checkout "master". +- A worktree can have a PowerShell terminal rooted in that tree and later an + agent session. +- Worktree creation supports a setup recipe. The built-in MCP bootstrap + (`utils/mcp/setup.das`) is the default recipe; `None` and a user-specified + recipe are alternatives. +- Removal is preceded by an explainable "is it safe to remove?" evaluation. +- Terminals are a general control and debugging surface. Git and setup work + can have visible, colored, scrollable terminals whose output is also logged. +- Every terminal is addressable through live commands. A human, Codex, another + agent, or later automation uses the same read/write terminal operations. +- Execution is not assumed to be local forever. A repository, worktree, Git + process, setup task, shell, or agent may live behind SSH. The UI must always + provide a route to the terminal that owns or can repair the operation. + +## Section 1: repository, base, and worktree identity + +Discussion status: open + +The first model needs to distinguish: + +- an execution target, initially the local machine and later an SSH target; +- a repository identity on that target; +- the repository's configured or detected base ref; +- the main checkout, which is a special worktree but not necessarily the base + branch; +- linked worktrees discovered from Git; +- worktrees created and managed by dasHerd; +- a stable session identity independent of a branch name. + +A path alone is not a global identity once SSH exists. Candidate identities +therefore include at least `target ID + repository Git-common-dir` for a +repository and `repository ID + canonical worktree path` for an initial +worktree identity. Session records get their own generated IDs. + +Questions to settle: + +- Is the base ref stored per repository, per worktree, or both with an + override? +- Are externally created worktrees fully manageable or initially read-only? +- Where are default managed worktrees placed, and how editable is that path? +- Do we show a virtual base row, or show the base only in the repository header + until it has a real worktree? + +## Section 2: asynchronous Git observation + +Discussion status: open + +Background observation needs structured, non-interactive results. It should +use Git's porcelain/plumbing output without color and publish versioned +snapshots to the UI. This path is separate from the human-facing task terminal: +terminal text is useful diagnostics but is never the authority for repository +state. + +The initial observer covers: + +- repository and Git-common-dir discovery; +- default-base detection, preferring `origin/HEAD` when available; +- main and linked worktree inventory; +- branch or detached HEAD and commit identity; +- staged, unstaged, untracked, renamed, and conflicted state; +- ahead/behind or containment relative to the selected base; +- locked and prunable worktree state; +- errors, last-success time, and whether a result is refreshing or stale. + +The local Git is currently 2.17.1, so the implementation cannot require modern +options such as `git worktree list --porcelain -z` or +`git branch --show-current`. Managed paths can reject CR/LF, while spaces and +Unicode remain supported. + +Worker rules: + +- invoke Git with argument arrays, never interpolated shell commands; +- send immutable/versioned results back to the UI thread; +- discard stale generations and coalesce redundant refresh requests; +- allow independent reads while serializing repository mutations; +- refresh after every mutation; +- do not fetch the network implicitly. + +Questions to settle: + +- Refresh triggers and cadence for selected and unselected worktrees. +- Whether a manual refresh means local observation only, with a separate + explicit Fetch action. +- How much per-command timing and argv detail is retained for the Git debug + window. + +## Section 3: execution targets and command sessions + +Discussion status: open + +Git cannot be designed as "run a Windows process in this local directory." +Commands run against an execution target. The first target implementation is +local; an SSH target later provides the same operations: + +- execute a structured non-interactive command and return a result; +- start or attach to a PTY terminal; +- identify a working directory on that target; +- observe process state and exit status; +- stream updates and retain diagnostics; +- terminate only through an explicit authorized action. + +Target-specific path syntax stays behind this boundary. UI and persisted +records carry a target ID together with paths rather than treating local +absolute paths as universal. + +Questions to settle: + +- How much of the target protocol is defined before implementing local-only + execution? +- Whether the future SSH host runs a small dasHerd helper or begins with direct + SSH command and PTY channels. +- Which environment, credential, and agent-command settings belong to a target + profile. + +## Section 4: Git and setup task terminals + +Discussion status: open + +User-initiated Git mutations, setup recipes, and repair work are activities +with terminals. Their terminal provides: + +- live colored output through a PTY; +- normal terminal scrolling, selection, search, links, and zoom; +- retained logs and structured process status; +- input when credentials, confirmation, or repair is required; +- an obvious "go to terminal" action from the worktree and activity UI; +- a recovery shell in the same target and working directory when a task fails. + +Short background observations remain structured jobs rather than flooding a +terminal. The Git debug UI can show their invocation, timing, exit status, and +bounded output, and can offer a terminal rerun or recovery shell. After any +interactive/mutating task, the structured observer refreshes Git state; the +colored terminal output is not parsed as truth. + +Logs live in the dasHerd user-data `logs/` area with rotation. A completed task +terminal remains inspectable after its process exits. Long setup tasks should +survive closing the UI, which may pull the detachable session host earlier in +the implementation order than agent launching. + +Questions to settle: + +- Which successful task terminals close automatically, remain as history, or + leave an interactive shell open? +- Whether a failed command automatically drops to a shell or offers an + explicit Open recovery shell action. +- Log retention, rotation, and whether input payloads are logged. Input can + contain secrets, so logging output and logging typed input are separate + decisions. +- Whether every mutation must be visibly terminal-backed or whether very short + non-interactive mutations may use the job path while retaining a one-click + diagnostic terminal. + +## Section 5: terminal identity and live-command control + +Discussion status: open + +Every terminal has a stable terminal/session ID independent of whether its +window is currently visible. Live commands must be able to: + +- list terminals with target, worktree, task/session, process, and activity + state; +- open or focus a terminal in the UI; +- inspect its bounded visible viewport and requested scrollback; +- read semantic text or changes since a known terminal revision; +- write UTF-8 text atomically; +- send normalized keys and paste operations; +- observe cursor, title, current-directory hint, exit state, and revisions. + +The same operations serve the user, Codex controlling dasHerd, another agent, +and future automation. The PTY receives the same bytes regardless of source; +there is no weaker "automation terminal." The control plane may still record +origin metadata outside the byte stream for audit, loop prevention, and +permissions. + +Input operations are serialized as complete events so a paste or prompt cannot +interleave byte-by-byte with human typing. This must be reconciled with the +single-input-lease rule for detachable sessions: live commands from the +lease-owning client are straightforward, while cross-client and cross-agent +writes need an explicit transfer, queue, or policy. + +Questions to settle: + +- The exact live-command names and bounded read/inspection schemas. +- Text, key, and paste operations versus an expert raw-byte operation. +- Whether writes require the input lease, can be queued for its owner, or can + use separately granted automation capability. +- What provenance is retained without making terminal behavior differ by + writer. + +## Section 6: basic application layout and navigation + +Discussion status: open + +The first application shape is: + +- a repository header that shows target and base; +- a draggable left rail containing the main checkout and linked worktrees; +- a right workspace for inspection and the selected terminal; +- a Git/tasks activity window containing running and retained jobs; +- status and failure affordances that always navigate to the relevant + terminal or recovery terminal. + +Each worktree row eventually shows branch/HEAD, dirty/conflict state, +refresh/error state, setup state, and live terminal/agent activity. Switching +worktrees does not restart their terminals. + +The Git/tasks window is a terminal when a task has a terminal, not a custom +plain-text log approximation. Structured Git observations may appear as debug +records beside the terminal activities. + +Questions to settle: + +- Default docking: fixed worktree rail plus terminal, or fully dockable + inspector/activity windows from the first version. +- Whether inspection sits above the terminal, in a separate dock, or behind a + tab. +- How completed task terminals are grouped and pruned from the activity UI. + +## Section 7: worktree creation and setup recipes + +Discussion status: open + +The initial creation flow collects a name, new branch, base ref, target path, +setup recipe, and post-create action. The default setup recipe is the MCP +worktree bootstrap; `None` and a user-entered recipe remain available. + +Recipes are structured command profiles with an executable, argument array, +working directory, environment additions, and placeholders such as repository +root, worktree root, target, and bootstrap daslang. Repository-provided recipes +must not execute merely because a repository was opened. + +Creation progresses through visible, retryable states: + +1. validate branch, base, target, and path; +2. create the worktree; +3. publish refreshed structured state; +4. run the selected setup recipe in a task terminal; +5. preserve and clearly mark the tree if setup fails; +6. open a shell or launch the selected agent after success. + +Once Git has created a real worktree, setup failure does not silently delete +it. The user can repair it in the task/recovery terminal, retry setup, keep the +tree, or proceed to the separately guarded removal flow. + +Questions to settle: + +- New branches only in v1, or attaching an existing branch too. +- Default worktree path and branch-name derivation. +- Recipe configuration location and first-use trust UI. +- Whether a shell may open while setup is still running. + +## Section 8: removal safety and recovery + +Discussion status: open + +"Is it safe to remove?" produces `Safe`, `Needs review`, or `Blocked` with +specific evidence. The mutation worker revalidates immediately before removal +to avoid acting on a stale UI snapshot. + +Initial evidence includes: + +- main checkout and protected-target rules; +- locked/prunable state; +- dasHerd-owned terminals, agents, setup jobs, and mutations; +- staged, unstaged, untracked, and conflicted changes; +- branch containment relative to the selected base; +- whether the branch is checked out elsewhere; +- whether repository, target, and path still match the inspected identity. + +Every blocking or review condition offers a route to the relevant terminal so +the user or an agent can inspect, commit, move, clean, or otherwise repair the +tree and ask again. + +Initial removal uses `git worktree remove` without force. Worktree removal and +branch deletion are separate operations; branch deletion uses the safe Git +operation rather than forced deletion. A force-removal design, if added, is a +later discussion. + +Questions to settle: + +- Exact distinction between `Needs review` and `Blocked`. +- Whether unpushed and not-contained-in-base are separate warnings. +- What evidence is sufficient for remote targets whose connection is down. +- Whether removal of worktrees not created by dasHerd is permitted in v1. + +## Section 9: agents, cross-agent communication, and self-prompting + +Discussion status: open, future-facing + +Agent integration should be built on terminal/session capabilities rather than +brand-specific screen scraping. A hosted PowerShell rooted in the worktree can +launch Codex, Claude Code, Pi, OpenCode, or a custom command. Closing the UI +must not stop the hosted terminal or agent. + +Cross-agent communication is a routed terminal write to a stable session ID. +Self-prompting is the same operation with source and destination equal. Both +use the ordinary terminal read/write contract; neither requires a second +hidden agent-control protocol. + +Terminal equivalence does not mean uncontrolled recursion. The control plane +can retain source session, destination session, event ID, parent event, and +timestamp outside the PTY stream. Later policy can use that provenance for +authorization, rate limits, hop/loop protection, and an inspectable audit +trail without changing what bytes the terminal receives. + +Questions to settle later: + +- Per-session permission to accept user, live-command, cross-agent, and + self-prompt writes. +- Loop and runaway protection that remains visible and overridable. +- Delivery acknowledgement: accepted into the terminal input queue versus + semantically acted upon by the agent. +- How an agent discovers addressable sessions without exposing unrelated + repositories or targets. + +## Tentative discussion and implementation order + +This order is a discussion aid, not an approved implementation schedule: + +1. Settle Sections 1-3: identity, asynchronous observation, execution target. +2. Implement the tested Git observer and the basic worktree/inspection app. +3. Settle Sections 4-6: task terminals, live control, and UI navigation. +4. Add worktree PowerShell terminals, Git activity/debug terminals, and the + live-command terminal surface. +5. Decide whether the detachable host must land before long-running setup. +6. Settle and implement Section 7 creation/setup. +7. Settle and implement Section 8 safe removal/recovery. +8. Settle Section 9 before one-click durable agent launching and any + cross-agent or self-prompt automation. + From 3224768e623550eabb02ed50a61bf9c9b3505476 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 01:58:26 -0700 Subject: [PATCH 02/20] Make detachable host the herder foundation --- utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md | 87 +++++++++++++++---- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md index e391f2145d..4662f90059 100644 --- a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md +++ b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md @@ -12,6 +12,12 @@ and settle each section before implementing the stage that depends on it. ## Established direction +- A detachable command host is the foundation beneath dasHerd. Callers do not + own launched OS processes directly: JIT invocations, Git commands, setup + scripts, shells, tests, and agents all execute as hosted command sessions. +- Every hosted command session has process state, a terminal, and a durable + log whether or not its terminal is currently visible. The UI and automation + attach to the session; detaching or closing the UI does not stop it. - Git observation is asynchronous from the first implementation. No Git command runs on the UI/render thread. - The application lists the main checkout and linked worktrees, and inspects @@ -34,6 +40,54 @@ and settle each section before implementing the stage that depends on it. process, setup task, shell, or agent may live behind SSH. The UI must always provide a route to the terminal that owns or can repair the operation. +## Section 0: detachable command host + +Discussion status: foundational direction agreed; contract details open + +The host is not an agent-specific service added late in the product. It is the +single execution building block used whenever dasHerd calls something. A JIT +compiler run, a Git query, a setup recipe, a test, an interactive PowerShell, +and a long-lived coding agent differ in profile and lifetime, not in whether +they receive a hosted session. + +The caller supplies a target, working directory, structured command and +arguments, environment additions, terminal geometry, and retention policy. +The host owns: + +- process creation and lifetime; +- the PTY and renderless terminal emulator; +- input serialization; +- terminal revisions, scrollback, title, CWD hints, and process status; +- an append-only session log and bounded retained terminal state; +- attach/detach and recovery after the UI disconnects; +- explicit termination and final exit status. + +The same session remains inspectable while running and after exit. A terminal +window is one client of that state, not its owner. Live commands, the human UI, +and future agent routing attach through the same session contract. + +Structured callers such as the Git observer still receive an explicit command +result and raw/captured output suitable for parsing. They do not reconstruct a +result by scraping rendered cells. Their hosted terminal and log remain +available for diagnostics even when the normal UI shows only structured state. + +The host must survive dasHerd UI exit from its first useful version. The later +SSH design decides where the host process physically runs, but not whether the +session has this contract. + +Questions to settle before implementation: + +- Host topology: one OS host process per command session, a supervisor with + child sessions, or a small supervisor plus isolated per-session hosts. +- The local attach protocol, discovery records, versioning, and authentication. +- Log format, rotation, crash consistency, terminal replay, and input-payload + privacy. +- Whether very short structured commands allocate a PTY or use a capture mode + that still exposes the same terminal/log/session API. +- Session retention after successful exit, failed exit, and explicit cleanup. +- What state can be reconstructed after the command host itself crashes; a PTY + cannot be recovered if its owning process is gone. + ## Section 1: repository, base, and worktree identity Discussion status: open @@ -67,11 +121,11 @@ Questions to settle: Discussion status: open -Background observation needs structured, non-interactive results. It should -use Git's porcelain/plumbing output without color and publish versioned -snapshots to the UI. This path is separate from the human-facing task terminal: -terminal text is useful diagnostics but is never the authority for repository -state. +Background observation needs structured, non-interactive results. It submits +commands through the detachable host, uses Git's porcelain/plumbing output +without color, and publishes versioned snapshots to the UI. Its hosted +terminal and log remain addressable for diagnostics, but terminal text is +never the authority for repository state. The initial observer covers: @@ -91,7 +145,8 @@ Unicode remain supported. Worker rules: -- invoke Git with argument arrays, never interpolated shell commands; +- invoke Git through hosted sessions with argument arrays, never interpolated + shell commands; - send immutable/versioned results back to the UI thread; - discard stale generations and coalesce redundant refresh requests; - allow independent reads while serializing repository mutations; @@ -339,14 +394,16 @@ Questions to settle later: This order is a discussion aid, not an approved implementation schedule: -1. Settle Sections 1-3: identity, asynchronous observation, execution target. -2. Implement the tested Git observer and the basic worktree/inspection app. -3. Settle Sections 4-6: task terminals, live control, and UI navigation. -4. Add worktree PowerShell terminals, Git activity/debug terminals, and the +1. Settle Section 0 and the core of Section 5: host, log, terminal, attach, and + live-command contracts. +2. Implement and stress the minimal local detachable host before building + worktree or agent policy on it. +3. Settle Sections 1-3: identity, asynchronous observation, execution target. +4. Implement the tested hosted Git observer and basic worktree/inspection app. +5. Settle Sections 4 and 6: task-terminal behavior and UI navigation. +6. Add worktree PowerShell terminals, Git activity/debug terminals, and the live-command terminal surface. -5. Decide whether the detachable host must land before long-running setup. -6. Settle and implement Section 7 creation/setup. -7. Settle and implement Section 8 safe removal/recovery. -8. Settle Section 9 before one-click durable agent launching and any +7. Settle and implement Section 7 creation/setup. +8. Settle and implement Section 8 safe removal/recovery. +9. Settle Section 9 before one-click durable agent launching and any cross-agent or self-prompt automation. - From b0d4b52f06cd3dd794f598959a9ce477a6c42bee Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 09:17:41 -0700 Subject: [PATCH 03/20] Measure Git terminal execution overhead --- utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md | 28 +++ utils/dasHerd/chunk0/README.md | 55 ++++ .../dasHerd/chunk0/benchmark_git_terminal.das | 202 +++++++++++++++ utils/dasHerd/chunk0/git_terminal_probe.das | 234 ++++++++++++++++++ utils/dasHerd/chunk0/git_terminal_window.das | 203 +++++++++++++++ 5 files changed, 722 insertions(+) create mode 100644 utils/dasHerd/chunk0/README.md create mode 100644 utils/dasHerd/chunk0/benchmark_git_terminal.das create mode 100644 utils/dasHerd/chunk0/git_terminal_probe.das create mode 100644 utils/dasHerd/chunk0/git_terminal_window.das diff --git a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md index 4662f90059..2144baab85 100644 --- a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md +++ b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md @@ -88,6 +88,34 @@ Questions to settle before implementation: - What state can be reconstructed after the command host itself crashes; a PTY cannot be recovered if its owning process is gone. +### Chunk 0: measure terminal execution overhead + +Discussion status: agreed + +Before choosing a capture fast path or optimizing host topology, measure local +Git through the terminal stack that already exists. SSH Git is a separate Git +installation and execution target; it gets the same probe after SSH exists. + +Compare the same forced-color Git argv through: + +1. direct process capture; +2. direct process capture plus a raw durable log; +3. ConPTY, terminal emulation, and a raw durable log. + +The probe records launch-to-first-output, launch-to-exit, final-output drain, +terminal poll/feed time, log-write time, byte count, and exit code. It separates +the cold first run from warmed median and p95 samples. The command set contains +a process-launch baseline, a representative colored repository status, and an +intentional failure to prove nonzero exit propagation. + +One windowed run displays the real terminal with color and scrolling while +retaining its raw log and final exit code. Repeated timing runs are headless. +This first comparison measures in-process ConPTY/emulator/log overhead; the +same probe is rerun through detach/IPC after the minimal host exists. + +No capture-mode or short-command optimization is selected until these numbers +exist. + ## Section 1: repository, base, and worktree identity Discussion status: open diff --git a/utils/dasHerd/chunk0/README.md b/utils/dasHerd/chunk0/README.md new file mode 100644 index 0000000000..4609904d28 --- /dev/null +++ b/utils/dasHerd/chunk0/README.md @@ -0,0 +1,55 @@ +# Chunk 0: local Git through the terminal + +This probe measures the cost of using the existing Windows terminal stack for +Git before dasHerd chooses a fast path or moves terminal ownership into a +detachable host. + +It compares the same Git operation through three lanes: + +1. direct process capture; +2. direct capture plus a raw log; +3. ConPTY plus terminal emulation plus a raw log. + +Run the headless benchmark from the repository root: + +```text +bin\Release\daslang.exe utils\dasHerd\chunk0\benchmark_git_terminal.das -- --warmup=5 --rounds=30 +``` + +Run the visible terminal probe with the local dasImgui module: + +```text +bin\Release\daslang-live.exe -load_module modules\dasLiveHost -load_module modules\dasImgui utils\dasHerd\chunk0\git_terminal_window.das +``` + +The visible probe starts maximized at 150% zoom, displays colored Git output, +and writes its raw byte stream under `logs/dasHerd/`. Ctrl+mouse-wheel and the +bottom buttons change zoom in 5% steps. `imgui_snapshot` exposes the terminal's +screen text, cursor, selection, scrolling, and rendering metrics to live +commands, so an agent can inspect the same terminal without image recognition. + +## First measurement + +Measured 2026-07-20 on Windows 11 Pro 10.0.26200, an AMD Ryzen Threadripper +3990X, Git 2.17.1.windows.1, and Release daScript binaries. Each result has five +unrecorded warmups and 30 recorded samples. + +The table reports launch-to-root-process-exit latency. This is the fair measure +of execution overhead; terminal `total` additionally includes the conservative +250 ms quiet period used to retain late ConHost output. + +| command | direct p50 / p95 | direct + log p50 / p95 | terminal + log p50 / p95 | terminal p50 overhead | +| --- | ---: | ---: | ---: | ---: | +| `git --version` | 40.2 / 51.0 ms | 39.3 / 49.9 ms | 44.9 / 60.4 ms | +4.7 ms, 1.12x | +| colored `git status --short --branch` | 71.5 / 79.0 ms | 71.3 / 85.5 ms | 87.2 / 92.0 ms | +15.7 ms, 1.22x | +| intentional missing ref | 42.6 / 46.4 ms | 42.9 / 50.5 ms | 58.5 / 59.9 ms | +15.9 ms, 1.37x | + +Raw-log-only median differences were -0.9 ms, -0.2 ms, and +0.3 ms. At this +sample size that is scheduler noise, so logging does not justify a separate +fast path. ConPTY and terminal emulation have a real but modest cost for these +short commands. We should repeat this probe after detach/IPC exists before +deciding whether that additional boundary needs optimization. + +Both successful commands returned zero through every lane. The missing-ref +command returned 128, and every lane contained the expected diagnostic. The +terminal logs also retained the forced ANSI color stream. diff --git a/utils/dasHerd/chunk0/benchmark_git_terminal.das b/utils/dasHerd/chunk0/benchmark_git_terminal.das new file mode 100644 index 0000000000..6aa632105b --- /dev/null +++ b/utils/dasHerd/chunk0/benchmark_git_terminal.das @@ -0,0 +1,202 @@ +options gen2 +options indenting = 4 + +require git_terminal_probe +require daslib/fio +require strings +require math + +struct BenchmarkOptions { + repository : string + warmup : int = 5 + rounds : int = 30 +} + +struct LaneSummary { + ok : bool + total_p50_us : int64 = -1l + total_p95_us : int64 = -1l + exit_p50_us : int64 = -1l + exit_p95_us : int64 = -1l + first_p50_us : int64 = -1l + first_p95_us : int64 = -1l +} + +def private parse_options() : BenchmarkOptions { + var cfg = BenchmarkOptions(repository = get_das_root()) + for (arg in get_command_line_arguments()) { + if (starts_with(arg, "--repo=")) { + cfg.repository = slice(arg, length("--repo=")) + } elif (starts_with(arg, "--warmup=")) { + cfg.warmup = max(0, to_int(slice(arg, length("--warmup=")))) + } elif (starts_with(arg, "--rounds=")) { + cfg.rounds = max(1, to_int(slice(arg, length("--rounds=")))) + } + } + return cfg +} + +def private lane_name(lane : GitProbeLane) : string { + if (lane == GitProbeLane.direct_capture) return "direct" + if (lane == GitProbeLane.direct_logged) return "direct+log" + return "terminal+log" +} + +def private percentile_us(var values : array; numerator : int) : int64 { + if (empty(values)) return -1l + sort(values) + let index = clamp((length(values) * numerator + 99) / 100 - 1, + 0, length(values) - 1) + return values[index] +} + +def private summarize(samples : array const) : LaneSummary { + var total : array + var exit : array + var first : array + total |> reserve(length(samples)) + exit |> reserve(length(samples)) + first |> reserve(length(samples)) + for (sample in samples) { + total |> push(sample.total_us) + exit |> push(sample.exit_us) + if (sample.first_output_us >= 0l) first |> push(sample.first_output_us) + } + return LaneSummary( + ok = true, + total_p50_us = percentile_us(total, 50), + total_p95_us = percentile_us(total, 95), + exit_p50_us = percentile_us(exit, 50), + exit_p95_us = percentile_us(exit, 95), + first_p50_us = percentile_us(first, 50), + first_p95_us = percentile_us(first, 95)) +} + +def private summary_text(summary : LaneSummary const) : string { + return ("total p50={summary.total_p50_us}us p95={summary.total_p95_us}us; " + + "exit p50={summary.exit_p50_us}us p95={summary.exit_p95_us}us; " + + "first p50={summary.first_p50_us}us p95={summary.first_p95_us}us") +} + +def private sample_tsv(sample : GitProbeSample const; iteration : int) : string { + return ("{sample.command}\t{lane_name(sample.lane)}\t{iteration}\t" + + "{sample.exit_code}\t{sample.output_bytes}\t{sample.launch_us}\t" + + "{sample.first_output_us}\t{sample.exit_us}\t{sample.drained_us}\t" + + "{sample.poll_feed_us}\t{sample.log_write_us}\t{sample.total_us}\t" + + "{sample.error}\n") +} + +def private sample_ok(command : GitProbeCommand const; + sample : GitProbeSample const) : bool { + if (!empty(sample.error)) return false + let exit_ok = command.expected_success ? sample.exit_code == 0l : sample.exit_code != 0l + return exit_ok && find(sample.output_text, command.expected_text) >= 0 +} + +def private run_lane(command : GitProbeCommand const; lane : GitProbeLane; + warmup, rounds : int; root : string; + var result_file : FILE const?) : LaneSummary { + var samples : array + samples |> reserve(rounds) + let prefix = "{command.name}_{lane_name(lane)}" + for (iteration in range(warmup + rounds)) { + let measured = iteration >= warmup + let log_path = (lane == GitProbeLane.direct_capture ? "" : + path_join(root, "logs/{prefix}_{iteration}.raw")) + var sample <- (lane == GitProbeLane.terminal_logged ? + run_terminal_probe(command, log_path) : + run_direct_probe(command, log_path)) + if (!sample_ok(command, sample)) { + print(("FAIL {command.name}/{lane_name(lane)}: rc={sample.exit_code} " + + "error={sample.error}\n")) + return LaneSummary() + } + if (measured) { + if (result_file != null) { + fwrite(result_file, sample_tsv(sample, iteration - warmup)) + } + samples |> emplace(sample) + } + // Preserve one representative raw log per logged lane; remove warmup + // and intermediate logs after their measured writes have completed. + if (!empty(log_path) && iteration != warmup + rounds - 1) { + var remove_error : string + let _ = remove(log_path, remove_error) + } + } + let summary = summarize(samples) + print(("{command.name}/{lane_name(lane)}: {summary_text(summary)}; " + + "bytes={samples[length(samples) - 1].output_bytes}; " + + "rc={samples[length(samples) - 1].exit_code}\n")) + return summary +} + +def private overhead_text(base, measured : int64) : string { + let delta = measured - base + let ratio = base > 0l ? double(measured) / double(base) : 0.0lf + return "{delta}us ({fmt(":.2f", ratio)}x)" +} + +[export] +def main() : int { + let cfg = parse_options() + if (!fexist(cfg.repository)) { + print("repository does not exist: {cfg.repository}\n") + return 2 + } + let temp = create_temp_directory_result("dasherd_git_terminal_chunk0_") + if (!(temp is value)) { + print("could not create benchmark directory: {temp as error}\n") + return 2 + } + let root = temp as value + var mkdir_error : string + if (!mkdir(path_join(root, "logs"), mkdir_error)) { + print("could not create log directory: {mkdir_error}\n") + return 2 + } + let result_path = path_join(root, "samples.tsv") + var result_file = fopen(result_path, "wb") + if (result_file == null) { + print("could not create results file: {result_path}\n") + return 2 + } + fwrite(result_file, + ("command\tlane\titeration\texit_code\toutput_bytes\tlaunch_us\t" + + "first_output_us\texit_us\tdrained_us\tpoll_feed_us\tlog_write_us\t" + + "total_us\terror\n")) + + print("Chunk 0 local Git terminal overhead\n") + print("repository: {cfg.repository}\n") + print("warmup={cfg.warmup}, rounds={cfg.rounds}\n") + print("drain policy: 250000us quiet after root-process exit\n") + var ok = true + for (command in make_git_probe_commands(cfg.repository)) { + var summaries : array + summaries |> reserve(3) + for (lane in [GitProbeLane.direct_capture, + GitProbeLane.direct_logged, + GitProbeLane.terminal_logged]) { + let summary = run_lane(command, lane, cfg.warmup, cfg.rounds, + root, result_file) + summaries |> push(summary) + if (!summary.ok) { + ok = false + break + } + } + if (ok && length(summaries) == 3) { + print(("{command.name}/overhead at process exit: log=" + + overhead_text(summaries[0].exit_p50_us, summaries[1].exit_p50_us) + + "; terminal+log=" + + overhead_text(summaries[0].exit_p50_us, summaries[2].exit_p50_us) + + "\n")) + } + break if (!ok) + } + fflush(result_file) + fclose(result_file) + print("samples: {result_path}\n") + print("representative logs: {path_join(root, "logs")}\n") + return ok ? 0 : 1 +} diff --git a/utils/dasHerd/chunk0/git_terminal_probe.das b/utils/dasHerd/chunk0/git_terminal_probe.das new file mode 100644 index 0000000000..1bc9e57ccf --- /dev/null +++ b/utils/dasHerd/chunk0/git_terminal_probe.das @@ -0,0 +1,234 @@ +options gen2 +options indenting = 4 + +module git_terminal_probe shared + +require terminal/terminal public +require daslib/fio public +require daslib/strings_boost +require strings public + +enum public GitProbeLane { + direct_capture + direct_logged + terminal_logged +} + +struct public GitProbeCommand { + name : string + argv : array + command_line : string + expected_text : string + expected_success : bool = true +} + +struct public GitProbeSample { + command : string + lane : GitProbeLane + exit_code : int64 = -1l + output_bytes : int + launch_us : int64 = -1l + first_output_us : int64 = -1l + exit_us : int64 = -1l + drained_us : int64 = -1l + poll_feed_us : int64 + log_write_us : int64 + total_us : int64 + log_path : string + output_text : string + error : string +} + +def private elapsed_us(started : int64) : int64 { + return get_time_nsec(started) / 1000l +} + +def private quote_terminal_arg(arg : string) : string { + if (empty(arg)) return "\"\"" + if (find(arg, " ") < 0 && find(arg, "\t") < 0 && find(arg, "\"") < 0) { + return arg + } + // Chunk 0 targets Windows ConPTY. Windows paths cannot contain a quote; + // reject that uncommon case instead of pretending this is a general shell + // command-line encoder. + if (find(arg, "\"") >= 0) return "" + return "\"{arg}\"" +} + +def public make_terminal_command_line(argv : array const) : string { + var parts : array + parts |> reserve(length(argv)) + for (arg in argv) { + let quoted = quote_terminal_arg(arg) + if (empty(quoted)) return "" + parts |> push(quoted) + } + return join(parts, " ") +} + +def private make_command(name : string; argv : array; + expected_text : string; + expected_success : bool = true) : GitProbeCommand { + var owned_argv : array + owned_argv := argv + return GitProbeCommand( + name = name, + command_line = make_terminal_command_line(owned_argv), + argv <- owned_argv, + expected_text = expected_text, + expected_success = expected_success) +} + +def public make_git_probe_commands(repository : string) : array { + return <- [ + <- make_command("version", ["git", "--version"], "git version"), + <- make_command("status", [ + "git", "-C", repository, "-c", "color.ui=always", + "status", "--short", "--branch"], "##"), + <- make_command("missing-ref", [ + "git", "-C", repository, "-c", "color.ui=always", + "rev-parse", "refs/heads/__dasherd_chunk0_missing_ref__"], + "fatal: ambiguous argument", false)] +} + +def public run_direct_probe(command : GitProbeCommand const; + log_path : string = "") : GitProbeSample { + var sample = GitProbeSample( + command = command.name, + lane = empty(log_path) ? GitProbeLane.direct_capture : GitProbeLane.direct_logged, + log_path = log_path) + var first_byte : array + first_byte |> resize(1) + var first_count = 0 + var remainder : string + let started = ref_time_ticks() + let rc = unsafe(popen_argv(command.argv, 0.0, $(f) { + sample.launch_us = elapsed_us(started) + if (f == null) { + sample.error = "popen_argv returned a null output stream" + return + } + first_count = fread(f, first_byte) + if (first_count > 0) { + sample.first_output_us = elapsed_us(started) + } + remainder = unsafe(fread_to_eof(f)) + })) + sample.exit_us = elapsed_us(started) + sample.drained_us = sample.exit_us + sample.exit_code = int64(rc) + sample.output_bytes = first_count + length(remainder) + sample.output_text = (first_count > 0 ? to_char(int(first_byte[0])) : "") + remainder + if (!empty(log_path)) { + let log_started = ref_time_ticks() + var log_file = fopen(log_path, "wb") + if (log_file == null) { + sample.error = "could not open log: {log_path}" + } else { + if (first_count > 0) { + let _ = fwrite(log_file, first_byte) + } + if (!empty(remainder)) { + fwrite(log_file, remainder) + } + fflush(log_file) + fclose(log_file) + } + sample.log_write_us = elapsed_us(log_started) + } + sample.total_us = elapsed_us(started) + return <- sample +} + +def public run_terminal_probe(command : GitProbeCommand const; + log_path : string; + drain_quiet_us : int64 = 250000l; + timeout_us : int64 = 10000000l) : GitProbeSample { + var sample = GitProbeSample( + command = command.name, + lane = GitProbeLane.terminal_logged, + log_path = log_path) + var log_file = fopen(log_path, "wb") + if (log_file == null) { + sample.error = "could not open log: {log_path}" + return <- sample + } + + let started = ref_time_ticks() + var inscope terminal <- terminal_launch(command.command_line, "", 100, 30) + sample.launch_us = elapsed_us(started) + if (terminal_process_id(terminal) == 0) { + sample.error = terminal_transport_error(terminal) + sample.total_us = elapsed_us(started) + fclose(log_file) + return <- sample + } + + var exit_seen = false + var last_data_us = 0l + while (true) { + let poll_started = ref_time_ticks() + let poll_status = terminal_poll(terminal) + sample.poll_feed_us += elapsed_us(poll_started) + let now_us = elapsed_us(started) + if (poll_status == TerminalPollStatus.data) { + if (sample.first_output_us < 0l) { + sample.first_output_us = now_us + } + sample.output_bytes += length(terminal.read_buffer) + last_data_us = now_us + let log_started = ref_time_ticks() + let _ = fwrite(log_file, terminal.read_buffer) + sample.log_write_us += elapsed_us(log_started) + } elif (poll_status == TerminalPollStatus.error) { + sample.error = terminal_transport_error(terminal) + break + } + + if (!exit_seen && terminal_process_exited(terminal)) { + exit_seen = true + sample.exit_us = now_us + sample.exit_code = terminal_exit_code(terminal) + if (last_data_us == 0l) { + last_data_us = now_us + } + } + + if (poll_status == TerminalPollStatus.closed) { + sample.drained_us = now_us + break + } + // ConPTY keeps its output pipe open after the root process exits. A + // bounded quiet interval after the last observed bytes is the Chunk 0 + // drain policy. ConHost can flush noticeably after root exit when the + // UI polls once per frame; exit_us remains the apples-to-apples primary + // metric and drained_us reports this conservative logging policy. + if (exit_seen && now_us - last_data_us >= drain_quiet_us) { + sample.drained_us = now_us + break + } + if (now_us >= timeout_us) { + sample.error = "terminal probe timed out" + if (!exit_seen) { + let _ = terminal_terminate(terminal) + } + break + } + sleep(0u) + } + + fflush(log_file) + fclose(log_file) + sample.total_us = elapsed_us(started) + if (sample.drained_us < 0l) { + sample.drained_us = sample.total_us + } + if (sample.exit_code < 0l && terminal_process_exited(terminal)) { + sample.exit_code = terminal_exit_code(terminal) + sample.exit_us = elapsed_us(started) + } + var viewport = TerminalViewportSnapshot() + terminal_viewport_snapshot(terminal, 0, viewport) + sample.output_text = viewport.screen_text + return <- sample +} diff --git a/utils/dasHerd/chunk0/git_terminal_window.das b/utils/dasHerd/chunk0/git_terminal_window.das new file mode 100644 index 0000000000..0f062463b9 --- /dev/null +++ b/utils/dasHerd/chunk0/git_terminal_window.das @@ -0,0 +1,203 @@ +options gen2 +options gc +options persistent_heap + +require imgui/imgui_harness +require imgui/imgui_terminal +require imgui/imgui_fonts +require daslib/safe_addr +require git_terminal_probe +require math +require strings + +var private g_terminal = Terminal() +var private g_terminal_view = ImGuiTerminalState() +var private g_terminal_cache = ImGuiTerminalCache() +var private g_terminal_font : ImFont? +var private g_font_sources : array +var private g_log_file : FILE const? = null +var private g_log_path : string +var private g_command_name : string +var private g_command_line : string +var private g_zoom_percent = 150 +var private g_started = 0l +var private g_launch_us = -1l +var private g_first_output_us = -1l +var private g_exit_us = -1l +var private g_drained_us = -1l +var private g_last_data_us = 0l +var private g_output_bytes = 0 +var private g_exit_code = -1l +var private g_done = false +var private g_error : string + +def private elapsed_us() : int64 { + return get_time_nsec(g_started) / 1000l +} + +def private option_value(prefix : string; fallback : string) : string { + for (arg in get_command_line_arguments()) { + if (starts_with(arg, prefix)) return slice(arg, length(prefix)) + } + return fallback +} + +def private select_command(repository : string) { + let requested = option_value("--probe=", "status") + let commands <- make_git_probe_commands(repository) + var index = 1 + if (requested == "version") { + index = 0 + } elif (requested == "missing-ref") { + index = 2 + } + g_command_name = commands[index].name + g_command_line = commands[index].command_line +} + +def private zoom_by(delta : int) { + g_zoom_percent = clamp(g_zoom_percent + delta, 50, 300) +} + +def private poll_terminal() { + return if (g_done) + for (_ in range(64)) { + let status = terminal_poll(g_terminal) + let now_us = elapsed_us() + if (status == TerminalPollStatus.data) { + if (g_first_output_us < 0l) { + g_first_output_us = now_us + } + g_last_data_us = now_us + g_output_bytes += length(g_terminal.read_buffer) + if (g_log_file != null) { + let _written = fwrite(g_log_file, g_terminal.read_buffer) + fflush(g_log_file) + } + } elif (status == TerminalPollStatus.error) { + g_error = terminal_transport_error(g_terminal) + g_done = true + break + } + if (g_exit_us < 0l && terminal_process_exited(g_terminal)) { + g_exit_us = now_us + g_exit_code = terminal_exit_code(g_terminal) + if (g_last_data_us == 0l) { + g_last_data_us = now_us + } + } + if (status == TerminalPollStatus.closed) { + g_drained_us = now_us + g_done = true + break + } + if (g_exit_us >= 0l && now_us - g_last_data_us >= 250000l) { + g_drained_us = now_us + g_done = true + break + } + break if (status == TerminalPollStatus.idle) + } +} + +[export] +def init() { + harness_init("dasHerd Chunk 0 - Git terminal", 1100, 720) + harness_maximize_window() + g_terminal_font = load_unicode_ui_font(14.0f, g_font_sources) + let repository = option_value("--repo=", get_das_root()) + select_command(repository) + let log_dir = path_join(get_das_root(), "logs/dasHerd") + var mkdir_error : string + let _ = mkdir_rec(log_dir, mkdir_error) + g_log_path = path_join(log_dir, "chunk0_{g_command_name}.raw") + g_log_file = fopen(g_log_path, "wb") + if (g_log_file == null) { + g_error = "could not open raw log: {g_log_path}" + } + g_started = ref_time_ticks() + var inscope launched <- terminal_launch(g_command_line, "", 100, 30) + g_terminal := launched + g_launch_us = elapsed_us() + if (terminal_process_id(g_terminal) == 0) { + g_error = terminal_transport_error(g_terminal) + g_done = true + } +} + +[export] +def update() { + if (!harness_begin_frame()) return + poll_terminal() + harness_new_frame() + GetStyle().FontScaleMain = float(g_zoom_percent) / 100.0f + let viewport = GetMainViewport() + SetNextWindowPos(viewport.WorkPos, ImGuiCond.Always) + SetNextWindowSize(viewport.WorkSize, ImGuiCond.Always) + window(CHUNK0_WIN, (text = "dasHerd Chunk 0 - Git terminal", + closable = false, + flags = ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) { + text("Git through ConPTY + terminal emulator + raw log") + text_disabled("{g_command_line}") + separator() + let io & = unsafe(GetIO()) + if (IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows) && io.KeyCtrl) { + if (io.MouseWheel > 0.0f) { + zoom_by(5) + } elif (io.MouseWheel < 0.0f) { + zoom_by(-5) + } + } + let terminal_style = ImGuiTerminalStyle( + view_height = max(GetTextLineHeight(), + GetContentRegionAvail().y - GetFrameHeightWithSpacing() * 2.0f + - GetStyle().ItemSpacing.y * 2.0f)) + with_font(g_terminal_font) { + let _ = imgui_terminal("git", g_terminal, g_terminal_view, + g_terminal_cache, terminal_style) + } + separator() + text_show(CHUNK0_METRICS, ("exit={g_exit_code} bytes={g_output_bytes} " + + "launch={g_launch_us}us first={g_first_output_us}us " + + "exit_seen={g_exit_us}us drained={g_drained_us}us")) + text_disabled("raw log: {g_log_path}") + if (!empty(g_error)) { + text_colored(CHUNK0_ERROR, + (color = float4(1.0f, 0.35f, 0.30f, 1.0f), text = g_error)) + } + if (small_button(CHUNK0_ZOOM_OUT, (text = "-5%"))) { + zoom_by(-5) + } + same_line() + if (small_button(CHUNK0_ZOOM_IN, (text = "+5%"))) { + zoom_by(5) + } + same_line() + text("Zoom {g_zoom_percent}%") + } + harness_end_frame() +} + +[export] +def shutdown() { + if (!terminal_process_exited(g_terminal)) { + let _ = terminal_terminate(g_terminal) + } + if (g_log_file != null) { + fflush(g_log_file) + fclose(g_log_file) + g_log_file = null + } + terminal_dispose(g_terminal) + harness_shutdown() +} + +[export] +def main() { + init() + while (!exit_requested()) { + update() + harness_maybe_collect_gc() + } + shutdown() +} From 18c544fb18948bc394805a3c5e60787574831f66 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 09:36:43 -0700 Subject: [PATCH 04/20] Show Git invocation in terminal view --- utils/dasHerd/chunk0/README.md | 13 ++++++++----- utils/dasHerd/chunk0/git_terminal_window.das | 4 +++- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/utils/dasHerd/chunk0/README.md b/utils/dasHerd/chunk0/README.md index 4609904d28..10cdc89c8e 100644 --- a/utils/dasHerd/chunk0/README.md +++ b/utils/dasHerd/chunk0/README.md @@ -22,11 +22,14 @@ Run the visible terminal probe with the local dasImgui module: bin\Release\daslang-live.exe -load_module modules\dasLiveHost -load_module modules\dasImgui utils\dasHerd\chunk0\git_terminal_window.das ``` -The visible probe starts maximized at 150% zoom, displays colored Git output, -and writes its raw byte stream under `logs/dasHerd/`. Ctrl+mouse-wheel and the +The visible probe starts maximized at 150% zoom, displays the invocation in a +dim `$ command` visual header followed by colored Git output, and writes the +process's raw byte stream under `logs/dasHerd/`. The header is terminal view +state, separate from emulator state and the raw log. Ctrl+mouse-wheel and the bottom buttons change zoom in 5% steps. `imgui_snapshot` exposes the terminal's -screen text, cursor, selection, scrolling, and rendering metrics to live -commands, so an agent can inspect the same terminal without image recognition. +`visual_header`, screen text, cursor, selection, scrolling, and rendering +metrics to live commands, so an agent can inspect the same terminal without +image recognition. ## First measurement @@ -40,7 +43,7 @@ of execution overhead; terminal `total` additionally includes the conservative | command | direct p50 / p95 | direct + log p50 / p95 | terminal + log p50 / p95 | terminal p50 overhead | | --- | ---: | ---: | ---: | ---: | -| `git --version` | 40.2 / 51.0 ms | 39.3 / 49.9 ms | 44.9 / 60.4 ms | +4.7 ms, 1.12x | +| `git --version` | 40.2 / 51.0 ms | 39.3 / 49.9 ms | 44.9 / 60.4 ms | +4.7 ms vs 40.2 ms, 1.12x | | colored `git status --short --branch` | 71.5 / 79.0 ms | 71.3 / 85.5 ms | 87.2 / 92.0 ms | +15.7 ms, 1.22x | | intentional missing ref | 42.6 / 46.4 ms | 42.9 / 50.5 ms | 58.5 / 59.9 ms | +15.9 ms, 1.37x | diff --git a/utils/dasHerd/chunk0/git_terminal_window.das b/utils/dasHerd/chunk0/git_terminal_window.das index 0f062463b9..35a3ea4453 100644 --- a/utils/dasHerd/chunk0/git_terminal_window.das +++ b/utils/dasHerd/chunk0/git_terminal_window.das @@ -19,6 +19,7 @@ var private g_log_file : FILE const? = null var private g_log_path : string var private g_command_name : string var private g_command_line : string +var private g_visual_header : string var private g_zoom_percent = 150 var private g_started = 0l var private g_launch_us = -1l @@ -53,6 +54,7 @@ def private select_command(repository : string) { } g_command_name = commands[index].name g_command_line = commands[index].command_line + g_visual_header = "$ {g_command_line}" } def private zoom_by(delta : int) { @@ -138,7 +140,6 @@ def update() { closable = false, flags = ImGuiWindowFlags.NoResize | ImGuiWindowFlags.NoMove)) { text("Git through ConPTY + terminal emulator + raw log") - text_disabled("{g_command_line}") separator() let io & = unsafe(GetIO()) if (IsWindowHovered(ImGuiHoveredFlags.RootAndChildWindows) && io.KeyCtrl) { @@ -149,6 +150,7 @@ def update() { } } let terminal_style = ImGuiTerminalStyle( + visual_header = g_visual_header, view_height = max(GetTextLineHeight(), GetContentRegionAvail().y - GetFrameHeightWithSpacing() * 2.0f - GetStyle().ItemSpacing.y * 2.0f)) From 187738527d1bf2a6a00d67ab49a5a156eb0cb215 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 10:09:26 -0700 Subject: [PATCH 05/20] Measure isolated session host overhead --- utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md | 22 ++ utils/dasHerd/chunk1/README.md | 60 ++++ .../dasHerd/chunk1/benchmark_session_host.das | 315 ++++++++++++++++++ utils/dasHerd/chunk1/session_host_worker.das | 74 ++++ 4 files changed, 471 insertions(+) create mode 100644 utils/dasHerd/chunk1/README.md create mode 100644 utils/dasHerd/chunk1/benchmark_session_host.das create mode 100644 utils/dasHerd/chunk1/session_host_worker.das diff --git a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md index 2144baab85..bb1205a8be 100644 --- a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md +++ b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md @@ -116,6 +116,28 @@ same probe is rerun through detach/IPC after the minimal host exists. No capture-mode or short-command optimization is selected until these numbers exist. +The first measurement found raw logging indistinguishable from noise and +in-process terminal overhead of roughly 5-16 ms at process exit. dasHerd keeps +one terminal execution path; it does not add a direct-capture fast path. The +conservative post-exit drain must not block result delivery or scheduling. + +### Chunk 1: measure an isolated session process + +Discussion status: agreed; first measurement complete + +The first process-isolation probe launches a fresh interpreted daScript worker +for every session. The worker owns ConPTY, terminal emulation, and logging and +acknowledges readiness over a pipe. This deliberately simple prototype measures +the complete extra process and runtime boundary before it becomes architecture. + +The fresh worker added roughly 334-345 ms at median and peaked near 71 MiB +working set in one status sample. Almost all latency occurred before the worker +ready acknowledgement; terminal execution inside the worker continued to match +the in-process lane. Therefore an interpreted worker compiled afresh per Git +query is rejected. Per-session isolation itself remains open pending the same +probe with a small AOT/native host; a persistent per-worktree host is the next +fallback, not an unmeasured optimization. + ## Section 1: repository, base, and worktree identity Discussion status: open diff --git a/utils/dasHerd/chunk1/README.md b/utils/dasHerd/chunk1/README.md new file mode 100644 index 0000000000..06bb61d06f --- /dev/null +++ b/utils/dasHerd/chunk1/README.md @@ -0,0 +1,60 @@ +# Chunk 1: fresh process-per-session overhead + +This probe measures the deliberately simplest isolated topology before dasHerd +commits to it. For every command, the benchmark launches a fresh +`daslang.exe` worker. That worker compiles the session-host script, acknowledges +readiness through a flushed stdout pipe, and then owns the same ConPTY, +renderless terminal emulator, and raw log used by Chunk 0. + +The three contemporaneous lanes are: + +1. direct process capture plus raw log; +2. in-process ConPTY, terminal emulation, and raw log; +3. a fresh daScript worker process containing lane 2, with a minimal pipe + acknowledgement standing in for the attach protocol. + +Run from the repository root: + +```text +bin\Release\daslang.exe utils\dasHerd\chunk1\benchmark_session_host.das -- --warmup=5 --rounds=30 +``` + +Add `--discard` for disposable smoke runs. The worker's ready timestamp +includes OS process creation, daScript startup, compilation, module +initialization, and delivery of the first IPC byte. Hosted exit time is ready +time plus the worker's independently measured launch-to-target-exit time. The +250 ms conservative ConHost drain is included only in worker total, not hosted +exit. + +## First measurement + +Measured 2026-07-20 on the same machine and Release build as Chunk 0, using +five unrecorded warmups and 30 recorded samples per command. + +| command | direct exit p50 / p95 | in-process terminal p50 / p95 | fresh host p50 / p95 | fresh-host delta | +| --- | ---: | ---: | ---: | ---: | +| `git --version` | 42.1 / 54.7 ms | 56.0 / 69.0 ms | 389.9 / 437.2 ms | +333.8 ms | +| colored `git status --short --branch` | 74.0 / 86.5 ms | 89.5 / 99.5 ms | 432.6 / 525.8 ms | +343.0 ms | +| intentional missing ref | 45.7 / 57.6 ms | 57.8 / 70.2 ms | 402.4 / 514.7 ms | +344.6 ms | + +The worker-ready median was 339.8-340.4 ms. Its p95 was 376.7 ms for the first +command and rose to 435.8-450.8 ms during the longer repeated run. The terminal +operation inside the worker remained close to the in-process result: 55.3, +88.8, and 59.6 ms respectively. The additional latency is therefore fresh +daScript process startup and script compilation, not the PTY, emulator, log, +or pipe protocol. + +One separately sampled status worker peaked at approximately 70.8 MiB working +set and 52.5 MiB private memory. This is an interpreted compiler process, so it +is an upper bound rather than a prediction for a small AOT/native host. + +## Decision supported by this probe + +Do not launch and compile an interpreted daScript worker for every short +command. That implementation adds roughly a third of a second per session and +substantial transient memory. + +This result does not reject per-session process isolation. The next fair +topology measurement is a small AOT/native session-host bootstrap running the +same daScript session core. A persistent per-worktree host is the fallback if +an isolated compiled host still costs too much; pooling is not justified yet. diff --git a/utils/dasHerd/chunk1/benchmark_session_host.das b/utils/dasHerd/chunk1/benchmark_session_host.das new file mode 100644 index 0000000000..bd9c6f3bd5 --- /dev/null +++ b/utils/dasHerd/chunk1/benchmark_session_host.das @@ -0,0 +1,315 @@ +options gen2 +options indenting = 4 + +require ../chunk0/git_terminal_probe.das +require daslib/fio +require daslib/strings_boost +require strings +require math + +struct BenchmarkOptions { + repository : string + warmup : int = 5 + rounds : int = 30 + discard : bool +} + +struct HostedSample { + ok : bool + exit_code : int64 = -1l + ready_us : int64 = -1l + hosted_exit_us : int64 = -1l + inner_exit_us : int64 = -1l + inner_total_us : int64 = -1l + outer_total_us : int64 = -1l + output_bytes : int + output_text : string + error : string +} + +struct ComparisonSummary { + direct_exit_p50 : int64 + direct_exit_p95 : int64 + terminal_exit_p50 : int64 + terminal_exit_p95 : int64 + host_ready_p50 : int64 + host_ready_p95 : int64 + hosted_exit_p50 : int64 + hosted_exit_p95 : int64 + hosted_inner_p50 : int64 + hosted_outer_p50 : int64 +} + +def private elapsed_us(started : int64) : int64 { + return get_time_nsec(started) / 1000l +} + +def private parse_options() : BenchmarkOptions { + var cfg = BenchmarkOptions(repository = get_das_root()) + for (arg in get_command_line_arguments()) { + if (starts_with(arg, "--repo=")) { + cfg.repository = slice(arg, length("--repo=")) + } elif (starts_with(arg, "--warmup=")) { + cfg.warmup = max(0, to_int(slice(arg, length("--warmup=")))) + } elif (starts_with(arg, "--rounds=")) { + cfg.rounds = max(1, to_int(slice(arg, length("--rounds=")))) + } elif (arg == "--discard") { + cfg.discard = true + } + } + return cfg +} + +def private percentile_us(var values : array; numerator : int) : int64 { + if (empty(values)) return -1l + sort(values) + let index = clamp((length(values) * numerator + 99) / 100 - 1, + 0, length(values) - 1) + return values[index] +} + +def private read_file(path : string; var error : string) : string { + var file = fopen(path, "rb") + if (file == null) { + error = "could not read {path}" + return "" + } + let result = unsafe(fread_to_eof(file)) + fclose(file) + return result +} + +def private parse_host_result(output : string; var sample : HostedSample) { + for (line in split_by_chars(output, "\n")) { + if (!starts_with(line, "DASHERD_HOST_RESULT\t")) continue + let fields <- split_by_chars(strip(line), "\t") + if (length(fields) != 10) { + sample.error = "malformed host result: {line}" + return + } + sample.exit_code = to_int64(fields[1]) + sample.inner_exit_us = to_int64(fields[4]) + sample.inner_total_us = to_int64(fields[8]) + sample.output_bytes = to_int(fields[9]) + sample.ok = true + return + } + sample.error = "session host produced no result: {output}" +} + +def private run_hosted_probe(command : GitProbeCommand const; + repository, log_path, screen_path : string) : HostedSample { + var sample = HostedSample() + let root = get_das_root() + let executable = path_join(root, "bin/Release/daslang.exe") + let worker = path_join(root, + "utils/dasHerd/chunk1/session_host_worker.das") + let argv = [executable, worker, "--", "--repo={repository}", + "--probe={command.name}", "--log={log_path}", "--screen={screen_path}"] + var first_byte : array + first_byte |> resize(1) + var first_count = 0 + var remainder : string + let started = ref_time_ticks() + let worker_rc = unsafe(popen_argv(argv, 0.0, $(f) { + if (f == null) { + sample.error = "session host returned a null output stream" + return + } + first_count = fread(f, first_byte) + if (first_count > 0) { + sample.ready_us = elapsed_us(started) + } + remainder = unsafe(fread_to_eof(f)) + })) + sample.outer_total_us = elapsed_us(started) + let output = (first_count > 0 ? to_char(int(first_byte[0])) : "") + remainder + if (worker_rc != 0) { + sample.error = "session host exited {worker_rc}: {output}" + return <- sample + } + parse_host_result(output, sample) + if (!sample.ok) return <- sample + sample.hosted_exit_us = sample.ready_us + sample.inner_exit_us + sample.output_text = read_file(screen_path, sample.error) + sample.ok = empty(sample.error) + return <- sample +} + +def private expected(command : GitProbeCommand const; exit_code : int64; + output : string; error : string) : bool { + if (!empty(error)) return false + let exit_ok = command.expected_success ? exit_code == 0l : exit_code != 0l + return exit_ok && find(output, command.expected_text) >= 0 +} + +def private remove_if_present(path : string) { + if (empty(path) || !fexist(path)) return + var error : string + let _ = remove(path, error) +} + +def private collect_exit(samples : array const) : array { + var values : array + values |> reserve(length(samples)) + for (sample in samples) { + values |> push(sample.exit_us) + } + return <- values +} + +def private collect_host(samples : array const; + selector : int) : array { + var values : array + values |> reserve(length(samples)) + for (sample in samples) { + if (selector == 0) { + values |> push(sample.ready_us) + } elif (selector == 1) { + values |> push(sample.hosted_exit_us) + } elif (selector == 2) { + values |> push(sample.inner_exit_us) + } else { + values |> push(sample.outer_total_us) + } + } + return <- values +} + +def private summarize(direct, terminal : array const; + hosted : array const) : ComparisonSummary { + var direct_exit <- collect_exit(direct) + var terminal_exit <- collect_exit(terminal) + var host_ready <- collect_host(hosted, 0) + var hosted_exit <- collect_host(hosted, 1) + var hosted_inner <- collect_host(hosted, 2) + var hosted_outer <- collect_host(hosted, 3) + return ComparisonSummary( + direct_exit_p50 = percentile_us(direct_exit, 50), + direct_exit_p95 = percentile_us(direct_exit, 95), + terminal_exit_p50 = percentile_us(terminal_exit, 50), + terminal_exit_p95 = percentile_us(terminal_exit, 95), + host_ready_p50 = percentile_us(host_ready, 50), + host_ready_p95 = percentile_us(host_ready, 95), + hosted_exit_p50 = percentile_us(hosted_exit, 50), + hosted_exit_p95 = percentile_us(hosted_exit, 95), + hosted_inner_p50 = percentile_us(hosted_inner, 50), + hosted_outer_p50 = percentile_us(hosted_outer, 50)) +} + +def private run_command(command : GitProbeCommand const; cfg : BenchmarkOptions const; + root : string; var results : FILE const?) : bool { + var direct : array + var terminal : array + var hosted : array + direct |> reserve(cfg.rounds) + terminal |> reserve(cfg.rounds) + hosted |> reserve(cfg.rounds) + for (iteration in range(cfg.warmup + cfg.rounds)) { + let measured = iteration >= cfg.warmup + let suffix = "{command.name}_{iteration}" + let direct_log = path_join(root, "logs/{suffix}_direct.raw") + let terminal_log = path_join(root, "logs/{suffix}_terminal.raw") + let hosted_log = path_join(root, "logs/{suffix}_hosted.raw") + let screen_path = path_join(root, "{suffix}.screen") + var direct_sample <- run_direct_probe(command, direct_log) + var terminal_sample <- run_terminal_probe(command, terminal_log) + var hosted_sample <- run_hosted_probe(command, cfg.repository, + hosted_log, screen_path) + if (!expected(command, direct_sample.exit_code, + direct_sample.output_text, direct_sample.error) + || !expected(command, terminal_sample.exit_code, + terminal_sample.output_text, terminal_sample.error) + || !expected(command, hosted_sample.exit_code, + hosted_sample.output_text, hosted_sample.error)) { + print(("FAIL {command.name} iteration {iteration}: " + + "direct='{direct_sample.error}' terminal='{terminal_sample.error}' " + + "hosted='{hosted_sample.error}'\n")) + return false + } + if (measured) { + direct |> emplace(direct_sample) + terminal |> emplace(terminal_sample) + hosted |> emplace(hosted_sample) + if (results != null) { + let index = iteration - cfg.warmup + fwrite(results, ("{command.name}\t{index}\t" + + "{direct[length(direct) - 1].exit_us}\t" + + "{terminal[length(terminal) - 1].exit_us}\t" + + "{hosted[length(hosted) - 1].ready_us}\t" + + "{hosted[length(hosted) - 1].inner_exit_us}\t" + + "{hosted[length(hosted) - 1].hosted_exit_us}\t" + + "{hosted[length(hosted) - 1].outer_total_us}\n")) + } + } + remove_if_present(screen_path) + if (iteration != cfg.warmup + cfg.rounds - 1) { + remove_if_present(direct_log) + remove_if_present(terminal_log) + remove_if_present(hosted_log) + } + } + + let summary = summarize(direct, terminal, hosted) + let terminal_overhead = summary.terminal_exit_p50 - summary.direct_exit_p50 + let host_overhead = summary.hosted_exit_p50 - summary.terminal_exit_p50 + print(("{command.name}: direct={summary.direct_exit_p50}us " + + "(p95 {summary.direct_exit_p95}); in-process terminal=" + + "{summary.terminal_exit_p50}us (p95 {summary.terminal_exit_p95}); " + + "session host={summary.hosted_exit_p50}us " + + "(p95 {summary.hosted_exit_p95})\n")) + print(("{command.name}: terminal delta={terminal_overhead}us; " + + "fresh-host delta={host_overhead}us; host ready=" + + "{summary.host_ready_p50}us (p95 {summary.host_ready_p95}); " + + "inner terminal={summary.hosted_inner_p50}us; " + + "worker total including drain={summary.hosted_outer_p50}us\n")) + return true +} + +[export] +def main() : int { + let cfg = parse_options() + let temp = create_temp_directory_result("dasherd_session_host_chunk1_") + if (!(temp is value)) { + print("could not create benchmark directory: {temp as error}\n") + return 2 + } + let root = temp as value + var mkdir_error : string + if (!mkdir(path_join(root, "logs"), mkdir_error)) { + print("could not create log directory: {mkdir_error}\n") + return 2 + } + let result_path = path_join(root, "samples.tsv") + var results = fopen(result_path, "wb") + if (results == null) { + print("could not create result file: {result_path}\n") + return 2 + } + fwrite(results, ("command\titeration\tdirect_exit_us\tterminal_exit_us\t" + + "host_ready_us\thost_inner_exit_us\thosted_exit_us\thost_total_us\n")) + print("Chunk 1 fresh process-per-session overhead\n") + print("repository: {cfg.repository}\n") + print("warmup={cfg.warmup}, rounds={cfg.rounds}\n") + var ok = true + for (command in make_git_probe_commands(cfg.repository)) { + if (!run_command(command, cfg, root, results)) { + ok = false + break + } + } + fflush(results) + fclose(results) + if (cfg.discard && ok) { + let removed = rmdir_rec_result(root) + if (!(removed is value)) { + print("could not discard benchmark directory: {removed as error}\n") + return 2 + } + print("benchmark artifacts discarded\n") + } else { + print("samples: {result_path}\n") + print("representative logs: {path_join(root, "logs")}\n") + } + return ok ? 0 : 1 +} diff --git a/utils/dasHerd/chunk1/session_host_worker.das b/utils/dasHerd/chunk1/session_host_worker.das new file mode 100644 index 0000000000..81a605c5e7 --- /dev/null +++ b/utils/dasHerd/chunk1/session_host_worker.das @@ -0,0 +1,74 @@ +options gen2 +options indenting = 4 + +require ../chunk0/git_terminal_probe.das +require daslib/fio +require strings + +struct WorkerOptions { + repository : string + probe : string = "status" + log_path : string + screen_path : string +} + +def private option_value(prefix : string; fallback : string = "") : string { + for (arg in get_command_line_arguments()) { + if (starts_with(arg, prefix)) return slice(arg, length(prefix)) + } + return fallback +} + +def private parse_options() : WorkerOptions { + return WorkerOptions( + repository = option_value("--repo=", get_das_root()), + probe = option_value("--probe=", "status"), + log_path = option_value("--log="), + screen_path = option_value("--screen=")) +} + +def private command_index(name : string) : int { + if (name == "version") return 0 + if (name == "missing-ref") return 2 + return 1 +} + +[export] +def main() : int { + let cfg = parse_options() + if (empty(cfg.log_path) || empty(cfg.screen_path)) { + fprint(fstderr(), "session host requires --log and --screen\n") + return 2 + } + + // This flushed byte is the minimal IPC-ready acknowledgement. The parent + // timestamps its arrival, so process creation, daScript compilation, and + // worker initialization are measured rather than inferred. + fprint(fstdout(), "DASHERD_HOST_READY\n") + fflush(fstdout()) + + let commands <- make_git_probe_commands(cfg.repository) + let sample <- run_terminal_probe(commands[command_index(cfg.probe)], + cfg.log_path) + if (!empty(sample.error)) { + fprint(fstderr(), "session host probe failed: {sample.error}\n") + return 1 + } + + var screen = fopen(cfg.screen_path, "wb") + if (screen == null) { + fprint(fstderr(), "could not create screen snapshot: {cfg.screen_path}\n") + return 1 + } + fwrite(screen, sample.output_text) + fflush(screen) + fclose(screen) + + fprint(fstdout(), + ("DASHERD_HOST_RESULT\t{sample.exit_code}\t{sample.launch_us}\t" + + "{sample.first_output_us}\t{sample.exit_us}\t{sample.drained_us}\t" + + "{sample.poll_feed_us}\t{sample.log_write_us}\t{sample.total_us}\t" + + "{sample.output_bytes}\n")) + fflush(fstdout()) + return 0 +} From 44a22a4963adff0a764ae1cc45dabfa97a612ca0 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 11:53:36 -0700 Subject: [PATCH 06/20] Add persistent dasHerd terminal watcher --- modules/dasHV/dashv/dashv_boost.das | 4 + modules/dasHV/src/aot_hv.h | 2 + modules/dasHV/src/dasHV.cpp | 21 + modules/dasHV/src/dasHV.h | 2 + modules/dasHV/src/dashv_gen.inc | 8 +- utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md | 42 ++ utils/dasHerd/watcher/README.md | 28 ++ utils/dasHerd/watcher/control.html | 201 +++++++++ utils/dasHerd/watcher/main.das | 77 ++++ .../watcher/tests/test_watcher_core.das | 64 +++ utils/dasHerd/watcher/watcher_core.das | 418 ++++++++++++++++++ utils/dasHerd/watcher/watcher_server.das | 287 ++++++++++++ 12 files changed, 1150 insertions(+), 4 deletions(-) create mode 100644 utils/dasHerd/watcher/README.md create mode 100644 utils/dasHerd/watcher/control.html create mode 100644 utils/dasHerd/watcher/main.das create mode 100644 utils/dasHerd/watcher/tests/test_watcher_core.das create mode 100644 utils/dasHerd/watcher/watcher_core.das create mode 100644 utils/dasHerd/watcher/watcher_server.das diff --git a/modules/dasHV/dashv/dashv_boost.das b/modules/dasHV/dashv/dashv_boost.das index 9c40ed32e6..e4314942bf 100644 --- a/modules/dasHV/dashv/dashv_boost.das +++ b/modules/dasHV/dashv/dashv_boost.das @@ -100,6 +100,10 @@ class HvWebServer { //! Starts the server. Returns 0 on success. return start(server) } + def set_bind_host(host : string) : bool { + //! Selects the interface to bind before ``start`` (for example, ``127.0.0.1``). + return set_bind_host(server, host) + } def stop : int { //! Stops the server. Returns 0 on success. return stop(server) diff --git a/modules/dasHV/src/aot_hv.h b/modules/dasHV/src/aot_hv.h index 6054ecc140..7ac59f1f11 100644 --- a/modules/dasHV/src/aot_hv.h +++ b/modules/dasHV/src/aot_hv.h @@ -32,6 +32,8 @@ namespace das { int das_wss_send ( Handle h, const char * msg, ws_opcode opcode, bool fin ); int das_wss_send_buf ( Handle h, const char * buf, int32_t len, ws_opcode opcode, bool fin ); int das_wss_send_fragment ( Handle h, const char * buf, int32_t len, int32_t fragment, ws_opcode opcode ); + int das_wss_close_channel ( Handle h ); + bool das_wss_set_bind_host ( Handle h, const char * host ); int das_wss_start ( Handle h ); void das_wss_tick ( Handle h ); int das_wss_stop ( Handle h ); diff --git a/modules/dasHV/src/dasHV.cpp b/modules/dasHV/src/dasHV.cpp index 02cb7fd9dc..a6592a56d5 100644 --- a/modules/dasHV/src/dasHV.cpp +++ b/modules/dasHV/src/dasHV.cpp @@ -591,6 +591,21 @@ int das_wss_send_fragment ( Handle h, const char * buf, in return p->send(buf, len, fragment, opcode); } +int das_wss_close_channel ( Handle h ) { + auto p = HandleRegistry::instance().lookup(h); + if ( !p ) return -1; + return p->close(); +} + +bool das_wss_set_bind_host ( Handle h, const char * host ) { + auto adapter = lookup_server(h); + if ( !adapter || !host ) return false; + const size_t len = strlen(host); + if ( len == 0 || len >= sizeof(adapter->host) ) return false; + memcpy(adapter->host, host, len + 1); + return true; +} + int das_wss_start ( Handle h ) { auto adapter = lookup_server(h); if ( !adapter ) return -1; @@ -1299,6 +1314,12 @@ class Module_HV : public Module { addExtern (*this, lib, "send", SideEffects::worstDefault, "das_wss_send_fragment") ->args({"channel","msg","len","fragment","opcode"}); + addExtern (*this, lib, "close", + SideEffects::worstDefault, "das_wss_close_channel") + ->args({"channel"}); + addExtern (*this, lib, "set_bind_host", + SideEffects::worstDefault, "das_wss_set_bind_host") + ->args({"server","host"}); addExtern (*this, lib, "start", SideEffects::worstDefault, "das_wss_start") ->args({"server"}); diff --git a/modules/dasHV/src/dasHV.h b/modules/dasHV/src/dasHV.h index bb7c3ed0db..379222fe0c 100644 --- a/modules/dasHV/src/dasHV.h +++ b/modules/dasHV/src/dasHV.h @@ -39,6 +39,8 @@ Handle makeWebSocketServer ( int port, int httpsPort, const int das_wss_send ( Handle h, const char * msg, ws_opcode opcode, bool fin ); int das_wss_send_buf ( Handle h, const char * buf, int32_t len, ws_opcode opcode, bool fin ); int das_wss_send_fragment ( Handle h, const char * buf, int32_t len, int32_t fragment, ws_opcode opcode ); +int das_wss_close_channel ( Handle h ); +bool das_wss_set_bind_host ( Handle h, const char * host ); int das_wss_start ( Handle h ); void das_wss_tick ( Handle h ); int das_wss_stop ( Handle h ); diff --git a/modules/dasHV/src/dashv_gen.inc b/modules/dasHV/src/dashv_gen.inc index 193ca940c0..07122e1c2e 100644 --- a/modules/dasHV/src/dashv_gen.inc +++ b/modules/dasHV/src/dashv_gen.inc @@ -54,10 +54,10 @@ protected: int _das_class_method_offset[4]; public: HvWebServer_Adapter ( const StructInfo * info ) { - _das_class_method_offset[__fn_onWsOpen] = info->fields[11]->offset; - _das_class_method_offset[__fn_onWsClose] = info->fields[12]->offset; - _das_class_method_offset[__fn_onWsMessage] = info->fields[13]->offset; - _das_class_method_offset[__fn_onTick] = info->fields[14]->offset; + _das_class_method_offset[__fn_onWsOpen] = info->fields[12]->offset; + _das_class_method_offset[__fn_onWsClose] = info->fields[13]->offset; + _das_class_method_offset[__fn_onWsMessage] = info->fields[14]->offset; + _das_class_method_offset[__fn_onTick] = info->fields[15]->offset; } __forceinline Func get_onWsOpen ( void * self ) const { return getDasClassMethod(self,_das_class_method_offset[__fn_onWsOpen]); diff --git a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md index bb1205a8be..0b6ff34276 100644 --- a/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md +++ b/utils/dasHerd/WORKTREES_AND_TASK_TERMINALS.md @@ -138,6 +138,48 @@ query is rejected. Per-session isolation itself remains open pending the same probe with a small AOT/native host; a persistent per-worktree host is the next fallback, not an unmeasured optimization. +### Chunk 2: smallest persistent watcher + +Discussion status: first local slice implemented + +One persistent JIT watcher owns multiple PTYs and terminal emulators. Browser, +UI, live-command, and future agent surfaces are clients of this process; they +do not own its child processes. The first client is a minimal browser control +panel. The local protocol is authenticated HTTP plus WebSocket on +`127.0.0.1` only. + +The first API provides health and session listing, launch, UTF-8 input, resize, +termination, attach/detach, snapshots, and controller heartbeats. Many clients +may observe a session, but only one client holds its controller lease. Browser +close, explicit detach, and heartbeat expiry release that lease without +terminating the child. Polling is bounded per session on each watcher tick so +one noisy PTY cannot create an unbounded drain loop. + +Each session writes the following under +`logs/dasHerd/watcher/sessions//`: + +- `session.json`: immutable launch request; +- `output.raw`: exact PTY output bytes, flushed as they arrive; +- `events.jsonl`: lifecycle, timeout, exit, drain, and controller events. + +Input payload contents are deliberately absent from the event log. An optional +external wall deadline terminates hung children and retains `wall_timeout` as +the final reason. Process exit is reported immediately; raw-output drain uses +the existing conservative 250 ms quiet interval without delaying the exit +observation. + +Focused validation covers normal output and exit, wall timeout, exclusive +controller transfer, browser/WebSocket disconnect while the child continues, +heartbeat lease expiry, loopback token rejection, and watcher crash. A forced +watcher crash loses its live PTYs as expected, kills their children when the +ConPTY owner disappears, and leaves already-flushed raw logs readable. PTY +recovery after watcher death is explicitly not part of this slice. + +Still deferred are remote authentication/pairing, TLS/LAN binding, SSH target +transport, styled browser terminal rendering, replay after watcher restart, +log rotation and retention policy, native dasHerd UI integration, and the +live-command terminal schema. + ## Section 1: repository, base, and worktree identity Discussion status: open diff --git a/utils/dasHerd/watcher/README.md b/utils/dasHerd/watcher/README.md new file mode 100644 index 0000000000..61af2bc6fe --- /dev/null +++ b/utils/dasHerd/watcher/README.md @@ -0,0 +1,28 @@ +# dasHerd watcher + +The watcher is dasHerd's local detachable terminal host. One persistent JIT +process owns multiple PTYs, terminal emulators, and durable session logs while +browser and future native/live-command surfaces attach as clients. + +Run it from the repository root: + +```powershell +bin\Release\daslang.exe -jit utils\dasHerd\watcher\main.das +``` + +The watcher prints a token-bearing control-panel URL. Optional arguments after +`--` are `--port=9191`, `--token=`, and `--log-root=`. + +This first slice binds only to `127.0.0.1`. Its startup token prevents +accidental access from an unrelated local page; it is not the future remote +authentication design. Do not expose this port through a proxy or tunnel. + +HTTP endpoints under `/api/v1/` provide health, session listing, launch, +input, resize, and termination. `/ws` provides attach/detach, controller +leases, heartbeats, input, resize, and terminal snapshots. Every request must +include the startup token as the `token` query parameter. + +Session logs are stored under +`logs/dasHerd/watcher/sessions//`. Closing a client releases its +controller lease but does not stop its child. Killing the watcher necessarily +loses its live PTYs; already-flushed logs remain readable. diff --git a/utils/dasHerd/watcher/control.html b/utils/dasHerd/watcher/control.html new file mode 100644 index 0000000000..c57556190b --- /dev/null +++ b/utils/dasHerd/watcher/control.html @@ -0,0 +1,201 @@ + + + + + + dasHerd watcher + + + +
+ +
+
+ + + + + No session selected +
+
$
+

+      
+ + +
+
+
+ Connecting… + + + + + 150% +
+
+ + + diff --git a/utils/dasHerd/watcher/main.das b/utils/dasHerd/watcher/main.das new file mode 100644 index 0000000000..4831851167 --- /dev/null +++ b/utils/dasHerd/watcher/main.das @@ -0,0 +1,77 @@ +options gen2 +options rtti +options persistent_heap +options gc +options indenting = 4 + +require ./watcher_server.das +require daslib/random +require daslib/fio +require daslib/module_path +require live_host +require strings +require math + +def private option_value(prefix : string; fallback : string = "") : string { + for (argument in get_command_line_arguments()) { + if (starts_with(argument, prefix)) return slice(argument, length(prefix)) + } + return fallback +} + +def private make_local_token : string { + // This first slice is deliberately loopback-only. The token prevents an + // accidental drive-by from another local page; remote deployment will use + // a real authentication and pairing design. + var seed = random_seed(int(ref_time_ticks())) + return "{random_uint(seed)}-{random_uint(seed)}-{random_uint(seed)}-{random_uint(seed)}" +} + +[export] +def main { + let port = clamp(to_int(option_value("--port=", "9191")), 1024, 65535) + let token_arg = option_value("--token=") + let token = empty(token_arg) ? make_local_token() : token_arg + let log_root = option_value("--log-root=", + path_join(get_das_root(), "logs/dasHerd/watcher/sessions")) + let init_error = watcher_init(log_root) + if (!empty(init_error)) { + print("dasHerd watcher: could not initialize logs: {init_error}\n") + return 2 + } + + var server = new DasHerdWatcherServer() + server.token = token + server.control_html = path_join(get_this_module_dir(), "control.html") + server->init(port) + if (!server->set_bind_host("127.0.0.1")) { + print("dasHerd watcher: could not select loopback bind address\n") + server->cleanup() + unsafe { delete server } + watcher_shutdown() + return 3 + } + let start_result = server->start() + if (start_result != 0) { + print("dasHerd watcher: server start failed ({start_result})\n") + server->cleanup() + unsafe { delete server } + watcher_shutdown() + return 4 + } + + print("dasHerd watcher ready\n") + print(" control: http://127.0.0.1:{port}/?token={token}\n") + print(" logs: {log_root}\n") + print(" bind: 127.0.0.1:{port}\n") + while (!exit_requested()) { + server->tick() + sleep(2u) + } + let _ = server->stop() + server->cleanup_clients() + server->cleanup() + unsafe { delete server } + watcher_shutdown() + return 0 +} diff --git a/utils/dasHerd/watcher/tests/test_watcher_core.das b/utils/dasHerd/watcher/tests/test_watcher_core.das new file mode 100644 index 0000000000..2f13e5c0a8 --- /dev/null +++ b/utils/dasHerd/watcher/tests/test_watcher_core.das @@ -0,0 +1,64 @@ +options gen2 +options rtti + +require dastest/testing_boost public +require ../watcher_core.das +require daslib/fio +require strings + +def private wait_for(session_id, state : string; timeout_ms : int64) : bool { + let started = ref_time_ticks() + while (get_time_nsec(started) / 1000000l < timeout_ms) { + watcher_tick() + let snapshot = watcher_snapshot_json(session_id) + if (find(snapshot, "\"state\":\"{state}\"") >= 0 && + find(snapshot, "\"drained\":true") >= 0) return true + sleep(1u) + } + return false +} + +[test] +def test_watcher_process_lifecycle(t : T?) { + t |> run("persistent core logs output, times out hangs, and arbitrates control") <| @(t : T?) { + let temp = create_temp_directory_result("dasherd_watcher_test_") + if (!(temp is value)) { + t |> failure("could not create temporary test directory") + return + } + var root = temp as value + t |> equal(watcher_init(path_join(root, "sessions")), "") + + let quick = watcher_launch(WatcherLaunchRequest( + command_line = "powershell.exe -NoProfile -Command \"Write-Output watcher-core-ok\"", + cwd = root, columns = 80, rows = 20, timeout_ms = 5000l)) + t |> success(quick.ok, quick.error) + t |> success(wait_for(quick.session_id, "exited", 5000l), "quick command exits and drains") + let quick_snapshot = watcher_snapshot_json(quick.session_id) + t |> success(find(quick_snapshot, "watcher-core-ok") >= 0, "viewport retains command output") + t |> success(find(quick_snapshot, "\"exit_code\":0") >= 0, "exit code is retained") + t |> success(fexist(path_join(path_join(path_join(root, "sessions"), quick.session_id), "output.raw")), + "raw output log exists") + + let hung = watcher_launch(WatcherLaunchRequest( + command_line = "powershell.exe -NoProfile -Command \"Start-Sleep -Seconds 30\"", + cwd = root, columns = 80, rows = 20, timeout_ms = 250l)) + t |> success(hung.ok, hung.error) + t |> success(wait_for(hung.session_id, "exited", 4000l), "hung command is externally terminated") + t |> success(find(watcher_snapshot_json(hung.session_id), "\"reason\":\"wall_timeout\"") >= 0, + "timeout reason is retained") + + let controlled = watcher_launch(WatcherLaunchRequest( + command_line = "powershell.exe -NoProfile -Command \"Start-Sleep -Seconds 5\"", + cwd = root, columns = 80, rows = 20, timeout_ms = 10000l)) + t |> success(controlled.ok, controlled.error) + t |> success(watcher_claim_controller(controlled.session_id, 11), "first controller claims lease") + t |> success(!watcher_claim_controller(controlled.session_id, 12), "second controller is rejected") + watcher_release_controller(controlled.session_id, 11, "test_detach") + t |> success(watcher_claim_controller(controlled.session_id, 12), "released lease can be reclaimed") + t |> equal(watcher_terminate(controlled.session_id, "test_complete"), "") + t |> success(wait_for(controlled.session_id, "exited", 4000l), "terminated command exits and drains") + watcher_shutdown(true) + rmdir_rec(root) + } +} diff --git a/utils/dasHerd/watcher/watcher_core.das b/utils/dasHerd/watcher/watcher_core.das new file mode 100644 index 0000000000..3c2a274819 --- /dev/null +++ b/utils/dasHerd/watcher/watcher_core.das @@ -0,0 +1,418 @@ +options gen2 +options rtti +options persistent_heap +options gc +options indenting = 4 + +module watcher_core shared public + +require terminal/terminal public +require daslib/fio public +require daslib/json_boost public +require strings public +require math + +let private DEFAULT_DRAIN_QUIET_MS = 250l +let private MAX_COMMAND_BYTES = 32 * 1024 +let private MAX_INPUT_BYTES = 1024 * 1024 + +struct public WatcherLaunchRequest { + command_line : string + cwd : string + columns : int = 100 + rows : int = 30 + timeout_ms : int64 +} + +struct public WatcherInputRequest { + session_id : string + text : string +} + +struct public WatcherResizeRequest { + session_id : string + columns : int + rows : int +} + +struct public WatcherSessionRequest { + session_id : string +} + +struct public WatcherEvent { + session_id : string + event : string + elapsed_ms : int64 + detail : string +} + +struct public WatcherSessionInfo { + id : string + command_line : string + cwd : string + state : string + reason : string + pid : int + exit_code : int64 = -1l + elapsed_ms : int64 + output_bytes : int64 + columns : int + rows : int + revision : int64 + status_revision : int64 + controller_id : int + drained : bool +} + +struct public WatcherSessionSnapshot { + session : WatcherSessionInfo = WatcherSessionInfo() + screen_text : string + title : string + current_directory : string + scrollback_rows : int + cursor_row : int + cursor_column : int + cursor_visible : bool + cursor_blinking : bool +} + +struct public WatcherLaunchResult { + ok : bool + session_id : string + error : string +} + +class private WatcherSession { + id : string + command_line : string + cwd : string + session_dir : string + output_path : string + events_path : string + state : string = "starting" + reason : string + terminal : Terminal = Terminal() + output_file : FILE const? = null + events_file : FILE const? = null + started : int64 + timeout_ms : int64 + last_data_ms : int64 + exit_seen_ms : int64 = -1l + exit_code : int64 = -1l + output_bytes : int64 + status_revision : int64 + controller_id : int + drained : bool + def operator delete { + if (output_file != null) { + fflush(output_file) + fclose(output_file) + output_file = null + } + if (events_file != null) { + fflush(events_file) + fclose(events_file) + events_file = null + } + terminal_dispose(terminal) + } +} + +var private g_sessions : array +var private g_log_root : string +var private g_next_session_id = 0 + +def private elapsed_ms(session : WatcherSession?) : int64 { + return get_time_nsec(session.started) / 1000000l +} + +def private write_text_file(path, body : string) : bool { + var file = fopen(path, "wb") + if (file == null) return false + fwrite(file, body) + fflush(file) + fclose(file) + return true +} + +def private append_event(session : WatcherSession?; event, detail : string) { + if (session.events_file == null) return + let line = sprint_json(WatcherEvent(session_id = session.id, event = event, + elapsed_ms = elapsed_ms(session), detail = detail), false) + "\n" + fwrite(session.events_file, line) + fflush(session.events_file) +} + +def private session_info(session : WatcherSession?) : WatcherSessionInfo { + return WatcherSessionInfo(id = session.id, command_line = session.command_line, + cwd = session.cwd, state = session.state, reason = session.reason, + pid = terminal_process_id(session.terminal), exit_code = session.exit_code, + elapsed_ms = elapsed_ms(session), output_bytes = session.output_bytes, + columns = session.terminal.columns, rows = session.terminal.rows, + revision = terminal_revision(session.terminal), status_revision = session.status_revision, + controller_id = session.controller_id, + drained = session.drained) +} + +def public watcher_init(log_root : string) : string { + g_log_root = log_root + var error : string + if (!mkdir_rec(g_log_root, error)) return error + return "" +} + +def public watcher_find_session(session_id : string) : int { + for (index in range(length(g_sessions))) { + if (g_sessions[index] != null && g_sessions[index].id == session_id) return index + } + return -1 +} + +def public watcher_launch(request : WatcherLaunchRequest) : WatcherLaunchResult { + if (empty(request.command_line)) { + return WatcherLaunchResult(error = "command_line is required") + } + if (length(request.command_line) > MAX_COMMAND_BYTES) { + return WatcherLaunchResult(error = "command_line is too large") + } + if (empty(g_log_root)) { + return WatcherLaunchResult(error = "watcher core is not initialized") + } + g_next_session_id++ + let session_id = "s{ref_time_ticks()}_{g_next_session_id}" + let session_dir = path_join(g_log_root, session_id) + var mkdir_error : string + if (!mkdir_rec(session_dir, mkdir_error)) { + return WatcherLaunchResult(error = "could not create session directory: {mkdir_error}") + } + + var session = new WatcherSession(id = session_id, + command_line = request.command_line, cwd = request.cwd, + session_dir = session_dir, + output_path = path_join(session_dir, "output.raw"), + events_path = path_join(session_dir, "events.jsonl"), + started = ref_time_ticks(), timeout_ms = max(0l, request.timeout_ms)) + session.output_file = fopen(session.output_path, "wb") + session.events_file = fopen(session.events_path, "wb") + if (session.output_file == null || session.events_file == null) { + unsafe { delete session } + return WatcherLaunchResult(error = "could not open session logs") + } + let spec = WatcherLaunchRequest(command_line = request.command_line, cwd = request.cwd, + columns = clamp(request.columns, 20, 400), rows = clamp(request.rows, 5, 200), + timeout_ms = max(0l, request.timeout_ms)) + if (!write_text_file(path_join(session_dir, "session.json"), sprint_json(spec, true))) { + unsafe { delete session } + return WatcherLaunchResult(error = "could not write session metadata") + } + + var inscope launched <- terminal_launch(spec.command_line, spec.cwd, spec.columns, spec.rows) + session.terminal := launched + if (terminal_process_id(session.terminal) == 0) { + session.state = "failed" + session.reason = terminal_transport_error(session.terminal) + session.drained = true + session.status_revision++ + append_event(session, "launch_failed", session.reason) + } else { + session.state = "running" + session.status_revision++ + append_event(session, "launched", "pid={terminal_process_id(session.terminal)}") + } + g_sessions |> push(session) + return WatcherLaunchResult(ok = true, session_id = session_id) +} + +def private observe_exit(var session : WatcherSession?; now_ms : int64) { + if (session.exit_seen_ms >= 0l || !terminal_process_exited(session.terminal)) return + session.exit_seen_ms = now_ms + session.exit_code = terminal_exit_code(session.terminal) + session.state = "exited" + session.status_revision++ + if (session.last_data_ms == 0l) { + session.last_data_ms = now_ms + } + append_event(session, "process_exited", "exit_code={session.exit_code}") +} + +def private finish_drain(var session : WatcherSession?; detail : string) { + if (session.drained) return + session.drained = true + session.status_revision++ + if (session.output_file != null) { + fflush(session.output_file) + fclose(session.output_file) + session.output_file = null + } + append_event(session, "output_drained", detail) +} + +def private enforce_timeout(var session : WatcherSession?; now_ms : int64) { + if (session.state != "running" || session.timeout_ms <= 0l || now_ms < session.timeout_ms) return + session.reason = "wall_timeout" + session.state = "terminating" + session.status_revision++ + let terminated = terminal_terminate(session.terminal) + append_event(session, "wall_timeout", "terminate={terminated}") +} + +def public watcher_tick(maximum_bytes_per_session : int = 64 * 1024) { + let bounded_bytes = clamp(maximum_bytes_per_session, 1024, 1024 * 1024) + for (index in range(length(g_sessions))) { + var session = g_sessions[index] + if (session == null) continue + if (session.drained) continue + let status = terminal_poll(session.terminal, bounded_bytes) + let now_ms = elapsed_ms(session) + if (status == TerminalPollStatus.data) { + session.last_data_ms = now_ms + session.output_bytes += int64(length(session.terminal.read_buffer)) + if (session.output_file != null) { + let _ = fwrite(session.output_file, session.terminal.read_buffer) + fflush(session.output_file) + } + } elif (status == TerminalPollStatus.error) { + session.state = "failed" + session.reason = terminal_transport_error(session.terminal) + session.status_revision++ + append_event(session, "transport_error", session.reason) + finish_drain(session, "transport_error") + continue + } + observe_exit(session, now_ms) + enforce_timeout(session, now_ms) + if (status == TerminalPollStatus.closed) { + finish_drain(session, "pipe_closed") + } elif (session.exit_seen_ms >= 0l && now_ms - session.last_data_ms >= DEFAULT_DRAIN_QUIET_MS) { + finish_drain(session, "quiet_interval") + } + } +} + +def private input_session(session : WatcherSession?; text : string; client_id : int) : string { + if (session.controller_id != 0 && session.controller_id != client_id) return "controller lease is held by another client" + if (session.state != "running") return "session is not running" + if (!terminal_write(session.terminal, text)) return terminal_transport_error(session.terminal) + return "" +} + +def public watcher_input(session_id, text : string; client_id : int = 0) : string { + let index = watcher_find_session(session_id) + if (index < 0) return "unknown session" + if (length(text) > MAX_INPUT_BYTES) return "input is too large" + return input_session(g_sessions[index], text, client_id) +} + +def private resize_session(var session : WatcherSession?; columns, rows, client_id : int) : string { + if (session.controller_id != 0 && session.controller_id != client_id) return "controller lease is held by another client" + terminal_resize(session.terminal, clamp(columns, 20, 400), clamp(rows, 5, 200)) + append_event(session, "resized", "{session.terminal.columns}x{session.terminal.rows}") + return "" +} + +def public watcher_resize(session_id : string; columns, rows, client_id : int) : string { + let index = watcher_find_session(session_id) + if (index < 0) return "unknown session" + return resize_session(g_sessions[index], columns, rows, client_id) +} + +def private terminate_session(var session : WatcherSession?; detail : string) : string { + if (terminal_process_exited(session.terminal)) return "" + session.reason = detail + session.state = "terminating" + session.status_revision++ + if (!terminal_terminate(session.terminal)) return terminal_transport_error(session.terminal) + append_event(session, "terminate_requested", detail) + return "" +} + +def public watcher_terminate(session_id : string; detail : string = "requested") : string { + let index = watcher_find_session(session_id) + if (index < 0) return "unknown session" + return terminate_session(g_sessions[index], detail) +} + +def private claim_session(var session : WatcherSession?; client_id : int) : bool { + if (session.controller_id != 0 && session.controller_id != client_id) return false + if (session.controller_id == 0) append_event(session, "controller_claimed", "client={client_id}") + session.controller_id = client_id + session.status_revision++ + return true +} + +def public watcher_claim_controller(session_id : string; client_id : int) : bool { + let index = watcher_find_session(session_id) + if (index < 0 || client_id == 0) return false + return claim_session(g_sessions[index], client_id) +} + +def private release_session(var session : WatcherSession?; client_id : int; detail : string) { + if (session.controller_id != client_id) return + session.controller_id = 0 + session.status_revision++ + append_event(session, "controller_released", "client={client_id}; reason={detail}") +} + +def public watcher_release_controller(session_id : string; client_id : int; detail : string = "released") { + let index = watcher_find_session(session_id) + if (index < 0) return + release_session(g_sessions[index], client_id, detail) +} + +def public watcher_list_json : string { + var result : array + result |> reserve(length(g_sessions)) + for (session in g_sessions) { + if (session != null) result |> push(session_info(session)) + } + return sprint_json(result, false) +} + +def private snapshot_session_json(session : WatcherSession?) : string { + var viewport = TerminalViewportSnapshot() + terminal_viewport_snapshot(session.terminal, 0, viewport) + let cursor = viewport.buffer.cursor + let snapshot = WatcherSessionSnapshot(session = session_info(session), + screen_text = viewport.screen_text, title = session.terminal.title, + current_directory = session.terminal.current_directory, + scrollback_rows = viewport.buffer.scrollback_rows, + cursor_row = cursor.row, cursor_column = cursor.column, + cursor_visible = cursor.visible, cursor_blinking = cursor.blinking) + return sprint_json(snapshot, false) +} + +def public watcher_snapshot_json(session_id : string) : string { + let index = watcher_find_session(session_id) + if (index < 0) return "" + return snapshot_session_json(g_sessions[index]) +} + +def public watcher_update_key(session_id : string) : string { + let index = watcher_find_session(session_id) + if (index < 0 || g_sessions[index] == null) return "" + let session = g_sessions[index] + return "{terminal_revision(session.terminal)}:{session.status_revision}" +} + +def public watcher_shutdown(terminate_running : bool = false) { + for (index in range(length(g_sessions))) { + var session = g_sessions[index] + if (session == null) continue + if (terminate_running && !terminal_process_exited(session.terminal)) { + let _ = terminal_terminate(session.terminal) + } + if (session.output_file != null) { + fflush(session.output_file) + fclose(session.output_file) + session.output_file = null + } + if (session.events_file != null) { + append_event(session, "watcher_shutdown", "terminate_running={terminate_running}") + fflush(session.events_file) + fclose(session.events_file) + session.events_file = null + } + unsafe { delete session } + } + g_sessions |> clear() +} diff --git a/utils/dasHerd/watcher/watcher_server.das b/utils/dasHerd/watcher/watcher_server.das new file mode 100644 index 0000000000..e2aabdbcfc --- /dev/null +++ b/utils/dasHerd/watcher/watcher_server.das @@ -0,0 +1,287 @@ +options gen2 +options rtti +options persistent_heap +options gc +options indenting = 4 +options no_unused_function_arguments = false +options no_unused_block_arguments = false + +module watcher_server shared public + +require dashv/dashv_boost public +require ./watcher_core.das public +require daslib/json_boost +require daslib/fio +require strings + +let private MAX_REQUEST_BODY_BYTES = 1024 * 1024 +let private CONTROLLER_HEARTBEAT_TIMEOUT_MS = 5000l + +struct private WatcherWsMessage { + op : string + session_id : string + text : string + columns : int + rows : int + control : bool +} + +class private WatcherClient { + @safe_when_uninitialized + channel : WebSocketChannel = default + id : int + session_id : string + controls : bool + last_heartbeat : int64 + last_update_key : string +} + +struct private WatcherOk { + ok : bool = true +} + +struct private WatcherError { + ok : bool + error : string +} + +def private json_error(message : string) : string { + return sprint_json(WatcherError(error = message), false) +} + +def private query_value(url, key : string) : string { + let query = find(url, "?") + if (query < 0) return "" + let wanted = key + "=" + var position = query + 1 + while (position < length(url)) { + let amp = find(url, "&", position) + let end = amp < 0 ? length(url) : amp + if (starts_with(slice(url, position, end), wanted)) { + return slice(url, position + length(wanted), end) + } + if (amp < 0) break + position = amp + 1 + } + return "" +} + +class DasHerdWatcherServer : HvWebServer { + token : string + control_html : string + clients : array + next_client_id : int + + def authorized(url : string) : bool { + return !empty(token) && query_value(url, "token") == token + } + + def respond_error(var resp : HttpResponse?; status : http_status; message : string) : http_status { + return resp |> JSON(json_error(message), status) + } + + def require_auth(var req : HttpRequest?; var resp : HttpResponse?) : bool { + if (req != null && authorized(string(req.url))) return true + let _ = respond_error(resp, http_status.UNAUTHORIZED, "invalid watcher token") + return false + } + + def override onInit { + GET("/") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + return resp |> SERVE_FILE(self.control_html) + } + GET("/api/v1/health") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + return resp |> JSON("\{\"ok\":true,\"service\":\"dasHerd-watcher\"\}", http_status.OK) + } + GET("/api/v1/sessions") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + return resp |> JSON(watcher_list_json(), http_status.OK) + } + POST("/api/v1/sessions") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + if (req == null || length(req.body) > MAX_REQUEST_BODY_BYTES) { + return self.respond_error(resp, http_status.BAD_REQUEST, "request body is too large") + } + var launch = WatcherLaunchRequest() + if (!sscan_json(string(req.body), launch)) { + return self.respond_error(resp, http_status.BAD_REQUEST, "invalid launch JSON") + } + let result = watcher_launch(launch) + return resp |> JSON(sprint_json(result, false), + result.ok ? http_status.CREATED : http_status.BAD_REQUEST) + } + POST("/api/v1/input") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + if (req == null || length(req.body) > MAX_REQUEST_BODY_BYTES) { + return self.respond_error(resp, http_status.BAD_REQUEST, "request body is too large") + } + var input : WatcherInputRequest + if (!sscan_json(string(req.body), input)) { + return self.respond_error(resp, http_status.BAD_REQUEST, "invalid input JSON") + } + let error = watcher_input(input.session_id, input.text) + if (!empty(error)) return self.respond_error(resp, http_status.CONFLICT, error) + return resp |> JSON(sprint_json(WatcherOk(), false), http_status.OK) + } + POST("/api/v1/resize") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + if (req == null || length(req.body) > MAX_REQUEST_BODY_BYTES) { + return self.respond_error(resp, http_status.BAD_REQUEST, "request body is too large") + } + var resize : WatcherResizeRequest + if (!sscan_json(string(req.body), resize)) { + return self.respond_error(resp, http_status.BAD_REQUEST, "invalid resize JSON") + } + let error = watcher_resize(resize.session_id, resize.columns, resize.rows, 0) + if (!empty(error)) return self.respond_error(resp, http_status.CONFLICT, error) + return resp |> JSON(sprint_json(WatcherOk(), false), http_status.OK) + } + POST("/api/v1/terminate") <| @(var req : HttpRequest?; var resp : HttpResponse?) : http_status { + if (!self.require_auth(req, resp)) return http_status.UNAUTHORIZED + if (req == null || length(req.body) > MAX_REQUEST_BODY_BYTES) { + return self.respond_error(resp, http_status.BAD_REQUEST, "request body is too large") + } + var request : WatcherSessionRequest + if (!sscan_json(string(req.body), request)) { + return self.respond_error(resp, http_status.BAD_REQUEST, "invalid terminate JSON") + } + let error = watcher_terminate(request.session_id) + if (!empty(error)) return self.respond_error(resp, http_status.CONFLICT, error) + return resp |> JSON(sprint_json(WatcherOk(), false), http_status.OK) + } + } + + def find_client(channel : WebSocketChannel) : int { + for (index in range(length(clients))) { + if (clients[index] != null && clients[index].channel == channel) return index + } + return -1 + } + + def send_error(channel : WebSocketChannel; message : string) { + let body = "\{\"type\":\"error\",\"error\":{sprint_json(message, false)}\}" + send(channel, body, ws_opcode.WS_OPCODE_TEXT, true) + } + + def send_snapshot(var client : WatcherClient?; force : bool = false) { + if (client == null || empty(client.session_id)) return + let update_key = watcher_update_key(client.session_id) + if (!force && update_key == client.last_update_key) return + let snapshot = watcher_snapshot_json(client.session_id) + if (empty(snapshot)) { + send_error(client.channel, "unknown session") + return + } + client.last_update_key = update_key + send(client.channel, "\{\"type\":\"snapshot\",\"data\":{snapshot}\}", + ws_opcode.WS_OPCODE_TEXT, true) + } + + def detach(var client : WatcherClient?; detail : string) { + if (client == null) return + if (client.controls && !empty(client.session_id)) { + watcher_release_controller(client.session_id, client.id, detail) + } + client.session_id = "" + client.controls = false + client.last_update_key = "" + } + + def override onWsOpen(channel : WebSocketChannel; url : string#) { + if (!authorized(string(url))) { + send_error(channel, "invalid watcher token") + close(channel) + return + } + next_client_id++ + clients |> push(new WatcherClient(channel = channel, id = next_client_id, + last_heartbeat = ref_time_ticks())) + send(channel, "\{\"type\":\"connected\",\"client_id\":{next_client_id}\}", + ws_opcode.WS_OPCODE_TEXT, true) + } + + def override onWsClose(channel : WebSocketChannel) { + let index = find_client(channel) + if (index < 0) return + var client = clients[index] + detach(client, "client_closed") + clients |> erase(index) + unsafe { delete client } + } + + def override onWsMessage(channel : WebSocketChannel; message : string#) { + let index = find_client(channel) + if (index < 0) return + var incoming : WatcherWsMessage + if (length(message) > MAX_REQUEST_BODY_BYTES || !sscan_json(string(message), incoming)) { + send_error(channel, "invalid WebSocket message") + return + } + var client = clients[index] + if (client == null) return + client.last_heartbeat = ref_time_ticks() + if (incoming.op == "heartbeat") { + send(channel, "\{\"type\":\"heartbeat\"\}", ws_opcode.WS_OPCODE_TEXT, true) + } elif (incoming.op == "attach") { + if (watcher_find_session(incoming.session_id) < 0) { + send_error(channel, "unknown session") + return + } + detach(client, "reattach") + client.session_id = incoming.session_id + client.controls = incoming.control && watcher_claim_controller(incoming.session_id, client.id) + if (incoming.control && !client.controls) { + send_error(channel, "controller lease is held by another client") + } + send(channel, "\{\"type\":\"attached\",\"session_id\":{sprint_json(client.session_id, false)},\"controls\":{client.controls}\}", + ws_opcode.WS_OPCODE_TEXT, true) + send_snapshot(client, true) + } elif (incoming.op == "detach") { + detach(client, "client_detached") + send(channel, "\{\"type\":\"detached\"\}", ws_opcode.WS_OPCODE_TEXT, true) + } elif (incoming.op == "input") { + if (!client.controls) { + send_error(channel, "client does not hold the controller lease") + return + } + let error = watcher_input(client.session_id, incoming.text, client.id) + if (!empty(error)) send_error(channel, error) + } elif (incoming.op == "resize") { + if (!client.controls) { + send_error(channel, "client does not hold the controller lease") + return + } + let error = watcher_resize(client.session_id, incoming.columns, incoming.rows, client.id) + if (!empty(error)) send_error(channel, error) + } else { + send_error(channel, "unknown operation") + } + } + + def override onTick { + watcher_tick() + for (index in range(length(clients))) { + var client = clients[index] + if (client == null) continue + if (client.controls && get_time_nsec(client.last_heartbeat) / 1000000l >= CONTROLLER_HEARTBEAT_TIMEOUT_MS) { + watcher_release_controller(client.session_id, client.id, "heartbeat_timeout") + client.controls = false + send_error(client.channel, "controller lease expired") + } + send_snapshot(client) + } + } + + def cleanup_clients { + for (index in range(length(clients))) { + var client = clients[index] + if (client != null) { + detach(client, "server_shutdown") + unsafe { delete client } + } + } + clients |> clear() + } +} From 12f4cd6ba56d101fe475a42e5dae26dc5fb435c5 Mon Sep 17 00:00:00 2001 From: Boris Batkin Date: Mon, 20 Jul 2026 12:20:30 -0700 Subject: [PATCH 07/20] Add interactive browser terminal fallback --- utils/dasHerd/watcher/README.md | 13 +- utils/dasHerd/watcher/control.html | 211 ++++++++++++----- utils/dasHerd/watcher/main.das | 9 +- .../watcher/tests/test_watcher_core.das | 4 + .../vendor/xterm/LICENSE.addon-fit.txt | 19 ++ .../vendor/xterm/LICENSE.addon-unicode11.txt | 19 ++ .../vendor/xterm/LICENSE.addon-web-links.txt | 19 ++ .../watcher/vendor/xterm/LICENSE.xterm.txt | 21 ++ utils/dasHerd/watcher/vendor/xterm/README.md | 12 + .../dasHerd/watcher/vendor/xterm/addon-fit.js | 2 + .../watcher/vendor/xterm/addon-unicode11.js | 2 + .../watcher/vendor/xterm/addon-web-links.js | 2 + utils/dasHerd/watcher/vendor/xterm/xterm.css | 218 ++++++++++++++++++ utils/dasHerd/watcher/vendor/xterm/xterm.js | 2 + utils/dasHerd/watcher/watcher_core.das | 49 ++++ utils/dasHerd/watcher/watcher_server.das | 51 ++++ 16 files changed, 585 insertions(+), 68 deletions(-) create mode 100644 utils/dasHerd/watcher/vendor/xterm/LICENSE.addon-fit.txt create mode 100644 utils/dasHerd/watcher/vendor/xterm/LICENSE.addon-unicode11.txt create mode 100644 utils/dasHerd/watcher/vendor/xterm/LICENSE.addon-web-links.txt create mode 100644 utils/dasHerd/watcher/vendor/xterm/LICENSE.xterm.txt create mode 100644 utils/dasHerd/watcher/vendor/xterm/README.md create mode 100644 utils/dasHerd/watcher/vendor/xterm/addon-fit.js create mode 100644 utils/dasHerd/watcher/vendor/xterm/addon-unicode11.js create mode 100644 utils/dasHerd/watcher/vendor/xterm/addon-web-links.js create mode 100644 utils/dasHerd/watcher/vendor/xterm/xterm.css create mode 100644 utils/dasHerd/watcher/vendor/xterm/xterm.js diff --git a/utils/dasHerd/watcher/README.md b/utils/dasHerd/watcher/README.md index 61af2bc6fe..920040ff3f 100644 --- a/utils/dasHerd/watcher/README.md +++ b/utils/dasHerd/watcher/README.md @@ -2,7 +2,8 @@ The watcher is dasHerd's local detachable terminal host. One persistent JIT process owns multiple PTYs, terminal emulators, and durable session logs while -browser and future native/live-command surfaces attach as clients. +native/live-command surfaces attach as clients. The browser panel is an +interactive debug and recovery fallback; the ImGui client remains the primary UI. Run it from the repository root: @@ -19,8 +20,14 @@ authentication design. Do not expose this port through a proxy or tunnel. HTTP endpoints under `/api/v1/` provide health, session listing, launch, input, resize, and termination. `/ws` provides attach/detach, controller -leases, heartbeats, input, resize, and terminal snapshots. Every request must -include the startup token as the `token` query parameter. +leases, heartbeats, input, resize, terminal snapshots, and bounded raw PTY replay. +Raw output is delivered as binary WebSocket frames, so the browser can render +the same color and control sequences and send keyboard input back to the PTY. +Every request must include the startup token as the `token` query parameter. + +The browser terminal vendors xterm.js 5.5.0 with the matching fit 0.10.0, +Unicode 11 0.8.0, and web-links 0.11.0 addons under `vendor/xterm/`. It works +offline; the corresponding MIT license files are kept beside the assets. Session logs are stored under `logs/dasHerd/watcher/sessions//`. Closing a client releases its diff --git a/utils/dasHerd/watcher/control.html b/utils/dasHerd/watcher/control.html index c57556190b..29e33ed8e5 100644 --- a/utils/dasHerd/watcher/control.html +++ b/utils/dasHerd/watcher/control.html @@ -3,7 +3,7 @@ - dasHerd watcher + dasHerd watcher terminal