` writes trimmed HTML snapshots (scripts, styles, svg removed; message text
+ replaced with "...") of the composer, the newest message, the sidebar and any attachment
+ chips — attach them to bug reports instead of screenshots of real content.
+- `--watch` waits while you type a prompt and send it manually, then captures the generating
+ state (stop indicator) and the finished state once each — the two states the unit tests
+ cannot see.
+
+Run `keyless doctor` after every DeepSeek UI update: a NOT FOUND line points at exactly which
+fallback list to fix.
+
+## Send / confirm / upload decision tables
+
+**Send attempt** (one attempt = the send button when clickable, otherwise Enter — never both):
+
+| Observation after the attempt | Decision |
+| --- | --- |
+| A new message item appeared in the DOM | Sent — continue to `WaitForReplyAsync` |
+| Input is empty AND the stop/generating indicator is on | Sent |
+| Input is empty, no indicator (virtualized list did not grow) | Sent — a constant item count proves nothing |
+| Input still holds the text | Not sent — retry exactly once, with ENTER (a repeated click on a mis-identified control only opens dialogs) |
+| Still not confirmed after the retry AND the input still holds the text | Fail `KA_SEND_NOT_CONFIRMED` |
+
+**Attachment delivery** (after `SetInputFilesAsync` is attempted):
+
+| Observation | Decision |
+| --- | --- |
+| `SetInputFilesAsync` itself failed | Paste the content into the message |
+| The hidden file input reports one of the uploaded file names | Attached — send with the files |
+| A composer-near element (chip) shows an uploaded file name | Attached |
+| Chips are visible at all | Attached (uncertainty counts as attached, with a warning) |
+| No sign of the files after `Browser.UploadWaitSeconds` | Paste the content instead |
+
+The same content is never both attached and pasted.
+
+## Reply sources — Copy button first, DOM fallback (`Browser.ReplySource`)
+
+Verified facts from real chats: the message-level Copy button copies ONLY the final answer
+(never the reasoning), the copied text is raw Markdown (indentation, markers, nested fences
+preserved), and it may or may not carry an outer code fence.
+
+| Mode (`Browser.ReplySource`) | Behavior |
+| --- | --- |
+| `Auto` *(default)* | Try Copy → validate → fall back to Dom. The used source is logged per reply. |
+| `Copy` | Copy only; capture or validation failures return `KA_REPLY_TIMEOUT` (never silent fallback). |
+| `Dom` | The original DOM extraction. |
+
+**Capture strategies, in order:**
+
+1. **Interception**: an injected script wraps `navigator.clipboard.writeText`, `document.execCommand('copy')` and the `copy` event, reporting the text to C# through an exposed binding — active ONLY while the agent's capture flag is set, and the OS clipboard is never written, so the user's manual copying is unaffected.
+2. **Clipboard read**: grant `clipboard-read`/`clipboard-write`, save the current clipboard text, hover the newest message and click its message-level Copy control, read `navigator.clipboard.readText()`, and ALWAYS restore the previous text in a `finally` block.
+3. **None**: fall back to Dom (Auto) or fail with `KA_REPLY_TIMEOUT` (Copy).
+
+**Validation** (rendered DOM text and raw Markdown differ by syntax, so normalized forms are compared — markdown/punctuation stripped, whitespace collapsed, lowercased): (a) the normalized DOM text is contained in the normalized copy, OR similarity ≥ 0.9 (token overlap + length ratio); (b) the first 60 normalized characters of the reasoning block must NOT appear in the copy; (c) the copy is non-empty and at least 50% of the DOM length. On failure the numbers are logged and Auto falls back ("Dom (copy rejected)").
+
+Completion detection is unchanged (new-message fingerprint, final-answer element, stop indicator gone, stable); with Copy the message-level Copy control of THAT newest message must also appear (hover first — the bar may be hover-only), otherwise Dom fallback (Auto) or `KA_REPLY_TIMEOUT` (Copy). The parser strips an outer code fence only when the first AND last non-blank lines are fences of the same or greater length, so a copied reply wrapped in a fence parses fine while inner fences survive.
+
+## Reply identity — never the previous reply
+
+After every send the DOM still shows the PREVIOUS assistant message until the new one renders
+(the list is virtualized; the stop indicator may also lag). To never return the old reply:
+
+1. **Before sending**, the session fingerprints the newest assistant message: its
+ `data-virtual-list-item-key` when present, otherwise a stable hash of its final-answer text,
+ plus the count of visible assistant messages (count alone is never trusted — virtualization).
+2. **The send itself is confirmed** within `Browser.SendConfirmSeconds` (default 15): a new
+ message item appeared in the DOM, or the input is empty and the stop/generating indicator
+ is on. Otherwise the send is retried once (click, then Enter) and then fails with
+ `KA_SEND_NOT_CONFIRMED`.
+3. **A reply is accepted only when all of these hold**: its fingerprint differs from the
+ pre-send one (identical text is valid only with a different message key), the final-answer
+ element exists, the text is stable for two reads one second apart, and the stop indicator
+ is gone.
+
+## Uploads — wait before sending
+
+After uploading attachments the send click waits until the upload settles (`Browser.UploadWaitSeconds`,
+default 60): the file chips are visible and the send button is enabled. On timeout (or when the
+file input cannot be used at all) the content is pasted into the message instead.
+
+## Reading replies — final answer only
+
+Each assistant message can contain **two** `.ds-markdown` blocks: the reasoning (`.ds-think-content`, headed "Thought for N seconds") and the final answer (`.ds-markdown.ds-assistant-message-main-content`). Only the **final answer of the newest assistant message** is ever read; the reasoning never reaches the parser (it would break it), the logs, or the chat.
+
+`IReplyExtractor` (unit-tested against fragments of the real sample) takes the newest message's HTML and returns only the final-answer text:
+
+- the reasoning subtree is skipped entirely,
+- inside code blocks only the `` text is kept (the Copy/Download banner is dropped),
+- `<`/`&`/… entities are decoded,
+- text inside `` is taken raw — indentation and newlines are preserved rather than reading the rendered `innerText` of the Markdown.
+
+**Completion detection**: the session waits while the stop/generating indicator is visible, then reads the final-answer element twice one second apart and accepts it when unchanged. A message with reasoning but no final-answer element yet never counts as a reply.
+
+## Sending
+
+- Long text is inserted with a whole-text insert (`keyboard.insertText`), **never per-character typing**.
+- A local rate limiter enforces `MaxMessagesPerMinute` plus random delays between `MinDelayMs`/`MaxDelayMs`.
+- Attachments are first **uploaded** through the chat's file input (`SetInputFilesAsync` on files written to `.agent/attachments`). When upload is impossible or the content is too big, it is **pasted** into the message; oversized dumps are split at file boundaries into consecutive parts.
+
+## Toggles
+
+Before the first message of every new chat, both toggles (DeepThink, Search) are checked via `aria-pressed` and clicked only when they disagree with `Browser.EnableDeepThink` / `Browser.EnableSearch`, then verified. A missing toggle is a warning, not an error.
+
+## Recovery
+
+- Empty replies / "server busy": retried with backoff inside the reply timeout.
+- Captcha: not solved — the user solves it in the browser while the agent waits.
+- Debug mode (`-v` / `Browser.Debug`): screenshots on errors into `.agent/logs/`, structured diagnostics log. Cookies, tokens and page HTML are never logged.
+
+## ⚠️ Manual verification still required
+
+Built from a DOM sample, verified only by unit tests so far. Before relying on it, verify against a **real chat**:
+
+1. **Code indentation**: send a reply containing an indented code block and confirm the parsed AgentScript preserves leading spaces (the extractor reads raw `` text nodes for this reason — but confirm on the live DOM).
+2. **Stop indicator**: confirm the selector matches the generating state (falls back to stability-based detection otherwise).
+3. **New Chat button** and **login page** selectors.
+4. **File upload**: confirm `.md`/`.txt` uploads render for the model; otherwise switch `AttachOnFeedback`-style pasting happens automatically.
+5. Toggle labels (`DeepThink`/`Search` texts) and `aria-pressed` values.
+
+Record the results of this checklist in this file after testing.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..82c6695
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,144 @@
+# Configuration
+
+KeylessAgent reads `keyless.json` from the workspace root. The first run writes the full default file so you can edit it. Parsing is **tolerant**: comments (`//`), case-insensitive keys, trailing commas, and string enums (`"Ask"`, `"AutoSafe"`, …) all work. Invalid JSON or out-of-range values fail with a precise message instead of an exception.
+
+```jsonc
+{
+ // How much confirmation the agent asks for.
+ "Mode": "Ask", // Ask | AutoSafe | Auto
+
+ "Browser": {
+ "ChromeDebugPort": 9222, // CDP port; ignored by Chrome's DEFAULT profile since Chrome 136
+ "UserDataDir": "", // dedicated profile; empty = %LOCALAPPDATA%\KeylessAgent\chrome-profile
+ "AutoLaunchChrome": true, // start the real Chrome with the dedicated profile when the port is closed
+ "ChromePath": "", // explicit chrome.exe path; empty = registry + common locations
+ "ReuseExistingTab": true, // reuse an open chat.deepseek.com tab instead of opening one
+ "AllowPlaywrightLaunch": false, // opt-in automated fallback browser (NOT your Chrome)
+ "CloseChromeOnExit": false, // false = keep the chat tab/window open across turns and runs
+ "EnableDeepThink": true, // DeepThink toggle before the first message
+ "EnableSearch": true, // Search toggle before the first message
+ "SelectorsFile": "selectors.json",
+ "ReplyTimeoutSeconds": 300,
+ "ReplySource": "Auto", // Auto (copy button + validation + DOM fallback) | Copy | Dom
+ "WindowMode": "Minimized", // Minimized | Offscreen | Normal (auto-launched Chrome only)
+ "MinimizeAfterLogin": true, // minimize the window after login completes
+ "LoginProbeSeconds": 15, // positive-first probe: wait for InputBox OR login indicator
+ "MaxTurnsPerChat": 20, // turns per chat before a new chat starts automatically
+ "HumanDelay": false, // random delays between browser actions (default: event-driven only)
+ "SendConfirmSeconds": 15, // wait for proof the message was sent before retrying once
+ "UploadWaitSeconds": 60, // wait for attachment chips + enabled send before pasting
+ "MinDelayMs": 0, // random delay between actions (default: event-driven only)
+ "MaxDelayMs": 150,
+ "MaxMessagesPerMinute": 6, // local rate limiter
+ "Debug": false // screenshots on errors
+ },
+
+ "Limits": { // injected into the system prompt; enforced in code
+ "MaxCommandsPerResponse": 5,
+ "MaxResponseChars": 60000,
+ "MaxFileContentChars": 40000,
+ "MaxCommandOutputChars": 8000,
+ "MaxFormatRetries": 3,
+ "MaxIterationsPerTurn": 25
+ },
+
+ "Snapshot": {
+ "Mode": "Full", // Full | TreeAndChanged
+ "AttachOnFeedback": false, // also attach the snapshot to result messages
+ "RefreshAfterEachBatch": false,
+ "Filter": {
+ "ExcludeTreePatterns": [ "bin", "obj", ".git", "node_modules", ".agent", "artifacts" ],
+ "ExcludeDumpHeadings": [ "tree-view-result", "code-dump-result" ],
+ "SensitiveFilePatterns": [ ".env", ".env.*", "*.pem", "*.key", "*.pfx", "*.p12", "id_rsa*", "*.keystore", "secrets.json", "*secret*", "*password*", "*credential*", "appsettings.Production.json", "appsettings.*.Production.json" ],
+ "MaskSensitiveLines": true
+ }
+ },
+
+ "Git": {
+ "AutoInit": true, // auto git init + .gitignore + initial commit when the workspace is not a repo
+ "BranchPrefix": "agent/",
+ "ProtectedBranchPatterns": [ "main", "master", "release*" ]
+ },
+
+ "Commands": {
+ "Allowlist": [ "dotnet", "git", "npm", "node", "python" ],
+ "AutoApprove": [ "ReadOnly", "Build" ],
+ "BatchFailureMode": "StopOnMutationFailure",
+ "TimeoutSeconds": 300
+ }
+}
+```
+
+## Sections
+
+### Mode (approval)
+
+- **Ask** *(default)* — every `runcommand` and every `deletefile` needs your confirmation; file changes show a colored diff first.
+- **AutoSafe** — allow-listed `runcommand`s run automatically, everything else (including every `deletefile`) still asks. A warning is shown at turn start: **AutoSafe is not a security boundary** — allow-listed programs (dotnet, node, python, …) can execute arbitrary code.
+- **Auto** — everything runs automatically, including `deletefile` (the deny list still applies); `deletefile` is the only confirmation Auto skips. Backups are always made.
+
+### Browser — the Chrome 136 rule
+
+Since Chrome 136, `--remote-debugging-port` is **ignored when Chrome runs with its default
+user-data-dir**. Attaching to a daily Chrome profile is impossible; a dedicated profile is
+mandatory. KeylessAgent therefore uses one dedicated profile and one login:
+
+1. **Probe**: `http://127.0.0.1:/json/version` — if it answers, attach ("Attached to Chrome on port N").
+2. **Auto-launch** (default on): start the REAL Chrome with `--remote-debugging-port … --user-data-dir= --no-first-run --no-default-browser-check`, opening chat.deepseek.com, wait up to 30s for the port, attach. Log in to DeepSeek **once** in that window; the login persists in the profile.
+3. **Playwright fallback**: only with `AllowPlaywrightLaunch: true` (default false) — a separate automated browser, clearly warned.
+4. If Chrome cannot be found or started, the agent fails with `KA_BROWSER_CONNECTION_FAILED` and prints the exact manual-start command line.
+
+Tab rules: an existing `chat.deepseek.com` tab is reused (`ReuseExistingTab`); otherwise a new TAB is opened in the existing window (`Target.createTarget … newWindow:false`). The agent never navigates your other tabs. The chosen tab is focused (`BringToFrontAsync` + `Target.activateTarget`) only when the user must act (login page detected).
+
+**Login detection is positive-first**: one parallel wait races the chat InputBox against the login indicators (`LoginProbeSeconds`, default 15 — all selectors in a single combined wait, never one timeout per fallback). If the input appears first you are logged in and no login wait happens; only a winning login indicator focuses the tab and waits for the manual login (5 minutes, polled with instant checks). Absent elements cost their wait once — not once per fallback.
+
+**Fast waits by design**: presence questions (stop indicator, chat-limit notice, chips, toggles, send button) are answered with instant single-round-trip checks, and reply polling runs at a ~100 ms cadence. `keyless run … --timing` prints the per-phase timing table (login probe, toggles, upload, send, send-confirm, first token, completion, copy, parse, execute, results-send) after the turn; the same table is always appended to `.agent/logs/turn-N.jsonl`.
+
+Dispose policy: only the tab the agent opened is closed; the Chrome window stays running by default (`CloseChromeOnExit: false`) so the next run reuses the warm session.
+
+`keyless chrome ` starts only the dedicated Chrome (for logging in first), and `keyless doctor ` reports which connection path was taken.
+- **Toggles**: before the first message of a new chat the agent reads each toggle's `aria-pressed` and only clicks when it disagrees with the config; if a toggle cannot be found it warns and continues.
+- **Debug/verbose** (`keyless run … -v` or `Browser.Debug`): writes `.agent/logs/diagnostics.log` and saves screenshots on errors.
+
+### Limits
+
+See [agentscript-spec.md](agentscript-spec.md#limits-enforced-injected-into-the-system-prompt).
+
+
+### Browser window
+
+- **WindowMode**: `Minimized` (default), `Offscreen`, or `Normal`. Affects auto-launched Chrome only. When minimized, throttling flags (`--disable-renderer-backgrounding`, `--disable-background-timer-throttling`, `--disable-backgrounding-occluded-windows`) prevent slow-downs.
+- **MinimizeAfterLogin**: minimize the window again after login completes (default true). The window is restored ONLY for login/captcha.
+
+### Snapshot — sensitive content never reaches the chat
+
+- `Snapshot.Filter.SensitiveFilePatterns` (case-insensitive `*` wildcards against the file name) drops dump sections of secret files from the ATTACHED copy: environment files (`.env`), key material (`.pem`, `.key`, `.pfx`, `.p12`, `id_rsa*`, `*.keystore`), and anything named `*secret*`/`*password*`/`*credential*`/`appsettings.Production.json` by default.
+- `Snapshot.Filter.MaskSensitiveLines` (default true) redacts secret-looking VALUES in the remaining sections: `Password=…`, `"ApiKey": "…"`, `Authorization: Bearer …` and PEM private-key blocks become `[redacted]` — key names and structure are kept.
+- The tree attachment reports it: `[N files filtered and M values masked as sensitive]`.
+- **Attachment copy only** — the files on disk (including `code-dump-result.md` and `tree-view-result.txt`) are never modified.
+
+### Snapshot
+
+- The snapshots are **generated natively in C#** — no `.bat` files need to exist in the workspace. `tree-view-result.txt` and `code-dump-result.md` are written into the workspace root (and excluded from git via `.git/info/exclude`).
+- Filtering applies to the **attached copy only** — `tree-view-result.txt` and `code-dump-result.md` on disk are never modified.
+- Oversized dumps are split at `### heading` boundaries into `code-dump-result-part1.md`, `-part2.md`, … and uploaded as consecutive files.
+
+### Git
+
+Protected patterns are matched case-insensitively with `*` wildcards. The guard runs **in code** before every file-changing command and every commit/push.
+
+### Commands
+
+The allow list matches the first program name of the command line. The built-in deny list (format/shutdown/force-push/`git config --global`/`curl | sh`/recursive deletes/encoded PowerShell/…) always applies and cannot be configured away.
+
+**Risk tiers** classify every command segment: ReadOnly (git status, dotnet --version, node -v, dir, type, cat, echo, …), Build (dotnet build/restore/clean), Execute (dotnet test/run, npm test/run, node file, python file, …), Risky (inline interpreters, redirection, command substitution, package installs, deletes/moves/copies, git writes, workspace-escaping paths, unknown programs — always confirmed even in AutoSafe), Denied (the built-in deny list — never runs).
+
+**AutoApprove** lists the tiers that run without confirmation in Ask mode (default: ReadOnly and Build). **BatchFailureMode** controls what happens after a failed command: `StopOnMutationFailure` (default — a ReadOnly probe failure never stops the batch, everything else does), `StopOnAnyFailure`, or `Continue`. Failed command output is always sent to the model so it can adapt.
+
+## Other files
+
+- `selectors.json` — the editable chat selector catalog (see [browser-automation.md](browser-automation.md)); created with defaults on first run.
+- `.agent/session.json` — the saved chat URL, branch and turn counter (powers `keyless resume`). A corrupt file never crashes a turn: it is renamed to `session.json.corrupt` and treated as no session.
+- `.agent/logs/turn-N.jsonl` — the per-turn transcript is best-effort: the first write failure disables it for the rest of the turn with one warning. Command **output is redacted before writing** (`Password=…`, tokens, Bearer/Basic values → `[redacted]`); requests and replies are stored as received.
+- `.agent/backups/` — timestamped backups of every changed file (powers `keyless undo`).
+- `.git/info/exclude` — the agent adds `.agent/`, `tree-view-result.txt` and `code-dump-result.md` automatically.
diff --git a/docs/git-workflow.md b/docs/git-workflow.md
new file mode 100644
index 0000000..0ccb976
--- /dev/null
+++ b/docs/git-workflow.md
@@ -0,0 +1,36 @@
+# Git workflow
+
+## Session start (per workspace)
+
+1. **Repository check** — probed once per session via IWorkspaceInspector (GitInstalled, IsRepository, CurrentBranch, IsAgentBranch). Not a git repo:
+ - `Git.AutoInit` true (default): the agent checks that git is installed (`KA_GIT_NOT_INSTALLED` with a download link when missing), runs `git init -b main` (fallback `git init` + `git branch -M main` on old git), creates a .NET `.gitignore` when the workspace contains a `.sln`/`.slnx`/`.csproj` (`dotnet new gitignore`, else a built-in minimal template), makes the initial commit "Initial commit (created by KeylessAgent)" — with a one-shot `-c user.name="KeylessAgent" -c user.email="keyless@localhost"` identity when none is configured (git config is never changed) — and tells the user in one line. Failure → `KA_GIT_INIT_FAILED`.
+ - `Git.AutoInit` false: the turn stops with `KA_NOT_A_GIT_REPOSITORY`.
+ - **git not installed at all**: ONE warning ("no branch protection, no commits, no git history, changes cannot be reverted with git; file changes are still backed up in .agent/backups"), then NO-GIT MODE: no branch guard, no agent branch, no dirty menu, no commit/push. @@commit returns Error/KA_GIT_NOT_INSTALLED "git unavailable, nothing committed" without failing the turn. The system prompt carries a note telling the model not to use @@commit or git commands.
+2. **Exclude upkeep** — `.agent/`, `tree-view-result.txt`, `code-dump-result.md` are appended to `.git/info/exclude` when missing.
+3. **Dirty tree (numbered menu, no typing)** — skipped when every dirty path was changed by the agent itself (tracked in session.AgentChangedPaths) AND the current branch is an agent branch. Options: **carry** (changes move to the new agent branch; only offered when NOT already on an agent branch), **stash** (`git stash push -u -m "keyless-agent auto stash"`; restore later yourself), **here** (continue on the current branch — only on non-protected branches), **cancel**. Default: carry off-branch, here on-branch. Arrow keys / digits / Enter / Esc.
+
+Change tracking works with and without git: with git, `git status --porcelain` snapshots are compared; without git, a lightweight path/size/mtime index (excluding bin/obj/.git/.agent/node_modules/artifacts/.vs/.idea/TestResults) is used. The agent-changed path set is persisted in `.agent/session.json` after each batch.
+4. **Main branch detection** — `git symbolic-ref refs/remotes/origin/HEAD`, falling back to `origin/main`, then `origin/master`, then `main`.
+5. **Branch setup**
+ - Current branch protected (`main`/`master`/`release*`, case-insensitive, `*` wildcards, configurable) → **no menu**: the agent creates `agent/-` automatically and tells the user. The slug comes from the request (kebab-case, `[a-z0-9-]`, max 40 chars, `task` when nothing survives — non-Latin requests included).
+ - Current branch not protected AND the tree is clean → one menu: **new** (default; create a new agent branch) / **stay** (continue on the current branch) / **cancel**. When the tree was dirty, the dirty menu already decided (carry → new agent branch, here → stay) and no second menu appears.
+
+At most **one menu** per turn; none when the tree is clean and the branch is protected.
+
+## During the turn
+
+- The **branch guard runs in code** before every `newfile`, `updatefile`, `editfile`, `deletefile`, and every `commit`/push — a protected branch can never be modified even if the model asks.
+- `commit` = `git add -A` → `git commit -m ""` → push. When the repo has **no `origin` remote**, the commit is made locally and the push is SKIPPED with a notice ("No 'origin' remote; committed locally") — the result sent to the model says so. When origin exists and the push fails, the failure is returned to the model as an error result and shown to the user.
+
+## Hard never-list (enforced by the policy / git service)
+
+No force push, no `reset --hard` on shared branches, no branch deletion, no merge, no rebase onto protected branches, no git global/system config changes. **Merging the agent branch is done only by the developer.**
+
+## End of turn
+
+- The branch name and the commit list (`git log --oneline`, newest first) are shown to the user.
+- The session (chat URL + branch + turn number) is persisted in `.agent/session.json`, so later requests (and `keyless resume`) continue in the same chat on the same branch.
+
+## Undo
+
+Every changed file is backed up to `.agent/backups//…` before modification. `keyless undo` restores the newest batch — it does **not** touch commits; use git for that.
diff --git a/docs/security.md b/docs/security.md
new file mode 100644
index 0000000..979c80d
--- /dev/null
+++ b/docs/security.md
@@ -0,0 +1,61 @@
+# Security model
+
+Model output is arbitrary text that gets executed on the user's machine. Every layer below is enforced in C# (not only in the prompt) and covered by unit tests.
+
+## Path sandbox
+
+Every model-supplied path goes through `IPathSandbox` before any file operation:
+
+- workspace-**relative** only, `/` separators,
+- absolute paths, `..` and `.` segments rejected,
+- anything under `.git` or `.agent` rejected (agent-internal state),
+- escapes through symlinks/junctions rejected (`ResolveLinkTarget` walk of the path),
+- resolved paths must stay under the workspace root (case-insensitive on Windows).
+
+`ImanSoftware.FileStorage` itself has **no** path safety — the sandbox is always the first gate.
+
+## Command policy
+
+`ICommandPolicy` checks **every segment** of a `runcommand` line (split on `&&`, `||`, `;`, `|`):
+
+- **Deny list (built-in, not configurable):** `format`, `shutdown`, `diskpart`, `mkfs`…, `git config --global/--system`, `git push --force`/`-f`, `git branch -d`, `rd /s`, `rm -rf /`, `del /s`, encoded PowerShell (`-enc`), and piping downloads into shells (`curl … | sh`, `… | powershell`).
+- **Allow list** (`Commands.Allowlist`): decides what runs automatically in `AutoSafe` mode.
+
+Every process runs with a hard timeout and captured output.
+
+## Git rules (see also git-workflow.md)
+
+- The workspace must be a git repository.
+- No file change, commit or push on `main` / `master` / `release*` — the guard runs before **every** affected command, and results carry `KA_BRANCH_PROTECTED`.
+- Never: force push, `reset --hard` on shared branches, branch deletion, merge, rebase onto protected branches, git global config changes. Merging is done only by the developer.
+- `.agent/`, `tree-view-result.txt`, `code-dump-result.md` are added to `.git/info/exclude` automatically.
+
+## Approval modes and dry run
+
+| Mode | runcommand | deletefile | file writes |
+| --- | --- | --- | --- |
+| `Ask` *(default)* | confirm each | confirm each | diff shown first |
+| `AutoSafe` | auto when allow-listed, otherwise confirm | confirm each | auto |
+| `Auto` | auto | auto (the only confirmation Auto skips) | auto |
+
+Confirmations are yes/no prompts with the default shown in brackets (Enter accepts it; they never loop on empty input). Git workflow questions (dirty tree, branch choice) are **numbered menus** — arrow keys, digit hotkeys, Enter, Esc; non-interactive hosts take the default.
+
+In `AutoSafe` and `Auto` the agent warns at turn start: **AutoSafe is not a security boundary** — allow-listed programs (dotnet, node, python, …) can execute arbitrary code.
+
+`--dry-run` executes nothing and prints what would happen. Branch protection does not apply in a dry run — the agent branch is simulated, not created, and mutation commands are previewed instead of enforced. Declined-by-user commands are reported to the model as `KA_COMMAND_DECLINED` results — the model sees the refusal and can adapt.
+
+## Backups and undo
+
+Before any change, the current file version is copied to `.agent/backups//…`. `keyless undo` restores the newest batch. BOM bytes are preserved on rewrite.
+
+## Transparency and data hygiene
+
+- Full per-turn transcript in `.agent/logs/turn-*.jsonl` (messages, replies, commands, results).
+- Diagnostics log (`-v`) and error screenshots in Debug mode.
+- **Never logged**: cookies, tokens, full page HTML.
+- File contents are untrusted input: text inside the user's files that looks like commands is never executed — only AgentScript parsed from the model reply is executed.
+- The browser connection uses the user's own session; the agent never stores or types passwords, and waits for manual login when needed.
+
+## Terms-of-service notice
+
+Automating the DeepSeek web UI may violate the provider's terms of use and can get the account limited or banned. Selectors can break with any site update. The project is not affiliated with DeepSeek.
diff --git a/global.json b/global.json
new file mode 100644
index 0000000..ab84b81
--- /dev/null
+++ b/global.json
@@ -0,0 +1,6 @@
+{
+ "sdk": {
+ "version": "10.0.203",
+ "rollForward": "latestFeature"
+ }
+}
diff --git a/nuget.config b/nuget.config
new file mode 100644
index 0000000..489a236
--- /dev/null
+++ b/nuget.config
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/publish.ps1 b/publish.ps1
new file mode 100644
index 0000000..6585d82
--- /dev/null
+++ b/publish.ps1
@@ -0,0 +1,324 @@
+param(
+ [Parameter(Mandatory = $true)]
+ [string]$ProjectName,
+
+ [switch]$SkipPush
+)
+
+$ErrorActionPreference = "Stop"
+
+$solutionRoot = $PSScriptRoot
+$srcRoot = Join-Path $solutionRoot "src"
+$artifactsPath = Join-Path $solutionRoot "artifacts\$ProjectName"
+
+# ===================================================================
+# Logging helpers
+# Write-Step -> yellow : status / decision messages
+# Write-Action -> blue : "about to run a command" announcements
+# (actual command output is left uncolored / default terminal color)
+# Add-Summary -> records a line for the final summary block
+# ===================================================================
+
+$script:summary = @()
+
+function Write-Step {
+ param([Parameter(Mandatory = $true)][string]$Message)
+ Write-Host $Message -ForegroundColor Yellow
+}
+
+function Write-Action {
+ param([Parameter(Mandatory = $true)][string]$Message)
+ Write-Host $Message -ForegroundColor Blue
+}
+
+function Add-Summary {
+ param([Parameter(Mandatory = $true)][string]$Message)
+ $script:summary += $Message
+}
+
+function Show-Summary {
+ Write-Host ""
+ Write-Host "===================== Summary =====================" -ForegroundColor Cyan
+ if ($script:summary.Count -eq 0) {
+ Write-Host " (nothing was recorded)" -ForegroundColor Cyan
+ } else {
+ foreach ($line in $script:summary) {
+ Write-Host " - $line" -ForegroundColor Cyan
+ }
+ }
+ Write-Host "=====================================================" -ForegroundColor Cyan
+}
+
+function Bump-PatchVersion {
+ param([Parameter(Mandatory = $true)][string]$Version)
+
+ if ($Version -notmatch '^(?\d+)\.(?\d+)\.(?\d+)(?[-+].*)?$') {
+ throw "Version '$Version' is not in a recognizable Major.Minor.Patch format."
+ }
+
+ $major = $Matches['major']
+ $minor = $Matches['minor']
+ $patch = [int]$Matches['patch'] + 1
+ $suffix = $Matches['suffix']
+
+ return "$major.$minor.$patch$suffix"
+}
+
+function Get-BuildPropsVersion {
+ param([Parameter(Mandatory = $true)][string]$Path)
+ [xml]$xml = Get-Content $Path
+ $value = $xml.Project.PropertyGroup.Version | Where-Object { $_ } | Select-Object -First 1
+ # Explicit cast: the pipeline above can hand back a wrapped value that
+ # later fails when assigned to an XmlAttribute, even though it prints fine elsewhere.
+ return [string]$value
+}
+
+function Set-BuildPropsVersion {
+ param(
+ [Parameter(Mandatory = $true)][string]$Path,
+ [Parameter(Mandatory = $true)][string]$NewVersion
+ )
+ [xml]$xml = Get-Content $Path
+ $propertyGroup = $xml.Project.PropertyGroup | Where-Object { $_.Version } | Select-Object -First 1
+
+ if (-not $propertyGroup) {
+ throw "Could not find a with a element in $Path"
+ }
+
+ $propertyGroup.Version = [string]$NewVersion
+ $xml.Save($Path)
+}
+
+# Runs an external process under a hard, PowerShell-enforced wall-clock timeout.
+# This exists because "dotnet nuget push --timeout N" only bounds the actual
+# upload request — the preceding service-index lookup (GET .../v3/index.json)
+# has its own internal ~100s timeout that --timeout does NOT control. If the
+# process doesn't exit in time we kill the whole process tree (dotnet can spawn
+# child processes) via taskkill /T /F so nothing is left running in the background.
+#
+# Uses System.Diagnostics.Process directly (not the Start-Process cmdlet) because
+# Start-Process -PassThru combined with output redirection can unreliably report
+# ExitCode even after the process has exited successfully.
+function ConvertTo-ArgumentString {
+ param([string[]]$Arguments)
+ return ($Arguments | ForEach-Object {
+ if ($_ -match '[\s"]') {
+ '"' + ($_ -replace '"', '\"') + '"'
+ } else {
+ $_
+ }
+ }) -join ' '
+}
+
+function Invoke-ProcessWithTimeout {
+ param(
+ [Parameter(Mandatory = $true)][string]$FilePath,
+ [Parameter(Mandatory = $true)][string[]]$ArgumentList,
+ [Parameter(Mandatory = $true)][int]$TimeoutSeconds
+ )
+
+ $psi = New-Object System.Diagnostics.ProcessStartInfo
+ $psi.FileName = $FilePath
+ $psi.Arguments = ConvertTo-ArgumentString -Arguments $ArgumentList
+ $psi.UseShellExecute = $false
+ $psi.RedirectStandardOutput = $true
+ $psi.RedirectStandardError = $true
+ $psi.CreateNoWindow = $true
+
+ $proc = New-Object System.Diagnostics.Process
+ $proc.StartInfo = $psi
+
+ $stdOutBuilder = New-Object System.Text.StringBuilder
+ $stdErrBuilder = New-Object System.Text.StringBuilder
+
+ $outEvent = Register-ObjectEvent -InputObject $proc -EventName OutputDataReceived -Action {
+ if ($EventArgs.Data) { $Event.MessageData.AppendLine($EventArgs.Data) | Out-Null }
+ } -MessageData $stdOutBuilder
+
+ $errEvent = Register-ObjectEvent -InputObject $proc -EventName ErrorDataReceived -Action {
+ if ($EventArgs.Data) { $Event.MessageData.AppendLine($EventArgs.Data) | Out-Null }
+ } -MessageData $stdErrBuilder
+
+ try {
+ $proc.Start() | Out-Null
+ $proc.BeginOutputReadLine()
+ $proc.BeginErrorReadLine()
+
+ $exited = $proc.WaitForExit($TimeoutSeconds * 1000)
+
+ if (-not $exited) {
+ try {
+ Start-Process -FilePath "taskkill.exe" -ArgumentList "/PID $($proc.Id) /T /F" -NoNewWindow -Wait -ErrorAction SilentlyContinue | Out-Null
+ } catch {}
+ Start-Sleep -Milliseconds 300
+ } else {
+ # Blocks only until the already-exited process's streams finish flushing.
+ $proc.WaitForExit()
+ }
+ }
+ finally {
+ Unregister-Event -SourceIdentifier $outEvent.Name -ErrorAction SilentlyContinue
+ Unregister-Event -SourceIdentifier $errEvent.Name -ErrorAction SilentlyContinue
+ Remove-Job $outEvent, $errEvent -Force -ErrorAction SilentlyContinue
+ }
+
+ if ($stdOutBuilder.Length -gt 0) { Write-Host $stdOutBuilder.ToString().TrimEnd() }
+ if ($stdErrBuilder.Length -gt 0) { Write-Host $stdErrBuilder.ToString().TrimEnd() }
+
+ if (-not $exited) {
+ return [PSCustomObject]@{ TimedOut = $true; ExitCode = $null }
+ }
+
+ return [PSCustomObject]@{ TimedOut = $false; ExitCode = $proc.ExitCode }
+}
+
+# ===================================================================
+# Main
+# ===================================================================
+
+try {
+ # KeylessAgent nests projects under src\Core, src\UI, src\Playground, ... so we
+ # locate the .csproj by name instead of assuming a fixed path.
+ Write-Step "looking for $ProjectName.csproj under $srcRoot ..."
+
+ $csprojFile = Get-ChildItem -Path $srcRoot -Filter "$ProjectName.csproj" -Recurse -ErrorAction SilentlyContinue |
+ Select-Object -First 1
+
+ if (-not $csprojFile) {
+ Write-Error "No project named '$ProjectName' found under $srcRoot (searched recursively)."
+ }
+
+ $projectPath = $csprojFile.DirectoryName
+ $csprojPath = $csprojFile.FullName
+ Write-Step "found project at $projectPath"
+
+ # Note: previous builds are kept intentionally, so you have a local history
+ # of every version ever packed. We don't delete anything here.
+ if (-not (Test-Path $artifactsPath)) {
+ Write-Step "artifacts folder for $ProjectName does not exist yet; creating $artifactsPath"
+ New-Item -Path $artifactsPath -ItemType Directory | Out-Null
+ }
+
+ # ---------------------------------------------------------------
+ # Pack (auto-bumping the patch version if it was already packed)
+ # ---------------------------------------------------------------
+
+ # The version lives in ONE place: Directory.Build.props (the csproj no longer carries it).
+ $buildPropsPath = Join-Path $solutionRoot "Directory.Build.props"
+ if (-not (Test-Path $buildPropsPath)) {
+ Write-Error "Could not find $buildPropsPath."
+ }
+
+ $version = Get-BuildPropsVersion -Path $buildPropsPath
+
+ if (-not $version) {
+ Write-Error "Could not read from $buildPropsPath. Make sure it's set explicitly in Directory.Build.props."
+ }
+
+ $originalVersion = $version
+
+ Write-Step "checking artifacts/$ProjectName for any $version output."
+ $expectedNupkg = Join-Path $artifactsPath "$ProjectName.$version.nupkg"
+
+ while (Test-Path $expectedNupkg) {
+ $previous = $version
+ $version = Bump-PatchVersion -Version $version
+ Write-Step "auto bumping version for $ProjectName from $previous to $version"
+ Add-Summary "Version auto-bumped: $previous -> $version (an artifact for $previous already existed)"
+ Write-Step "checking artifacts/$ProjectName for any $version output."
+ $expectedNupkg = Join-Path $artifactsPath "$ProjectName.$version.nupkg"
+ }
+
+ Write-Step "version $version is free; no existing artifact found."
+
+ if ($version -ne $originalVersion) {
+ Set-BuildPropsVersion -Path $buildPropsPath -NewVersion $version
+ Write-Step "updated in $buildPropsPath : $originalVersion -> $version"
+ Add-Summary "Directory.Build.props updated: Version $originalVersion -> $version"
+ } else {
+ Add-Summary "Version used: $ProjectName $version (no bump needed)"
+ }
+
+ Write-Action "packing $ProjectName v$version ..."
+ dotnet pack $projectPath -c Release --output $artifactsPath
+ if ($LASTEXITCODE -ne 0) {
+ Add-Summary "FAILED: dotnet pack for $ProjectName v$version"
+ Write-Error "Pack failed."
+ }
+ Add-Summary "Packed: $ProjectName v$version -> $artifactsPath"
+
+ # The package we just built has the EXACT name for the current version — never pick
+ # "newest *.nupkg by timestamp", which can grab the wrong file if anything else
+ # touched the folder after the pack.
+ $nupkgPath = Join-Path $artifactsPath "$ProjectName.$version.nupkg"
+
+ if (-not (Test-Path $nupkgPath)) {
+ Write-Error "The package for v$version was not found after packing: $nupkgPath"
+ }
+
+ $snupkgPath = Join-Path $artifactsPath "$ProjectName.$version.snupkg"
+
+ Write-Step "package built: $(Split-Path $nupkgPath -Leaf)"
+
+ if ($SkipPush) {
+ Write-Step "SkipPush is set; packed only, not pushed."
+ Add-Summary "Push skipped (-SkipPush): $(Split-Path $nupkgPath -Leaf) left in $artifactsPath"
+ return
+ }
+
+ # ---------------------------------------------------------------
+ # Push (timeout; on failure, remove the artifacts we just built
+ # so the next run doesn't see a "used" version and bump for no reason)
+ # ---------------------------------------------------------------
+
+ # The API key is needed ONLY for the push: pack-only runs (-SkipPush) work without it.
+ Write-Action "pushing package to nuget ..."
+
+ $ApiKey = $env:NUGET_API_KEY
+ if ([string]::IsNullOrWhiteSpace($ApiKey)) {
+ Write-Error "Environment variable NUGET_API_KEY is not set. Run 'setx NUGET_API_KEY your-key' and restart your terminal."
+ }
+
+ $pushTimeoutSeconds = 30
+ $pushArgs = @(
+ 'nuget', 'push', $nupkgPath,
+ '--source', 'https://api.nuget.org/v3/index.json',
+ '--api-key', $ApiKey,
+ '--skip-duplicate',
+ '--timeout', "$pushTimeoutSeconds"
+ )
+
+ $pushResult = Invoke-ProcessWithTimeout -FilePath 'dotnet' -ArgumentList $pushArgs -TimeoutSeconds $pushTimeoutSeconds
+
+ if ($pushResult.TimedOut) {
+ Write-Step "push did not finish within ${pushTimeoutSeconds}s; the dotnet process was forcefully terminated."
+ Add-Summary "Push timed out after ${pushTimeoutSeconds}s (process killed): $ProjectName v$version"
+ } elseif ($pushResult.ExitCode -ne 0) {
+ Write-Step "push failed (exit code $($pushResult.ExitCode))."
+ Add-Summary "Push failed (exit code $($pushResult.ExitCode)): $ProjectName v$version"
+ }
+
+ if ($pushResult.TimedOut -or $pushResult.ExitCode -ne 0) {
+ Write-Step "removing the artifacts just built for v$version so the version isn't wasted."
+
+ if (Test-Path $nupkgPath) {
+ Remove-Item $nupkgPath -Force
+ Write-Step "removed $(Split-Path $nupkgPath -Leaf)"
+ Add-Summary "Removed after failed push: $(Split-Path $nupkgPath -Leaf)"
+ }
+ if (Test-Path $snupkgPath) {
+ Remove-Item $snupkgPath -Force
+ Write-Step "removed $(Split-Path $snupkgPath -Leaf)"
+ Add-Summary "Removed after failed push: $(Split-Path $snupkgPath -Leaf)"
+ }
+
+ Add-Summary "FAILED: dotnet nuget push for $ProjectName v$version"
+ Write-Error "Push failed."
+ }
+
+ Write-Step "$(Split-Path $nupkgPath -Leaf) was successfully published to NuGet.org."
+ Add-Summary "Pushed to NuGet.org: $(Split-Path $nupkgPath -Leaf)"
+}
+finally {
+ Show-Summary
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/ImanSoftware.KeylessAgent.Abstractions.csproj b/src/Core/ImanSoftware.KeylessAgent.Abstractions/ImanSoftware.KeylessAgent.Abstractions.csproj
new file mode 100644
index 0000000..bd4bd64
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/ImanSoftware.KeylessAgent.Abstractions.csproj
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentProgressEvent.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentProgressEvent.cs
new file mode 100644
index 0000000..1bc9005
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentProgressEvent.cs
@@ -0,0 +1,10 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+///
+/// A progress event reported by the agent loop while a turn is running.
+///
+/// What is happening right now.
+/// Short human-readable detail (English).
+public sealed record AgentProgressEvent(AgentEventType Type, string Detail);
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentSession.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentSession.cs
new file mode 100644
index 0000000..135cad9
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentSession.cs
@@ -0,0 +1,48 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+///
+/// The persisted agent session state in .agent/session.json , powering keyless resume .
+///
+public sealed record AgentSession
+{
+ ///
+ /// The chat URL the session runs in.
+ ///
+ public string ChatUrl { get; init; } = string.Empty;
+
+ ///
+ /// The agent branch created for this session.
+ ///
+ public string Branch { get; init; } = string.Empty;
+
+ ///
+ /// How many user requests this session has processed.
+ ///
+ public int Turn { get; init; }
+
+ ///
+ /// Whether the system prompt was already sent to this chat (first message only).
+ ///
+ public bool SystemPromptSent { get; init; }
+
+ ///
+ /// UTC timestamp of session creation.
+ ///
+ public string CreatedAtUtc { get; init; } = string.Empty;
+
+ ///
+ /// UTC timestamp of the last activity.
+ ///
+ public string LastActivityUtc { get; init; } = string.Empty;
+
+ ///
+ /// Workspace-relative paths the AGENT changed (file commands plus runcommand-detected
+ /// changes). Drives the dirty-menu skip.
+ ///
+ public IReadOnlyList AgentChangedPaths { get; init; } = [];
+
+ ///
+ /// Turns served by the current chat (for Chat.MaxTurnsPerChat).
+ ///
+ public int TurnsInChat { get; init; }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentTurnResult.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentTurnResult.cs
new file mode 100644
index 0000000..5230da2
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/AgentTurnResult.cs
@@ -0,0 +1,80 @@
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+///
+/// The outcome of one agent turn (one user request), reported to the UI at the end.
+///
+public sealed record AgentTurnResult
+{
+ ///
+ /// The branch the turn worked on.
+ ///
+ public string Branch { get; init; } = string.Empty;
+
+ ///
+ /// The 1-based request number within the session.
+ ///
+ public int Turn { get; init; }
+
+ ///
+ /// Commit summaries made during the turn.
+ ///
+ public IReadOnlyList Commits { get; init; } = [];
+
+ ///
+ /// The summary from the final @@done , when present.
+ ///
+ public string? Summary { get; init; }
+
+ ///
+ /// True when the model ended the turn with @@done .
+ ///
+ public bool CompletedByDone { get; init; }
+
+ ///
+ /// Why the turn stopped early (iteration limit, format retries exhausted, error, cancelled); null when it ended normally.
+ ///
+ public string? StoppedReason { get; init; }
+
+ ///
+ /// Error message when the turn failed before any model interaction.
+ ///
+ public string? ErrorMessage { get; init; }
+
+ ///
+ /// The stable KA_ code for the failure, when known.
+ ///
+ public string? ErrorCode { get; init; }
+
+ ///
+ /// True when the turn ran in dry-run mode (nothing was executed).
+ ///
+ public bool DryRun { get; init; }
+
+ ///
+ /// The workspace files changed during this turn (added/modified/deleted/renamed).
+ ///
+ public IReadOnlyList ChangeSet { get; init; } = [];
+
+ ///
+ /// The per-phase timing table of this turn (the same text that is appended to the turn log).
+ ///
+ public string? Timings { get; init; }
+
+ ///
+ /// How many runcommand executions ran without asking the user (auto-approved by tier).
+ ///
+ public int AutoApprovedCommands { get; init; }
+
+ ///
+ /// How many runcommand executions asked the user for confirmation (accepted or declined).
+ ///
+ public int ConfirmedCommands { get; init; }
+
+ ///
+ /// Output of git diff --stat <base>...HEAD after the turn; empty when git
+ /// is unavailable, the turn was dry-run, or git failed (never fails the turn).
+ ///
+ public string DiffStat { get; init; } = "";
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/ChoiceRequest.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/ChoiceRequest.cs
new file mode 100644
index 0000000..9fac364
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/ChoiceRequest.cs
@@ -0,0 +1,32 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+///
+/// One selectable option of a numbered choice menu.
+///
+/// Stable identifier the agent logic switches on (e.g. "carry", "stash").
+/// Text shown to the user.
+/// Optional longer explanation shown under the label.
+public sealed record ChoiceOption(string Id, string Label, string? Description = null);
+
+///
+/// A numbered choice menu: the user moves with arrow keys (or presses a digit) and confirms
+/// with Enter; Esc cancels. UI-agnostic so a Windows Forms UI can implement it with a dialog.
+///
+/// The question shown above the options.
+/// The options in display order (numbered 1..N).
+/// The initially highlighted option's .
+public sealed record ChoiceRequest(
+ string Title,
+ IReadOnlyList Options,
+ string DefaultId);
+
+///
+/// The outcome of a choice menu.
+///
+/// The chosen option's id, or null when the menu was cancelled.
+/// Whether the user cancelled (Esc / Ctrl+C).
+public sealed record ChoiceResult(string? SelectedId, bool Cancelled)
+{
+ /// A cancelled menu result.
+ public static ChoiceResult CancelledMenu() => new(null, true);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/ConfirmRequest.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/ConfirmRequest.cs
new file mode 100644
index 0000000..792552d
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/ConfirmRequest.cs
@@ -0,0 +1,8 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+///
+/// A yes/no question for the user, shown before a risky command runs.
+///
+/// Short title of what is about to happen.
+/// Why the confirmation is needed and what exactly will run or change.
+public sealed record ConfirmRequest(string Title, string Details);
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/FileChange.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/FileChange.cs
new file mode 100644
index 0000000..d2d0568
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Agent/FileChange.cs
@@ -0,0 +1,28 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+///
+/// The kind of change a file command applies, for diff display.
+///
+public enum FileChangeKind
+{
+ /// A new file is created.
+ Create,
+
+ /// The whole file content is replaced.
+ Update,
+
+ /// A single occurrence of text is replaced.
+ Edit,
+
+ /// The file is deleted.
+ Delete,
+}
+
+///
+/// A pending file change, shown to the user (as a colored diff in Ask mode) before it is applied.
+///
+/// Workspace-relative path.
+/// The kind of change.
+/// Current content, when the file exists.
+/// Content after the change, when the file survives it.
+public sealed record FileChange(string Path, FileChangeKind Kind, string? OldContent, string? NewContent);
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs
new file mode 100644
index 0000000..519db06
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Chat/ChatSessionOptions.cs
@@ -0,0 +1,30 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Chat;
+
+///
+/// Everything the chat provider needs to open (or resume) one chat session.
+///
+public sealed record ChatSessionOptions
+{
+ ///
+ /// Browser automation settings (CDP port, toggles, selectors file, timeouts, rate limits).
+ ///
+ public BrowserOptions Browser { get; init; } = new();
+
+ ///
+ /// Absolute path of the workspace root (for the selectors file, attachments, debug artifacts).
+ ///
+ public string WorkspaceRoot { get; init; } = string.Empty;
+
+ ///
+ /// URL of an existing chat to resume; null opens a new chat.
+ ///
+ public string? ResumeChatUrl { get; init; }
+
+ ///
+ /// Optional per-phase timing recorder (the --timing table). Null when timing is off.
+ ///
+ public ITimingRecorder? Timings { get; init; }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs
new file mode 100644
index 0000000..bffa5cd
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Configuration/KeylessOptions.cs
@@ -0,0 +1,318 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+
+///
+/// Root of keyless.json , the per-workspace configuration file.
+/// All properties have defaults; the file may be partial or absent.
+///
+public sealed record KeylessOptions
+{
+ ///
+ /// How much confirmation the agent asks for. Default: .
+ ///
+ public ApprovalMode Mode { get; init; } = ApprovalMode.Ask;
+
+ ///
+ /// Browser automation settings.
+ ///
+ public BrowserOptions Browser { get; init; } = new();
+
+ ///
+ /// Response size and loop limits, injected into the system prompt.
+ ///
+ public LimitsOptions Limits { get; init; } = new();
+
+ ///
+ /// Snapshot (workspace tree / code dump) behavior.
+ ///
+ public SnapshotOptions Snapshot { get; init; } = new();
+
+ ///
+ /// Git branch settings.
+ ///
+ public GitOptions Git { get; init; } = new();
+
+ ///
+ /// External command policy settings.
+ ///
+ public CommandsOptions Commands { get; init; } = new();
+}
+
+///
+/// Browser automation settings (Browser section).
+///
+public sealed record BrowserOptions
+{
+ ///
+ /// The Chrome remote debugging port used with ConnectOverCDP . Default 9222.
+ ///
+ public int ChromeDebugPort { get; init; } = 9222;
+
+ ///
+ /// Dedicated Chrome profile directory (mandatory since Chrome 136: the default
+ /// user-data-dir ignores --remote-debugging-port). Default
+ /// %LOCALAPPDATA%\KeylessAgent\chrome-profile . The DeepSeek login persists here.
+ ///
+ public string UserDataDir { get; init; } = string.Empty;
+
+ ///
+ /// Start the real Chrome with the dedicated profile automatically when the debug port
+ /// is not open. Default true.
+ ///
+ public bool AutoLaunchChrome { get; init; } = true;
+
+ ///
+ /// Explicit path of chrome.exe. When empty, the registry (App Paths) and the common
+ /// install locations are searched.
+ ///
+ public string ChromePath { get; init; } = string.Empty;
+
+ ///
+ /// Reuse an already-open chat.deepseek.com tab instead of opening a new one. Default true.
+ ///
+ public bool ReuseExistingTab { get; init; } = true;
+
+ ///
+ /// Maximum turns per chat before opening a new chat automatically. Default 20.
+ ///
+ public int MaxTurnsPerChat { get; init; } = 20;
+
+ ///
+ /// Opt-in Playwright-managed fallback browser when the real Chrome cannot be found or
+ /// started. Default false (an automated browser with its own profile is confusing).
+ ///
+ public bool AllowPlaywrightLaunch { get; init; }
+
+ ///
+ /// Close the Chrome window the agent launched when the session ends. Default false:
+ /// the profile stays warm and the login persists.
+ ///
+ public bool CloseChromeOnExit { get; init; }
+
+ ///
+ /// Whether the DeepThink toggle is turned on before the first message of a chat. Default true.
+ ///
+ public bool EnableDeepThink { get; init; } = true;
+
+ ///
+ /// Whether the Search toggle is turned on before the first message of a chat. Default true.
+ ///
+ public bool EnableSearch { get; init; } = true;
+
+ ///
+ /// Workspace-relative path of the editable selector catalog. Default selectors.json .
+ ///
+ public string SelectorsFile { get; init; } = "selectors.json";
+
+ ///
+ /// Where replies are read from: Auto (copy button, validated, DOM fallback — default),
+ /// Copy (never silent fallback), Dom (original behavior).
+ ///
+ public ReplySourceMode ReplySource { get; init; } = ReplySourceMode.Auto;
+
+ ///
+ /// How long to wait for a complete reply. Default 300 seconds.
+ ///
+ public int ReplyTimeoutSeconds { get; init; } = 300;
+
+ ///
+ /// How long to wait for confirmation that a message was actually sent (new message item,
+ /// or empty input plus generating indicator) before retrying once and failing. Default 15 seconds.
+ ///
+ public int SendConfirmSeconds { get; init; } = 15;
+
+ ///
+ /// How long to wait for attachment uploads to settle (chips visible, send enabled)
+ /// before falling back to pasting the content. Default 60 seconds.
+ ///
+ public int UploadWaitSeconds { get; init; } = 60;
+
+ ///
+ /// Minimum artificial delay between browser actions. Default 400 ms.
+ ///
+ public int MinDelayMs { get; init; } = 0;
+
+ ///
+ /// Maximum artificial delay between browser actions. Default 1500 ms.
+ ///
+ public int MaxDelayMs { get; init; } = 150;
+
+ ///
+ /// Rate limit for messages sent to the chat. Default 6 per minute.
+ ///
+ public int MaxMessagesPerMinute { get; init; } = 6;
+
+ ///
+ /// Debug mode: saves screenshots on errors and writes a diagnostics log. Default false.
+ ///
+ public bool Debug { get; init; }
+
+ ///
+ /// Human-like random delays between browser actions. Default false (event-driven waits only).
+ ///
+ public bool HumanDelay { get; init; }
+
+ ///
+ /// Browser window behavior: Minimized (default, with throttling flags), Offscreen
+ /// (--window-position=-32000,-32000), Normal. Only affects auto-launched Chrome.
+ ///
+ public string WindowMode { get; init; } = "Minimized";
+
+ ///
+ /// Minimize the window again after the login completes. Default true.
+ ///
+ public bool MinimizeAfterLogin { get; init; } = true;
+
+ ///
+ /// Upper bound of the positive-first login probe: wait this long for EITHER the chat
+ /// input (logged in) OR a login indicator, in one parallel wait. Default 15 seconds.
+ ///
+ public int LoginProbeSeconds { get; init; } = 15;
+}
+
+///
+/// Response size and loop limits (Limits section).
+///
+public sealed record LimitsOptions
+{
+ ///
+ /// Maximum number of non-say commands executed per response; the rest are reported as skipped. Default 5.
+ ///
+ public int MaxCommandsPerResponse { get; init; } = 5;
+
+ ///
+ /// Maximum accepted total reply length in characters. Default 60000.
+ ///
+ public int MaxResponseChars { get; init; } = 60000;
+
+ ///
+ /// Maximum accepted file content length per command in characters. Default 40000.
+ ///
+ public int MaxFileContentChars { get; init; } = 40000;
+
+ ///
+ /// Cap of command output sent back to the model (head and tail kept). Default 8000.
+ ///
+ public int MaxCommandOutputChars { get; init; } = 8000;
+
+ ///
+ /// How many times an invalid reply is re-requested before the turn stops. Default 3.
+ ///
+ public int MaxFormatRetries { get; init; } = 3;
+
+ ///
+ /// Maximum model responses processed per user request. Default 25.
+ ///
+ public int MaxIterationsPerTurn { get; init; } = 25;
+}
+
+///
+/// Snapshot behavior (Snapshot section).
+///
+public sealed record SnapshotOptions
+{
+ ///
+ /// What gets attached to a user request. Default .
+ ///
+ public SnapshotMode Mode { get; init; } = SnapshotMode.Full;
+
+ ///
+ /// Whether the snapshot is also attached to result messages. Default false.
+ ///
+ public bool AttachOnFeedback { get; init; }
+
+ ///
+ /// Whether the bat files also run after every executed batch. Default false.
+ ///
+ public bool RefreshAfterEachBatch { get; init; }
+
+ ///
+ /// Noise filtering applied to the attached copy only, never to files on disk.
+ ///
+ public SnapshotFilterOptions Filter { get; init; } = new();
+}
+
+///
+/// Configurable noise filters for snapshot attachments (Snapshot.Filter section).
+///
+public sealed record SnapshotFilterOptions
+{
+ ///
+ /// Tree-view line filters: lines mentioning any of these folder names are dropped from the attached copy.
+ ///
+ public IReadOnlyList ExcludeTreePatterns { get; init; } = ["bin", "obj", ".git", "node_modules", ".agent", "artifacts"];
+
+ ///
+ /// Code-dump section filters: sections whose ### heading matches any of these are dropped.
+ ///
+ public IReadOnlyList ExcludeDumpHeadings { get; init; } = ["tree-view-result", "code-dump-result"];
+
+ ///
+ /// Files whose sections are dropped from the ATTACHED copy entirely (never from disk):
+ /// case-insensitive * wildcards matched against the file name. Environment files,
+ /// key material and anything whose name suggests credentials.
+ ///
+ public IReadOnlyList SensitiveFilePatterns { get; init; } =
+ [
+ ".env", ".env.*", "*.pem", "*.key", "*.pfx", "*.p12", "id_rsa*", "*.keystore",
+ "secrets.json", "*secret*", "*password*", "*credential*", "appsettings.Production.json",
+ "appsettings.*.Production.json",
+ ];
+
+ ///
+ /// Replace secret-looking VALUES in the remaining attached sections with [redacted]
+ /// (Password=/Pwd=/ApiKey/… assignments, JSON secret keys, Authorization tokens, PEM
+ /// private-key blocks). Key names and structure are kept. Default true.
+ ///
+ public bool MaskSensitiveLines { get; init; } = true;
+}
+
+///
+/// Git settings (Git section).
+///
+public sealed record GitOptions
+{
+ ///
+ /// Automatically initialize a git repository (with .gitignore and an initial commit) when
+ /// the workspace is not one yet. Default true.
+ ///
+ public bool AutoInit { get; init; } = true;
+
+ ///
+ /// Prefix of agent branches. Default agent/ .
+ ///
+ public string BranchPrefix { get; init; } = "agent/";
+
+ ///
+ /// Branch name patterns (case-insensitive, * wildcard) that must never be modified. Default main, master, release*.
+ ///
+ public IReadOnlyList ProtectedBranchPatterns { get; init; } = ["main", "master", "release*"];
+}
+
+///
+/// External command policy settings (Commands section).
+///
+public sealed record CommandsOptions
+{
+ ///
+ /// Program names allowed to run in AutoSafe mode.
+ ///
+ public IReadOnlyList Allowlist { get; init; } = ["dotnet", "git", "npm", "node", "python"];
+
+ ///
+ /// Command tiers that run without confirmation in Ask mode. Default: ReadOnly and Build.
+ ///
+ public IReadOnlyList AutoApprove { get; init; } = ["ReadOnly", "Build"];
+
+ ///
+ /// What happens after a failed command: StopOnMutationFailure (default; a ReadOnly probe
+ /// failure never stops, everything else does), StopOnAnyFailure, Continue.
+ ///
+ public string BatchFailureMode { get; init; } = "StopOnMutationFailure";
+
+ ///
+ /// Timeout for every external process. Default 300 seconds.
+ ///
+ public int TimeoutSeconds { get; init; } = 300;
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/AgentEventType.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/AgentEventType.cs
new file mode 100644
index 0000000..1dea494
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/AgentEventType.cs
@@ -0,0 +1,46 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+///
+/// The kinds of progress events the agent loop reports to the UI while a turn is running.
+///
+public enum AgentEventType
+{
+ /// The workspace and git preconditions are being checked.
+ SessionStarting,
+
+ /// The agent is connecting to the user's Chrome.
+ ConnectingToBrowser,
+
+ /// A new chat is being opened or an existing one reused.
+ OpeningChat,
+
+ /// Attachments are being uploaded or pasted into the chat.
+ UploadingAttachments,
+
+ /// The request is being sent to DeepSeek.
+ SendingMessage,
+
+ /// Waiting for DeepSeek to finish its reply.
+ WaitingForReply,
+
+ /// The reply is being parsed and validated as AgentScript.
+ ValidatingReply,
+
+ /// The reply was invalid; asking DeepSeek to resend it.
+ RequestingResend,
+
+ /// A command is being executed on the workspace.
+ ExecutingCommand,
+
+ /// The user is being asked a question or for confirmation.
+ AskingUser,
+
+ /// The results message is being sent back to DeepSeek.
+ SendingResults,
+
+ /// The workspace snapshot bat files are being refreshed.
+ RefreshingSnapshot,
+
+ /// The agent turn has finished.
+ TurnFinished,
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/ApprovalMode.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/ApprovalMode.cs
new file mode 100644
index 0000000..9a07763
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/ApprovalMode.cs
@@ -0,0 +1,16 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+///
+/// How much confirmation the agent asks for before running risky commands.
+///
+public enum ApprovalMode
+{
+ /// Confirm every runcommand and deletefile with the user (default).
+ Ask,
+
+ /// Run allow-listed commands automatically; still ask for everything else.
+ AutoSafe,
+
+ /// Run everything automatically, with a warning shown to the user.
+ Auto,
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs
new file mode 100644
index 0000000..d423101
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandKind.cs
@@ -0,0 +1,46 @@
+using System.ComponentModel;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+///
+/// The AgentScript v1 commands the model may use in a reply.
+/// The value is the command name as it appears in a reply.
+///
+public enum CommandKind
+{
+ /// Prints a plain-English message to the user. Does not count toward the command limit.
+ [Description("say")] Say,
+
+ /// Creates a new file. Fails if the file already exists.
+ [Description("newfile")] NewFile,
+
+ /// Replaces the whole content of an existing file. Fails if the file does not exist.
+ [Description("updatefile")] UpdateFile,
+
+ /// Replaces one occurrence of FIND with REPLACE. Fails unless FIND matches exactly once.
+ [Description("editfile")] EditFile,
+
+ /// Deletes a file after backup and (in Ask mode) confirmation.
+ [Description("deletefile")] DeleteFile,
+
+ /// Returns the current file content to the model as that command's result.
+ [Description("readfile")] ReadFile,
+
+ /// Runs a single command line after the command policy checks every segment.
+ [Description("runcommand")] RunCommand,
+
+ /// Asks the user a question and returns the answer as that command's result.
+ [Description("ask")] Ask,
+
+ /// Stages everything, commits, and pushes the agent branch.
+ [Description("commit")] Commit,
+
+ /// Ends the agent turn with a summary message.
+ [Description("done")] Done,
+
+ /// Control directive: run this batch, send results, keep waiting for the next reply.
+ [Description("next")] Next,
+
+ /// Moves/renames a file: argument = source path, TO payload = destination path.
+ [Description("movefile")] MoveFile,
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandStatus.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandStatus.cs
new file mode 100644
index 0000000..fe8b741
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/CommandStatus.cs
@@ -0,0 +1,16 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+///
+/// The execution status of a single AgentScript command, reported back to the model.
+///
+public enum CommandStatus
+{
+ /// The command executed successfully.
+ Ok,
+
+ /// The command failed, was blocked by policy, or was declined by the user.
+ Error,
+
+ /// The command was not executed because the per-response batch limit was reached.
+ Skipped,
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/ReplySourceMode.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/ReplySourceMode.cs
new file mode 100644
index 0000000..f8bf5de
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/ReplySourceMode.cs
@@ -0,0 +1,16 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+///
+/// Where the agent reads the model's reply text from.
+///
+public enum ReplySourceMode
+{
+ /// Try the message Copy button first, validate, fall back to the DOM reader.
+ Auto,
+
+ /// Copy button only; capture or validation failures are errors (never silent fallback).
+ Copy,
+
+ /// DOM final-answer extraction only (the original behavior).
+ Dom,
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/SnapshotMode.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/SnapshotMode.cs
new file mode 100644
index 0000000..f4e8d6e
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Enums/SnapshotMode.cs
@@ -0,0 +1,13 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+///
+/// What the agent attaches to a user request.
+///
+public enum SnapshotMode
+{
+ /// Attach the full tree view and the full code dump (default).
+ Full,
+
+ /// Attach the full tree view plus only files changed since the last snapshot.
+ TreeAndChanged,
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs
new file mode 100644
index 0000000..aea4211
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/ErrorCodes.cs
@@ -0,0 +1,150 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models;
+
+///
+/// The catalog of stable KA_ error codes used in every Outcome failure.
+/// Each code is documented in docs/agentscript-spec.md .
+///
+public static class ErrorCodes
+{
+ // --- Parsing / format -------------------------------------------------
+
+ /// A @@ keyword is not a known AgentScript v1 command.
+ public const string ParseUnknownCommand = "KA_PARSE_UNKNOWN_COMMAND";
+
+ /// Non-whitespace text outside command blocks.
+ public const string ParseStrayText = "KA_PARSE_STRAY_TEXT";
+
+ /// The reply does not end with @@eof .
+ public const string ParseMissingEof = "KA_PARSE_MISSING_EOF";
+
+ /// Non-whitespace text after the final @@eof .
+ public const string ParseTextAfterEof = "KA_PARSE_TEXT_AFTER_EOF";
+
+ /// A block was opened but never closed with @@end .
+ public const string ParseUnterminatedBlock = "KA_PARSE_UNTERMINATED_BLOCK";
+
+ /// A required payload section (MSG , CONTENT , FIND , REPLACE , CMD ) is missing.
+ public const string ParseMissingPayload = "KA_PARSE_MISSING_PAYLOAD";
+
+ /// A payload section was given to a command that does not take one.
+ public const string ParseUnexpectedPayload = "KA_PARSE_UNEXPECTED_PAYLOAD";
+
+ /// A command that needs a path or argument line did not get one.
+ public const string ParseMissingArgument = "KA_PARSE_MISSING_ARGUMENT";
+
+ /// A payload start or end marker line is malformed.
+ public const string ParseBadMarker = "KA_PARSE_BAD_MARKER";
+
+ /// An unknown command option or option value (e.g. on_error=maybe).
+ public const string ParseBadOption = "KA_PARSE_BAD_OPTION";
+
+ /// Both @@next and @@done (or another conflicting control directive) in one reply.
+ public const string ParseConflictingControl = "KA_PARSE_CONFLICTING_CONTROL";
+
+ /// A reply contains only @@say and no control directive.
+ public const string ParseSayOnly = "KA_PARSE_SAY_ONLY";
+
+ /// A path is absolute, contains .. , or uses backslashes.
+ public const string ParseBadPath = "KA_PARSE_BAD_PATH";
+
+ /// The reply is empty or whitespace only.
+ public const string ReplyEmpty = "KA_REPLY_EMPTY";
+
+ // --- Limits -----------------------------------------------------------
+
+ /// The reply exceeds Limits.MaxResponseChars .
+ public const string LimitResponseChars = "KA_LIMIT_RESPONSE_CHARS";
+
+ /// A file payload exceeds Limits.MaxFileContentChars .
+ public const string LimitFileContentChars = "KA_LIMIT_FILE_CONTENT_CHARS";
+
+ /// An external process exceeded its timeout.
+ public const string Timeout = "KA_TIMEOUT";
+
+ // --- Workspace execution ----------------------------------------------
+
+ /// A resolved path falls outside the workspace or inside .git .
+ public const string PathOutsideWorkspace = "KA_PATH_OUTSIDE";
+
+ /// A file to read, update, edit or delete was not found.
+ public const string FileNotFound = "KA_FILE_NOT_FOUND";
+
+ /// newfile target already exists.
+ public const string FileAlreadyExists = "KA_FILE_ALREADY_EXISTS";
+
+ /// editfile FIND text did not match exactly once.
+ public const string FindNotUnique = "KA_FIND_NOT_UNIQUE";
+
+ /// A file operation failed at the I/O level.
+ public const string FileIoFailure = "KA_FILE_IO";
+
+ // --- Policy / git -----------------------------------------------------
+
+ /// A command was blocked by the command policy deny list or allow list.
+ public const string CommandDenied = "KA_COMMAND_DENIED";
+
+ /// The program of a command does not exist on this machine.
+ public const string CommandNotFound = "KA_COMMAND_NOT_FOUND";
+
+ /// The user declined a command that needed confirmation.
+ public const string CommandDeclined = "KA_COMMAND_DECLINED";
+
+ /// An external command ran but returned a non-zero exit code.
+ public const string CommandFailed = "KA_COMMAND_FAILED";
+
+ /// An operation was blocked because the current branch is protected.
+ public const string BranchProtected = "KA_BRANCH_PROTECTED";
+
+ /// The workspace is not a git repository.
+ public const string NotAGitRepository = "KA_NOT_A_GIT_REPOSITORY";
+
+ /// A git command failed.
+ public const string GitFailure = "KA_GIT_FAILURE";
+
+ /// git is not installed (or not on PATH) and the workspace is not a repository.
+ public const string GitNotInstalled = "KA_GIT_NOT_INSTALLED";
+
+ /// Automatic repository initialization failed.
+ public const string GitInitFailed = "KA_GIT_INIT_FAILED";
+
+ // --- Chat / browser ---------------------------------------------------
+
+ /// The browser could not be attached to or launched.
+ public const string BrowserConnectionFailed = "KA_BROWSER_CONNECTION_FAILED";
+
+ /// A required selector could not be resolved on the page.
+ public const string SelectorNotFound = "KA_SELECTOR_NOT_FOUND";
+
+ /// No reply arrived within the configured timeout.
+ public const string ReplyTimeout = "KA_REPLY_TIMEOUT";
+
+ /// The message could not be sent (not confirmed in the DOM, retry failed too).
+ public const string SendNotConfirmed = "KA_SEND_NOT_CONFIRMED";
+
+ /// Uploading attachments to the chat failed.
+ public const string AttachmentUploadFailed = "KA_ATTACHMENT_UPLOAD_FAILED";
+
+ /// The chat hit its length limit and could not be continued.
+ public const string ChatExhausted = "KA_CHAT_EXHAUSTED";
+ public const string Cancelled = "KA_CANCELLED";
+
+ // --- Configuration ----------------------------------------------------
+
+ /// keyless.json or selectors.json is not valid JSON.
+ public const string ConfigurationInvalid = "KA_CONFIG_INVALID";
+
+ /// A configuration value is out of range or inconsistent.
+ public const string ConfigurationValidation = "KA_CONFIG_VALIDATION";
+
+ /// A workspace snapshot bat file or its output is missing.
+ public const string SnapshotMissing = "KA_SNAPSHOT_MISSING";
+
+ /// A snapshot bat file run failed.
+ public const string SnapshotRunFailed = "KA_SNAPSHOT_RUN_FAILED";
+
+ /// An unexpected internal failure (bug guard); reported to the model and the user as-is.
+ public const string Internal = "KA_INTERNAL";
+
+ /// No saved session exists yet (expected on the first run).
+ public const string SessionNotFound = "KA_SESSION_NOT_FOUND";
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Execution/AgentExecutionContext.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Execution/AgentExecutionContext.cs
new file mode 100644
index 0000000..e648c57
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Execution/AgentExecutionContext.cs
@@ -0,0 +1,41 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+
+///
+/// Everything a command executor needs about the current run.
+///
+public sealed record AgentExecutionContext
+{
+ ///
+ /// Absolute path of the workspace root (the git repository the agent works in).
+ ///
+ public string WorkspaceRoot { get; init; } = string.Empty;
+
+ ///
+ /// The effective configuration.
+ ///
+ public KeylessOptions Options { get; init; } = new();
+
+ ///
+ /// Dry-run mode: print what would happen, execute nothing.
+ ///
+ public bool DryRun { get; init; }
+
+ ///
+ /// NO-GIT MODE: git is not installed. File commands skip the branch guard; @@commit
+ /// reports that nothing was committed instead of failing.
+ ///
+ public bool NoGitMode { get; init; }
+
+ ///
+ /// The UI contract to talk to the user (say, ask, confirm, diff).
+ ///
+ public IAgentUserInterface Ui { get; init; } = null!;
+
+ ///
+ /// Cancellation token for the current turn.
+ ///
+ public CancellationToken CancellationToken { get; init; }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Execution/CommandExecutionResult.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Execution/CommandExecutionResult.cs
new file mode 100644
index 0000000..2379b3d
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Execution/CommandExecutionResult.cs
@@ -0,0 +1,86 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+
+///
+/// The outcome of one executed AgentScript command, reported back to the model in the results message.
+///
+public sealed record CommandExecutionResult
+{
+ ///
+ /// The position of the command in its response (matches the #N in result headers).
+ ///
+ public int Number { get; init; }
+
+ ///
+ /// Which command this was.
+ ///
+ public CommandKind Kind { get; init; }
+
+ ///
+ /// Path, command line, or message excerpt identifying the target.
+ ///
+ public string Target { get; init; } = string.Empty;
+
+ ///
+ /// Ok, Error (failed / blocked / declined) or Skipped (batch limit reached).
+ ///
+ public CommandStatus Status { get; init; }
+
+ ///
+ /// The stable KA_ code on failure; null otherwise.
+ ///
+ public string? ErrorCode { get; init; }
+
+ ///
+ /// Human-readable failure reason; null otherwise.
+ ///
+ public string? ErrorMessage { get; init; }
+
+ ///
+ /// Stable machine-readable reason for a failed command:
+ /// command_not_found / timeout / denied / declined / non_zero_exit / killed.
+ ///
+ public string? FailureReason { get; init; }
+
+ ///
+ /// The risk tier of this command (from the classifier). Used for batch failure decisions.
+ ///
+ public CommandRiskTier Tier { get; init; } = CommandRiskTier.Risky;
+
+ ///
+ /// Output for the model: file content for readfile , the user's answer for ask ,
+ /// process output for runcommand . Capped by Limits.MaxCommandOutputChars when formatted.
+ ///
+ public string? Output { get; init; }
+
+ ///
+ /// Process exit code for runcommand ; null otherwise.
+ ///
+ public int? ExitCode { get; init; }
+
+ ///
+ /// Wall-clock duration in seconds for runcommand ; null otherwise.
+ ///
+ public double? DurationSeconds { get; init; }
+
+ ///
+ /// Builds a successful result.
+ ///
+ public static CommandExecutionResult Ok(AgentScriptCommand command)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ return new() { Number = command.Number, Kind = command.Kind, Target = command.Target, Status = CommandStatus.Ok };
+ }
+
+ ///
+ /// Builds a failed result from an outcome error.
+ ///
+ public static CommandExecutionResult Fail(AgentScriptCommand command, string code, string message)
+ {
+ ArgumentNullException.ThrowIfNull(command);
+ return new() { Number = command.Number, Kind = command.Kind, Target = command.Target, Status = CommandStatus.Error, ErrorCode = code, ErrorMessage = message };
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Git/GitRepositoryInfo.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Git/GitRepositoryInfo.cs
new file mode 100644
index 0000000..1e49ea6
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Git/GitRepositoryInfo.cs
@@ -0,0 +1,27 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Git;
+
+///
+/// A snapshot of the git state the agent checks before starting a session.
+///
+public sealed record GitRepositoryInfo
+{
+ ///
+ /// Whether the workspace is inside a git work tree.
+ ///
+ public bool IsRepository { get; init; }
+
+ ///
+ /// Absolute path of the repository root, or null outside a repository.
+ ///
+ public string? RepositoryRoot { get; init; }
+
+ ///
+ /// The currently checked-out branch, or "DETACHED" for a detached HEAD.
+ ///
+ public string CurrentBranch { get; init; } = string.Empty;
+
+ ///
+ /// Whether the working tree has uncommitted changes.
+ ///
+ public bool IsDirty { get; init; }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs
new file mode 100644
index 0000000..a0b4856
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptCommand.cs
@@ -0,0 +1,169 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+
+///
+/// Base record of one parsed command block from an AgentScript v1 reply.
+///
+/// is the position of the block in its response (1, 2, 3 …), assigned by the parser.
+/// The model does not number commands itself.
+///
+///
+public abstract record AgentScriptCommand
+{
+ ///
+ /// The 1-based position of this command block within the response it came from.
+ ///
+ public int Number { get; init; }
+
+ ///
+ /// The kind of the command, matching the @@ keyword of the block.
+ ///
+ public abstract CommandKind Kind { get; }
+
+ ///
+ /// A short human-readable target of the command (path, command line, or message excerpt),
+ /// used in result headers sent back to the model.
+ ///
+ public abstract string Target { get; }
+}
+
+/// Prints to the user. Does not count toward the command limit.
+/// Plain-English message shown in the console.
+public sealed record SayCommand(string Message) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.Say;
+
+ ///
+ public override string Target => Message;
+}
+
+/// Creates a new file at with .
+/// Workspace-relative path using forward slashes.
+/// Full file content.
+public sealed record NewFileCommand(string Path, string Content) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.NewFile;
+
+ ///
+ public override string Target => Path;
+}
+
+/// Replaces the whole content of the existing file at .
+/// Workspace-relative path using forward slashes.
+/// Full new file content.
+public sealed record UpdateFileCommand(string Path, string Content) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.UpdateFile;
+
+ ///
+ public override string Target => Path;
+}
+
+/// Replaces the single occurrence of with .
+/// Workspace-relative path using forward slashes.
+/// Text that must occur exactly once in the file.
+/// Replacement text.
+public sealed record EditFileCommand(string Path, string Find, string Replace) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.EditFile;
+
+ ///
+ public override string Target => Path;
+}
+
+/// Deletes the file at (after backup and, in Ask mode, confirmation).
+/// Workspace-relative path using forward slashes.
+public sealed record DeleteFileCommand(string Path) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.DeleteFile;
+
+ ///
+ public override string Target => Path;
+}
+
+/// Returns the current content of the file at to the model.
+/// Workspace-relative path using forward slashes.
+public sealed record ReadFileCommand(string Path) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.ReadFile;
+
+ ///
+ public override string Target => Path;
+}
+
+/// Runs after the command policy checks every segment.
+/// A single command line; segments split on &&, ||, ; and | are each checked.
+public sealed record RunCommandCommand(string CommandLine, string? OnError = null) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.RunCommand;
+
+ ///
+ public override string Target => CommandLine;
+}
+
+/// Asks the user and returns the answer as this command's result.
+/// The question the user must answer before work continues.
+public sealed record AskCommand(string Question) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.Ask;
+
+ ///
+ public override string Target => Question;
+}
+
+/// Stages everything, commits with , and pushes the agent branch.
+/// Short English commit message.
+public sealed record CommitCommand(string Message) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.Commit;
+
+ ///
+ public override string Target => Message;
+}
+
+/// Ends the agent turn with .
+/// Summary of what was done.
+public sealed record DoneCommand(string Summary) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.Done;
+
+ ///
+ public override string Target => Summary;
+}
+
+///
+/// Control directive: execute this batch, send the per-command results, then keep waiting for
+/// the model's next reply (the turn and chat stay open). Opposite of @@done which ends the turn.
+///
+/// Optional note about what the model will do with the results.
+public sealed record NextCommand(string Message) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.Next;
+
+ ///
+ public override string Target => Message;
+}
+
+/// Moves/renames a file (sandboxed, backed up, refuses to overwrite).
+/// Source path, workspace-relative.
+/// Destination path, workspace-relative.
+public sealed record MoveFileCommand(string Path, string ToPath) : AgentScriptCommand
+{
+ ///
+ public override CommandKind Kind => CommandKind.MoveFile;
+
+ ///
+ public override string Target => $"{Path} -> {ToPath}";
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptParseResult.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptParseResult.cs
new file mode 100644
index 0000000..66b406c
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/AgentScriptParseResult.cs
@@ -0,0 +1,33 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+
+///
+/// The full result of parsing one model reply: the commands when the reply was valid,
+/// and every grammar problem when it was not.
+///
+public sealed record AgentScriptParseResult
+{
+ ///
+ /// The parsed commands in response order. Empty when is not empty.
+ ///
+ public IReadOnlyList Commands { get; init; } = [];
+
+ ///
+ /// Every grammar problem found, with line numbers. An empty list means the reply was valid.
+ ///
+ public IReadOnlyList Problems { get; init; } = [];
+
+ ///
+ /// Whether a final @@eof line was seen.
+ ///
+ public bool EofSeen { get; init; }
+
+ ///
+ /// Whether the reply had a valid start but appears cut off (no @@eof or an open block).
+ ///
+ public bool LooksTruncated { get; init; }
+
+ ///
+ /// True when is empty.
+ ///
+ public bool IsValid => Problems.Count == 0;
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/FormatGuardResult.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/FormatGuardResult.cs
new file mode 100644
index 0000000..c9ec5a2
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/FormatGuardResult.cs
@@ -0,0 +1,29 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+
+///
+/// The verdict of the response format guard: either a valid, executable script,
+/// or the list of problems to send back to the model in a correction message.
+///
+public sealed record FormatGuardResult
+{
+ ///
+ /// True when the reply is valid AgentScript and may be executed.
+ ///
+ public bool IsValid { get; init; }
+
+ ///
+ /// The parsed script when is true; otherwise .
+ ///
+ public AgentScriptParseResult? Script { get; init; }
+
+ ///
+ /// Every problem found (grammar and limits), for the correction message.
+ ///
+ public IReadOnlyList Problems { get; init; } = [];
+
+ ///
+ /// Whether the reply looks truncated; the correction message then tells the model
+ /// to resend in smaller pieces.
+ ///
+ public bool LooksTruncated { get; init; }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/FormatProblem.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/FormatProblem.cs
new file mode 100644
index 0000000..92398fd
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Parser/FormatProblem.cs
@@ -0,0 +1,16 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+
+///
+/// One problem found in a model reply, reported to the model in the format-correction message.
+///
+/// The 1-based line the problem was detected on, or when not tied to a line.
+/// The stable KA_ error code (see docs/agentscript-spec.md ).
+/// Human-readable description of the problem.
+public sealed record FormatProblem(int? Line, string Code, string Message)
+{
+ ///
+ /// Returns the problem formatted for the correction message, e.g. line 14: unknown command "@@createfile" .
+ ///
+ public string ToDisplayString()
+ => Line is null ? Message : $"line {Line}: {Message}";
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Snapshot/SnapshotAttachment.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Snapshot/SnapshotAttachment.cs
new file mode 100644
index 0000000..36470b8
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Models/Snapshot/SnapshotAttachment.cs
@@ -0,0 +1,24 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Models.Snapshot;
+
+///
+/// What kind of snapshot content an attachment carries.
+///
+public enum SnapshotAttachmentKind
+{
+ /// The filtered tree-view-result.txt output.
+ TreeView,
+
+ /// The filtered code-dump-result.md output, or one part of it.
+ CodeDump,
+
+ /// The list of files changed since the last snapshot (TreeAndChanged mode).
+ ChangedFiles,
+}
+
+///
+/// One piece of workspace state attached to a message sent to the chat.
+///
+/// Attachment file name shown in the chat (e.g. code-dump-result-part1.md ).
+/// Which snapshot this is.
+/// The filtered text to upload or paste.
+public sealed record SnapshotAttachment(string Name, SnapshotAttachmentKind Kind, string Content);
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs
new file mode 100644
index 0000000..ffbb0ae
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentLoop.cs
@@ -0,0 +1,28 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// The public entry point every UI uses to run the agent: one call = one user request (one turn).
+/// Everything the user sees during the turn flows back through .
+///
+public interface IAgentLoop : IService
+{
+ ///
+ /// Runs one full agent turn for :
+ /// git setup, snapshot, chat message + attachments, reply validation (with automatic re-ask),
+ /// command execution with per-command results, and a snapshot refresh at the end — always.
+ ///
+ /// The workspace directory (a git repository).
+ /// The user's request text.
+ /// The UI contract that shows progress, says, questions and diffs.
+ /// Print what would happen; execute nothing.
+ /// Cancels the turn (the snapshot still refreshes).
+ /// The turn result (branch, commits, summary, stop reason).
+ Task RunAsync(
+ string workspaceRoot,
+ string request,
+ IAgentUserInterface ui,
+ bool dryRun = false,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentScriptParser.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentScriptParser.cs
new file mode 100644
index 0000000..daa161d
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentScriptParser.cs
@@ -0,0 +1,51 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Parses a raw model reply into AgentScript v1 commands, or reports every grammar problem with line numbers.
+///
+public interface IAgentScriptParser : IService
+{
+ ///
+ /// Parses (a surrounding Markdown code fence is tolerated and stripped).
+ ///
+ /// The raw reply text read from the chat.
+ ///
+ /// A successful outcome carrying the parse result; the result's
+ /// list is empty when the reply is valid AgentScript. The outcome itself only fails on unexpected errors.
+ ///
+ Outcome Parse(string reply);
+}
+
+///
+/// Semantic validation of a parsed reply against the configured limits and path rules.
+///
+public interface IAgentScriptValidator : IService
+{
+ ///
+ /// Checks the parsed reply and the raw text it came from against .
+ ///
+ /// The parser result (problems, when present, are passed through unchanged).
+ /// The raw reply text, for the total length check.
+ /// The configured limits.
+ /// Problems found; an empty list means the script passes validation.
+ Outcome> Validate(AgentScriptParseResult parse, string rawReply, LimitsOptions limits);
+}
+
+///
+/// The single gate every reply passes through before anything is executed:
+/// grammar, @@eof , known commands, required payloads, no stray text, size limits.
+///
+public interface IResponseFormatGuard : IService
+{
+ ///
+ /// Checks one raw reply.
+ ///
+ /// The raw reply text.
+ /// The configured limits.
+ /// The verdict: a valid script, or the problems for the correction message.
+ Outcome Check(string reply, LimitsOptions limits);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentUserInterface.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentUserInterface.cs
new file mode 100644
index 0000000..cc47def
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IAgentUserInterface.cs
@@ -0,0 +1,62 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// The single contract every UI implements to host the agent.
+/// Core never touches the console or any UI library — everything the user sees goes through this interface.
+///
+public interface IAgentUserInterface
+{
+ ///
+ /// Shows one say message from the model to the user.
+ ///
+ /// Plain-English message.
+ void Say(string message);
+
+ ///
+ /// Reports background progress (waiting for reply, executing, snapshot, …).
+ ///
+ /// What is happening right now.
+ void ShowProgress(AgentProgressEvent progressEvent);
+
+ ///
+ /// Asks a yes/no question and waits for the answer.
+ ///
+ /// What needs confirmation and why.
+ /// True when the user approved.
+ Task ConfirmAsync(ConfirmRequest request);
+
+ ///
+ /// Asks an open question and waits for the answer.
+ ///
+ /// The question text.
+ /// The user's answer.
+ Task AskAsync(string question);
+
+ ///
+ /// Shows a pending file change (colored diff in Ask mode) before it is applied.
+ ///
+ /// The change about to be applied.
+ void ShowDiff(FileChange change);
+
+ ///
+ /// Shows a numbered choice menu and waits for the selection. Arrow keys (or digits) move,
+ /// Enter confirms the highlighted option, Esc/Ctrl+C cancels. Non-interactive hosts return
+ /// the default option.
+ ///
+ /// The menu (title, options, default).
+ /// Cancellation token (a cancelled token counts as cancelled).
+ /// The selection result.
+ Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default);
+
+ ///
+ /// Marks a waiting phase (chat reply, running process, snapshot scripts) so the UI can show
+ /// a spinner. The returned scope is disposed when the wait ends; any other UI call
+ /// (Say, Ask, Confirm, ShowDiff, ShowProgress) stops the wait display first.
+ /// Never wraps a whole turn — only actual waits.
+ ///
+ /// What is being waited for.
+ /// A disposable scope; dispose it when the wait ends.
+ IDisposable BeginWait(string message);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBackupService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBackupService.cs
new file mode 100644
index 0000000..76cb527
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBackupService.cs
@@ -0,0 +1,25 @@
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Backs up files into .agent/backups before any change, powering keyless undo .
+///
+public interface IBackupService : IService
+{
+ ///
+ /// Copies the current version of a file into a timestamped backup folder.
+ ///
+ /// The workspace directory.
+ /// The absolute path of the file to back up.
+ /// Success or a KA_FILE_IO failure.
+ Task BackupAsync(string workspaceRoot, string fullPath);
+
+ ///
+ /// Restores the newest backup batch: copies every file in the newest timestamped folder
+ /// back to its workspace path, then removes that folder.
+ ///
+ /// The workspace directory.
+ /// The restored file paths, or a KA_FILE_NOT_FOUND failure when no backup exists.
+ Task>> UndoLatestAsync(string workspaceRoot);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBranchGuard.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBranchGuard.cs
new file mode 100644
index 0000000..c79b10e
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBranchGuard.cs
@@ -0,0 +1,28 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Enforces the protected-branch rule in code, not only in the prompt:
+/// no file change, commit or push ever happens on main , master or release* .
+///
+public interface IBranchGuard : IService
+{
+ ///
+ /// Checks whether the given branch name matches any protected pattern.
+ ///
+ /// The branch to test.
+ /// The configured git options (protected patterns).
+ /// True when the branch is protected.
+ bool IsProtected(string branchName, GitOptions options);
+
+ ///
+ /// Verifies the current branch of the workspace may be modified.
+ /// Runs before every file-changing command and before commit/push.
+ ///
+ /// The workspace directory.
+ /// The configured git options (protected patterns).
+ /// Success, or a KA_BRANCH_PROTECTED failure naming the branch.
+ Task EnsureModifiableAsync(string workspaceRoot, GitOptions options);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBrowserConnector.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBrowserConnector.cs
new file mode 100644
index 0000000..24a07da
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IBrowserConnector.cs
@@ -0,0 +1,68 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// How the browser connection was established — surfaced by doctor and the agent UI.
+///
+public enum BrowserConnectionKind
+{
+ /// The debug port was already open: attached to the running Chrome.
+ AttachedToRunningChrome,
+
+ /// The agent started the real Chrome with the dedicated profile, then attached.
+ AutoLaunchedChrome,
+
+ /// Opt-in fallback: a Playwright-managed browser with its own profile.
+ PlaywrightLaunched,
+}
+
+///
+/// A live browser attachment: the Playwright context plus the metadata the agent needs to
+/// honor the dispose policy (close only our tab, leave a launched Chrome running).
+///
+public sealed record BrowserAttachment
+{
+ /// The Playwright browser (null for persistent contexts).
+ public object? Browser { get; init; }
+
+ /// The connected or launched browser context.
+ public object Context { get; init; } = null!;
+
+ /// How the connection was established.
+ public BrowserConnectionKind Kind { get; init; }
+
+ /// Whether the agent started this Chrome process itself.
+ public bool LaunchedProcess { get; init; }
+
+ /// The Chrome process id when is true.
+ public int LaunchedProcessId { get; init; }
+
+ /// The dedicated profile directory the browser runs with.
+ public string UserDataDir { get; init; } = string.Empty;
+}
+
+///
+/// Establishes the browser connection for run/doctor/chrome: probe the debug port, optionally
+/// auto-launch the real Chrome with a dedicated profile, attach over CDP, and pick or open the
+/// chat tab with focus. Reports every decision through the UI — never silent.
+///
+public interface IBrowserConnector : IService
+{
+ ///
+ /// Connects and returns the browser attachment plus the chosen (or newly opened) page.
+ ///
+ /// The browser section of keyless.json.
+ /// Status messages are reported here (never silent).
+ /// Cancellation token.
+ /// Optional per-phase timing recorder (--timing); the tab-open phase is timed here.
+ /// The attachment and the chat page, or a KA_BROWSER_CONNECTION_FAILED failure
+ /// whose message is actionable (including the manual-start command line when Chrome could
+ /// not be found or started).
+ Task> ConnectAsync(
+ BrowserOptions options,
+ IAgentUserInterface ui,
+ CancellationToken cancellationToken = default,
+ ITimingRecorder? timings = null);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChangeTracker.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChangeTracker.cs
new file mode 100644
index 0000000..a02655f
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChangeTracker.cs
@@ -0,0 +1,41 @@
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// One changed path of a change set.
+///
+/// Workspace-relative path using forward slashes.
+/// added / modified / deleted / renamed.
+public sealed record ChangedPath(string Path, string Kind);
+
+///
+/// Tracks which workspace paths changed, with git (porcelain before/after a turn) and without
+/// git (a lightweight index of path/size/mtime excluding the configured ignore patterns).
+/// Powers the dirty-tree menu decision and the turn summary.
+///
+public interface IChangeTracker : IService
+{
+ ///
+ /// Captures the current change baseline of the workspace.
+ ///
+ /// The workspace directory.
+ /// Whether git porcelain can be used.
+ /// Cancellation token.
+ /// A handle to compare against later.
+ Task> CaptureAsync(string workspaceRoot, bool gitAvailable, CancellationToken cancellationToken = default);
+
+ ///
+ /// Compares the workspace against a captured baseline.
+ ///
+ /// The workspace directory.
+ /// The handle returned by .
+ /// Whether git porcelain can be used.
+ /// Cancellation token.
+ /// The changed paths (added / modified / deleted / renamed), or a failure.
+ Task>> DiffAsync(
+ string workspaceRoot,
+ object baseline,
+ bool gitAvailable,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChatDiagnostics.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChatDiagnostics.cs
new file mode 100644
index 0000000..09416bf
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChatDiagnostics.cs
@@ -0,0 +1,78 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+using ImanSoftware.Outcomes;
+
+///
+/// How a selector behaved against the live page during a doctor run.
+///
+public enum SelectorCheckStatus
+{
+ /// At least one fallback selector matched.
+ Matched,
+
+ /// No fallback selector matched although the element should exist.
+ NotFound,
+
+ /// The element legitimately cannot exist right now (e.g. Stop while idle).
+ NotApplicableNow,
+}
+
+///
+/// The result of checking one selector key of selectors.json.
+///
+/// The selectors.json key (InputBox, SendButton, …).
+/// Matched / NotFound / NotApplicableNow.
+/// Which fallback selector matched (−1 when none did).
+/// How many elements the matching selector found.
+/// Human-readable extra information (English).
+public sealed record SelectorCheck(
+ string Key,
+ SelectorCheckStatus Status,
+ int FallbackIndex,
+ int MatchCount,
+ string Detail);
+
+///
+/// One full doctor report over the live chat page.
+///
+/// The inspected page URL.
+/// Whether the login page is NOT shown.
+/// The DeepThink toggle's aria-pressed value.
+/// The Search toggle's aria-pressed value.
+/// Whether the input box is editable.
+/// One check per selector key.
+/// Whether every selector required for the current state matched.
+/// Paths of the HTML snapshots written by --capture (may be empty).
+public sealed record ChatDiagnosticsReport(
+ string PageUrl,
+ bool LoggedIn,
+ string? DeepThinkPressed,
+ string? SearchPressed,
+ bool InputAcceptsText,
+ IReadOnlyList Checks,
+ bool AllRequiredMatched,
+ IReadOnlyList CapturedFiles);
+
+///
+/// Live selector check: attaches to the user's Chrome, opens the DeepSeek chat page in a new
+/// tab and reports how every selector in selectors.json behaves — without ever sending a
+/// message or changing the chat. The logic lives in Core so a future GUI can reuse it.
+///
+public interface IChatDiagnostics : IService
+{
+ ///
+ /// Runs the inspection.
+ ///
+ /// The workspace (for the selectors file and capture defaults).
+ /// The browser section of keyless.json.
+ /// When set, trimmed HTML snapshots of the interesting areas are written here.
+ /// When true, waits for a manually sent prompt and captures the generating and finished states.
+ /// Cancellation token.
+ /// The report, or a KA_BROWSER_* failure when Chrome cannot be reached.
+ Task> InspectAsync(
+ string workspaceRoot,
+ ImanSoftware.KeylessAgent.Abstractions.Models.Configuration.BrowserOptions browser,
+ string? captureDirectory,
+ bool watch,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChatProvider.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChatProvider.cs
new file mode 100644
index 0000000..4af6688
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IChatProvider.cs
@@ -0,0 +1,56 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Chat;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Snapshot;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Opens chat sessions in the user's logged-in DeepSeek web chat.
+/// The browser lives behind this interface so a future official-API provider can be added
+/// without touching the agent loop.
+///
+public interface IChatProvider : IService
+{
+ ///
+ /// Connects to the user's Chrome and opens (or resumes) a chat.
+ ///
+ /// Session options (browser settings, workspace root, resume URL).
+ /// Cancellation for the whole session.
+ /// A live chat session, or a KA_BROWSER_* failure.
+ Task> StartSessionAsync(ChatSessionOptions options, CancellationToken cancellationToken = default);
+}
+
+///
+/// One live chat in the user's browser. Messages are rate-limited and replies are read
+/// from the final-answer element only — never from the reasoning block.
+///
+public interface IChatSession : IAsyncDisposable
+{
+ ///
+ /// The chat URL (persisted in .agent/session.json for keyless resume ).
+ ///
+ string ChatUrl { get; }
+
+ ///
+ /// Where the last accepted reply was read from: "Copy", "Dom" or "Dom (copy rejected)".
+ ///
+ string LastReplyOrigin { get; }
+
+ ///
+ /// Sends one message. Attachments are uploaded through the chat's file input when possible
+ /// and pasted into the message otherwise.
+ ///
+ /// The message text.
+ /// Snapshot attachments to send along (may be empty).
+ /// Cancellation token.
+ /// Success or a failure with a KA_ code.
+ Task SendMessageAsync(string message, IReadOnlyList attachments, CancellationToken cancellationToken = default);
+
+ ///
+ /// Waits until the model finished its reply and returns the final-answer text
+ /// (AgentScript), never the reasoning.
+ ///
+ /// Cancellation token.
+ /// The reply text, or KA_REPLY_TIMEOUT /KA_SELECTOR_NOT_FOUND failures.
+ Task> WaitForReplyAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ICommandPolicy.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ICommandPolicy.cs
new file mode 100644
index 0000000..e14843b
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ICommandPolicy.cs
@@ -0,0 +1,28 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// The external-command safety gate: a deny list nothing can pass, plus the configurable allow list
+/// that decides what may run automatically in AutoSafe mode.
+///
+public interface ICommandPolicy : IService
+{
+ ///
+ /// Validates a whole command line. Every segment split on && , || , ; and |
+ /// is checked against the deny list.
+ ///
+ /// The command line the model wants to run.
+ /// The configured command options (allow list).
+ /// Success, or a KA_COMMAND_DENIED failure naming the offending segment.
+ Outcome Validate(string commandLine, CommandsOptions options);
+
+ ///
+ /// Whether the first program of the command line is on the allow list
+ /// (only meaningful for segments that already passed the deny list).
+ ///
+ /// The command line.
+ /// The configured command options (allow list).
+ bool IsAllowlisted(string commandLine, CommandsOptions options);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ICommandRiskClassifier.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ICommandRiskClassifier.cs
new file mode 100644
index 0000000..63307e7
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ICommandRiskClassifier.cs
@@ -0,0 +1,50 @@
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// The risk tier of a command (or of its most dangerous segment).
+///
+public enum CommandRiskTier
+{
+ /// Writes nothing and touches nothing outside the workspace.
+ ReadOnly,
+
+ /// Workspace-scoped build tooling (dotnet build/restore/clean).
+ Build,
+
+ /// Runs project code or scripts (dotnet test/run, npm test/run, node file, …).
+ Execute,
+
+ /// Always confirmed (even in AutoSafe): interpreters, redirects, installs, deletes, git writes, unknown programs.
+ Risky,
+
+ /// Blocked by the deny list — never runs.
+ Denied,
+}
+
+///
+/// The classification of one command line.
+///
+/// The highest segment tier.
+/// Per-segment tiers in order.
+/// Human-readable justification for the tier (English).
+public sealed record CommandRiskAssessment(
+ CommandRiskTier Tier,
+ IReadOnlyList Segments,
+ string Reason);
+
+///
+/// Classifies a command line into risk tiers. Parsing respects quotes; each segment
+/// (split on &&, ||, ;, |) is classified separately and the command takes the highest tier.
+///
+public interface ICommandRiskClassifier : IService
+{
+ ///
+ /// Classifies a command line against the workspace.
+ ///
+ /// The raw command line.
+ /// The workspace root (path arguments are resolved against it).
+ /// The assessment; Denied assessments carry the deny reason.
+ Outcome Classify(string commandLine, string workspaceRoot);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs
new file mode 100644
index 0000000..e3befe2
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IConfigurationService.cs
@@ -0,0 +1,19 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Loads, validates and creates the per-workspace keyless.json .
+///
+public interface IConfigurationService : IService
+{
+ ///
+ /// Loads keyless.json from the workspace root. When the file is missing,
+ /// the default configuration is written to it and returned. Invalid JSON or out-of-range
+ /// values become precise failures, never exceptions.
+ ///
+ /// The workspace directory.
+ /// The effective configuration.
+ Task> LoadAsync(string workspaceRoot);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IGitService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IGitService.cs
new file mode 100644
index 0000000..ad367b3
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IGitService.cs
@@ -0,0 +1,114 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Git;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// All git operations the agent performs, as short-lived processes run inside the workspace.
+/// The agent never force-pushes, resets shared branches, deletes branches, merges or rebases.
+///
+public interface IGitService : IService
+{
+ ///
+ /// Collects repository state (is repo, root, current branch, dirty) in one call.
+ ///
+ /// The workspace directory.
+ /// The repository snapshot; outside a repository only is false.
+ Task> GetRepositoryInfoAsync(string workspaceRoot);
+
+ ///
+ /// Detects the repository's main branch: origin/HEAD when set, else main , else master .
+ ///
+ /// The workspace directory.
+ /// The main branch name.
+ Task> DetectMainBranchAsync(string workspaceRoot);
+
+ ///
+ /// Creates a branch from the current HEAD and switches to it.
+ ///
+ /// The workspace directory.
+ /// The new branch name.
+ /// Success or a KA_GIT_FAILURE .
+ Task CreateAndSwitchBranchAsync(string workspaceRoot, string branchName);
+
+ ///
+ /// Stashes all local changes including untracked files.
+ ///
+ /// The workspace directory.
+ /// Success or a KA_GIT_FAILURE .
+ Task StashAsync(string workspaceRoot);
+
+ ///
+ /// Stages every change (git add -A ).
+ ///
+ /// The workspace directory.
+ /// Success or a KA_GIT_FAILURE .
+ Task StageAllAsync(string workspaceRoot);
+
+ ///
+ /// Commits staged changes with the given message.
+ ///
+ /// The workspace directory.
+ /// Short English commit message.
+ /// Success or a KA_GIT_FAILURE .
+ Task CommitAsync(string workspaceRoot, string message);
+
+ ///
+ /// Pushes the branch to origin, setting upstream (git push -u origin <branch> ). Never force.
+ ///
+ /// The workspace directory.
+ /// The branch to push.
+ /// Success or a KA_GIT_FAILURE with stderr in the message.
+ Task PushBranchAsync(string workspaceRoot, string branchName);
+
+ ///
+ /// Lists the commits of the current branch (newest first).
+ ///
+ /// The workspace directory.
+ /// Maximum number of commits to return.
+ /// One-line-per-commit summaries ("<hash> <subject>").
+ Task>> ListCommitsAsync(string workspaceRoot, int maxCount);
+
+ ///
+ /// Whether the git executable is installed and callable (git --version ).
+ ///
+ Task> IsInstalledAsync();
+
+ ///
+ /// The raw git status --porcelain lines (change tracking with git).
+ ///
+ Task>> RunStatusPorcelainAsync(string workspaceRoot);
+
+ ///
+ /// Initializes a repository: git init -b main , falling back to
+ /// git init + git branch -M main on old git versions.
+ ///
+ Task InitRepositoryAsync(string workspaceRoot);
+
+ ///
+ /// When the workspace contains a .sln/.slnx/.csproj and has no .gitignore yet, creates a
+ /// standard .NET .gitignore (dotnet new gitignore when dotnet works, else a
+ /// built-in minimal template).
+ ///
+ Task EnsureDotnetGitignoreAsync(string workspaceRoot);
+
+ ///
+ /// Creates the initial commit (git add -A + commit "Initial commit (created by
+ /// KeylessAgent)"). When git user.name/user.email are not configured, a one-shot identity
+ /// is passed via -c for this commit only (git config is never changed).
+ ///
+ Task CreateInitialCommitAsync(string workspaceRoot);
+
+ ///
+ /// Whether a remote named "origin" exists.
+ ///
+ Task> HasOriginRemoteAsync(string workspaceRoot);
+
+ ///
+ /// Adds .agent/ , tree-view-result.txt and code-dump-result.md to
+ /// .git/info/exclude when missing, so agent artifacts never show up as dirty state.
+ ///
+ /// The workspace directory.
+ /// Success or a KA_GIT_FAILURE .
+ Task EnsureAgentPathsExcludedAsync(string workspaceRoot);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IPathSandbox.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IPathSandbox.cs
new file mode 100644
index 0000000..63a255a
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IPathSandbox.cs
@@ -0,0 +1,17 @@
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// The path safety gate: every model-supplied path goes through here before any file operation.
+///
+public interface IPathSandbox : IService
+{
+ ///
+ /// Resolves a workspace-relative path to an absolute path and verifies it stays inside the workspace,
+ /// outside .git and .agent , and does not escape through .. or a symlink.
+ ///
+ /// The model-supplied path (forward slashes).
+ /// The absolute path, or a KA_PATH_OUTSIDE failure.
+ Outcome Resolve(string relativePath);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IReplyExtractor.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IReplyExtractor.cs
new file mode 100644
index 0000000..67e2dff
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IReplyExtractor.cs
@@ -0,0 +1,19 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Extracts the final answer from the DOM of one assistant message — and only the final answer.
+/// The reasoning block (.ds-think-content ) is never returned: it is not AgentScript
+/// and feeding it to the parser would break the agent.
+///
+public interface IReplyExtractor : IService
+{
+ ///
+ /// Reads the final-answer text out of a message's HTML.
+ ///
+ /// The outer HTML of one assistant .ds-message element.
+ ///
+ /// The final-answer text with code indentation preserved, or null when the message
+ /// contains no final-answer element yet (still thinking).
+ ///
+ string? ExtractFinalAnswer(string messageOuterHtml);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs
new file mode 100644
index 0000000..4dca1ce
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IResultFormatter.cs
@@ -0,0 +1,22 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Builds the [KEYLESSAGENT RESULTS] message sent back to DeepSeek after each executed batch.
+/// Every result maps output to the command's number, name and target.
+///
+public interface IResultFormatter : IService
+{
+ ///
+ /// Formats one results message.
+ ///
+ /// The 1-based user-request number within the session.
+ /// The 1-based response number within the turn.
+ /// The executed commands' results, including skipped ones.
+ /// The per-response command limit (for the skipped note).
+ /// The configured limits, for output capping.
+ /// The complete message text.
+ string Format(int turn, int batch, IReadOnlyList results, int batchLimit, LimitsOptions limits);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IService.cs
new file mode 100644
index 0000000..16194c0
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IService.cs
@@ -0,0 +1,8 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Marker interface for all services in the KeylessAgent application.
+///
+public interface IService
+{
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISessionStore.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISessionStore.cs
new file mode 100644
index 0000000..0d4cc0f
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISessionStore.cs
@@ -0,0 +1,31 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Persists the agent session state in .agent/session.json (chat URL, branch, turn counter),
+/// so a later keyless resume reuses the same chat.
+///
+public interface ISessionStore : IService
+{
+ ///
+ /// Reads the saved session, if any.
+ ///
+ /// The workspace directory.
+ /// The session, or a KA_SESSION_NOT_FOUND failure when none exists yet.
+ Task> LoadAsync(string workspaceRoot);
+
+ ///
+ /// Saves (or updates) the session.
+ ///
+ /// The workspace directory.
+ /// The session state to persist.
+ Task SaveAsync(string workspaceRoot, AgentSession session);
+
+ ///
+ /// Removes the saved session (after the chat is exhausted or the user starts over).
+ ///
+ /// The workspace directory.
+ Task ClearAsync(string workspaceRoot);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs
new file mode 100644
index 0000000..a52b6ae
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ISnapshotService.cs
@@ -0,0 +1,38 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Snapshot;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Runs the two workspace bat files and turns their outputs into filtered, chunked attachments.
+/// The bat files themselves are never modified; the files on disk are never filtered —
+/// filtering applies to the attached copy only.
+///
+public interface ISnapshotService : IService
+{
+ ///
+ /// Runs tree-view.bat and code-dump.bat without a console window, with stdin closed
+ /// and a hard timeout, so the trailing pause can never hang the agent.
+ ///
+ /// The workspace directory.
+ /// Success, or a KA_SNAPSHOT_* failure naming the missing bat or output file.
+ Task RefreshAsync(string workspaceRoot);
+
+ ///
+ /// Reads the current outputs and builds the attachments for the next message,
+ /// applying Snapshot.Filter and splitting oversized dumps at file boundaries.
+ ///
+ /// The workspace directory.
+ /// The effective configuration.
+ /// One tree attachment plus one or more code-dump parts.
+ Task>> BuildAttachmentsAsync(string workspaceRoot, KeylessOptions options);
+
+ ///
+ /// Whether the outputs are missing or older than the newest workspace source file
+ /// (a refresh should run before the first request of a session).
+ ///
+ /// The workspace directory.
+ /// True when a refresh is needed.
+ Task> IsStaleAsync(string workspaceRoot);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ITimingRecorder.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ITimingRecorder.cs
new file mode 100644
index 0000000..ad3d1a2
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/ITimingRecorder.cs
@@ -0,0 +1,23 @@
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Records per-phase timings for one agent turn and produces a summary table.
+/// Thread-safe: each phase is a single Start/Stop pair.
+///
+public interface ITimingRecorder : IService
+{
+ /// Starts timing a named phase (no-op if already started).
+ void Start(string phase);
+
+ /// Stops timing a named phase and records the elapsed time.
+ void Stop(string phase);
+
+ /// The recorded phases in start order with their durations.
+ IReadOnlyList<(string Phase, TimeSpan Duration)> Entries { get; }
+
+ /// Total turn duration (from first Start to last Stop).
+ TimeSpan Total { get; }
+
+ /// A formatted table of all recorded timings.
+ string FormatTable();
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IWorkspaceCommandExecutor.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IWorkspaceCommandExecutor.cs
new file mode 100644
index 0000000..0334a1d
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IWorkspaceCommandExecutor.cs
@@ -0,0 +1,21 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// Executes one parsed AgentScript command on the workspace and returns its result.
+/// Behind this interface sit per-command executors; each one owns its own safety checks
+/// (branch guard, path sandbox, command policy, backups) and never throws for expected failures.
+///
+public interface IWorkspaceCommandExecutor : IService
+{
+ ///
+ /// Executes a single command.
+ ///
+ /// The parsed command to execute.
+ /// The current run context (workspace, options, UI, cancellation).
+ /// The command's execution result, including blocked/declined outcomes.
+ Task ExecuteAsync(AgentScriptCommand command, AgentExecutionContext context);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IWorkspaceInspector.cs b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IWorkspaceInspector.cs
new file mode 100644
index 0000000..e72f902
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Abstractions/Services/IWorkspaceInspector.cs
@@ -0,0 +1,46 @@
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Abstractions.Services;
+
+///
+/// The git-related capabilities of the chosen workspace, probed ONCE per session and reused
+/// for every git decision (branch guard, menus, commit, push).
+///
+/// Whether the git executable is installed and callable.
+/// Whether the workspace is inside a git work tree.
+/// The current branch name ("main", an agent branch, "HEAD" when undeterminable).
+/// Whether the current branch matches the configured agent-branch prefix.
+public sealed record WorkspaceCapabilities(
+ bool GitInstalled,
+ bool IsRepository,
+ string CurrentBranch,
+ bool IsAgentBranch)
+{
+ ///
+ /// NO-GIT MODE: git is not installed. File commands still work (with backups), but there
+ /// is no branch protection, no commits, no push and no git-based change tracking.
+ ///
+ public bool NoGitMode => !GitInstalled;
+
+ /// Whether git-based change tracking is available.
+ public bool CanTrackWithGit => GitInstalled && IsRepository;
+}
+
+///
+/// Probes the workspace capabilities once per session. Implementations must be cheap to call
+/// repeatedly (implementations cache the result).
+///
+public interface IWorkspaceInspector : IService
+{
+ ///
+ /// Returns the workspace capabilities (cached per instance).
+ ///
+ /// The workspace directory.
+ /// The configured git options (branch prefix).
+ /// Cancellation token.
+ /// The capabilities snapshot.
+ Task> InspectAsync(
+ string workspaceRoot,
+ ImanSoftware.KeylessAgent.Abstractions.Models.Configuration.GitOptions options,
+ CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs
new file mode 100644
index 0000000..019e66b
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Factory/KeylessAgentServiceFactory.cs
@@ -0,0 +1,125 @@
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+using ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+using ImanSoftware.KeylessAgent.Core.Implementation.Configuration;
+using ImanSoftware.KeylessAgent.Core.Implementation.Execution;
+using ImanSoftware.KeylessAgent.Core.Implementation.Git;
+using ImanSoftware.KeylessAgent.Core.Implementation.Parsing;
+using ImanSoftware.KeylessAgent.Core.Implementation.Snapshot;
+using ImanSoftware.KeylessAgent.Core.Implementation.Workspace;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Logging.Abstractions;
+
+namespace ImanSoftware.KeylessAgent.Core.Factory;
+
+///
+/// Hand-written wiring for every KeylessAgent service — no DI container.
+/// UIs call and drive it through .
+///
+public static class KeylessAgentServiceFactory
+{
+ ///
+ /// Creates the agent loop with the real browser chat provider.
+ ///
+ /// Optional logger factory; defaults to the no-op factory.
+ /// The agent loop, ready to run turns.
+ public static IAgentLoop CreateAgentLoop(ILoggerFactory? loggerFactory = null)
+ => CreateAgentLoop(new PlaywrightChatProvider(loggerFactory, new ImanSoftware.KeylessAgent.Core.Implementation.Browser.ReplyExtractor()), loggerFactory);
+
+ ///
+ /// Creates the agent loop with a custom chat provider (fake provider in tests and the Playground).
+ ///
+ /// The chat provider to use.
+ /// Optional logger factory; defaults to the no-op factory.
+ /// The agent loop, ready to run turns.
+ public static IAgentLoop CreateAgentLoop(IChatProvider chatProvider, ILoggerFactory? loggerFactory = null)
+ {
+ loggerFactory ??= NullLoggerFactory.Instance;
+ return new AgentLoop(chatProvider, new ImanSoftware.Logging.LoggerAdapter(loggerFactory));
+ }
+
+ ///
+ /// Creates the configuration service.
+ ///
+ /// The configuration service.
+ public static IConfigurationService CreateConfigurationService()
+ => new ConfigurationService();
+
+ ///
+ /// Creates the git service.
+ ///
+ /// The git service.
+ public static IGitService CreateGitService()
+ => new GitService();
+
+ ///
+ /// Creates the backup service (powers keyless undo ).
+ ///
+ /// The backup service.
+ public static IBackupService CreateBackupService()
+ => new BackupService();
+
+ ///
+ /// Creates the snapshot service.
+ ///
+ /// The snapshot service.
+ public static ISnapshotService CreateSnapshotService()
+ => new SnapshotService();
+
+ ///
+ /// Creates a logger factory that appends diagnostics to .agent/logs/diagnostics.log
+ /// (used by the CLI in verbose/debug mode).
+ ///
+ /// The workspace directory.
+ /// The logger factory.
+ public static ILoggerFactory CreateFileLoggerFactory(string workspaceRoot)
+ {
+ var storage = ImanSoftware.FileStorage.ImanFileStorageFactory.CreateStorage();
+ var provider = new ImanSoftware.KeylessAgent.Core.Implementation.Diagnostics.FileLoggerProvider(
+ workspaceRoot, storage);
+ return new SingleProviderLoggerFactory(provider);
+ }
+
+ ///
+ /// Creates the session store (.agent/session.json , powers keyless resume ).
+ ///
+ /// The session store.
+ public static ISessionStore CreateSessionStore()
+ => new SessionStore();
+
+ ///
+ /// Creates the workspace capability inspector (probed once per session).
+ ///
+ /// The workspace inspector.
+ public static IWorkspaceInspector CreateWorkspaceInspector()
+ => new WorkspaceInspector();
+
+ ///
+ /// Creates the change tracker (git porcelain or no-git index).
+ ///
+ /// The change tracker.
+ public static IChangeTracker CreateChangeTracker()
+ => new ChangeTracker();
+
+ ///
+ /// Creates the browser connection service (probe/launch/attach/tab rules) shared by
+ /// run, doctor and the keyless chrome command.
+ ///
+ /// The browser connector.
+ public static IBrowserConnector CreateBrowserConnector()
+ => new BrowserConnector();
+
+ ///
+ /// Creates the chat diagnostics service (powers keyless doctor ; a future GUI can reuse it).
+ ///
+ /// The diagnostics service.
+ public static IChatDiagnostics CreateChatDiagnostics()
+ => new ChatDiagnosticsService();
+
+ ///
+ /// Creates the AgentScript parser.
+ ///
+ /// The parser.
+ public static IAgentScriptParser CreateParser()
+ => new AgentScriptParser();
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Factory/SingleProviderLoggerFactory.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Factory/SingleProviderLoggerFactory.cs
new file mode 100644
index 0000000..f9902a9
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Factory/SingleProviderLoggerFactory.cs
@@ -0,0 +1,31 @@
+using Microsoft.Extensions.Logging;
+
+namespace ImanSoftware.KeylessAgent.Core.Factory;
+
+///
+/// The minimal ILoggerFactory for a single provider — enough for the CLI's verbose mode,
+/// without pulling in the full Microsoft.Extensions.Logging package at the call site.
+///
+internal sealed class SingleProviderLoggerFactory(ILoggerProvider singleProvider) : ILoggerFactory
+{
+ private bool _disposed;
+
+ ///
+ public ILogger CreateLogger(string categoryName) => singleProvider.CreateLogger(categoryName);
+
+ ///
+ public void AddProvider(ILoggerProvider provider)
+ {
+ // This factory wraps exactly one provider; additional providers are ignored.
+ }
+
+ ///
+ public void Dispose()
+ {
+ if (_disposed)
+ return;
+
+ _disposed = true;
+ singleProvider.Dispose();
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/ImanSoftware.KeylessAgent.Core.csproj b/src/Core/ImanSoftware.KeylessAgent.Core/ImanSoftware.KeylessAgent.Core.csproj
new file mode 100644
index 0000000..1385b77
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/ImanSoftware.KeylessAgent.Core.csproj
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs
new file mode 100644
index 0000000..d4df143
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/AgentLoop.cs
@@ -0,0 +1,928 @@
+using ImanSoftware.DateTimeProvider;
+using ImanSoftware.KeylessAgent.Abstractions.Models;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Snapshot;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.KeylessAgent.Core.Implementation.Configuration;
+using ImanSoftware.KeylessAgent.Core.Implementation.Workspace;
+using ImanSoftware.KeylessAgent.Core.Implementation.Execution;
+using ImanSoftware.KeylessAgent.Core.Implementation.Parsing;
+using ImanSoftware.KeylessAgent.Core.Implementation.Git;
+using ImanSoftware.KeylessAgent.Core.Implementation.Snapshot;
+using ImanSoftware.Logging;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// The orchestrator: one call = one user request (one turn). A sequence of small services with
+/// numbered steps, each returning an outcome. The snapshot bat files always run at the end,
+/// even when the turn fails or the user cancels.
+///
+internal sealed class AgentLoop : IAgentLoop
+{
+ private readonly IConfigurationService _configuration;
+ private readonly IGitService _git;
+ private readonly IBranchGuard _branchGuard;
+ private readonly ISnapshotService _snapshot;
+ private readonly IChatProvider _chat;
+ private readonly IResponseFormatGuard _guard;
+ private readonly IWorkspaceCommandExecutor _executor;
+ private readonly IResultFormatter _resultFormatter;
+ private readonly ISessionStore _sessions;
+ private readonly IWorkspaceInspector _workspaceInspector;
+ private readonly IChangeTracker _changeTracker;
+ private readonly BranchNameBuilder _branchNames;
+ private readonly IDateTimeProvider _time;
+ private readonly ILoggerAdapter _log;
+
+ public AgentLoop(IChatProvider chat, ILoggerAdapter? log = null)
+ : this(
+ chat,
+ new ConfigurationService(),
+ new GitService(),
+ new BranchGuard(),
+ new SnapshotService(),
+ new ResponseFormatGuard(),
+ new WorkspaceCommandExecutor(),
+ new ResultFormatter(),
+ new SessionStore(),
+ new WorkspaceInspector(),
+ new ChangeTracker(),
+ new BranchNameBuilder(new DefaultDateTimeProvider()),
+ new DefaultDateTimeProvider(),
+ log)
+ {
+ }
+
+ internal AgentLoop(
+ IChatProvider chat,
+ IConfigurationService configuration,
+ IGitService git,
+ IBranchGuard branchGuard,
+ ISnapshotService snapshot,
+ IResponseFormatGuard guard,
+ IWorkspaceCommandExecutor executor,
+ IResultFormatter resultFormatter,
+ ISessionStore sessions,
+ IWorkspaceInspector workspaceInspector,
+ IChangeTracker changeTracker,
+ BranchNameBuilder branchNames,
+ IDateTimeProvider time,
+ ILoggerAdapter? log)
+ {
+ _chat = chat;
+ _configuration = configuration;
+ _git = git;
+ _branchGuard = branchGuard;
+ _snapshot = snapshot;
+ _guard = guard;
+ _executor = executor;
+ _resultFormatter = resultFormatter;
+ _sessions = sessions;
+ _workspaceInspector = workspaceInspector;
+ _changeTracker = changeTracker;
+ _branchNames = branchNames;
+ _time = time;
+ _log = log ?? new LoggerAdapter(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance);
+ }
+
+ ///
+ ///
+ /// A compact fixed reminder (~8 lines) prepended to every user-request message.
+ ///
+ private static string BuildPerMessageReminder(string envSummary, bool noGitMode)
+ {
+ var gitNote = noGitMode
+ ? "git is NOT installed — do not use @@commit or git commands."
+ : "You are on an agent branch; use @@commit when done.";
+
+ return string.Join("\n",
+ "[REMINDER] AgentScript v1 — reply ONLY with blocks from this set:",
+ "@@say / @@newfile / @@updatefile / @@editfile / @@deletefile / @@readfile / @@runcommand / @@ask / @@commit / @@movefile + <<>> / @@next + optional <<>> / @@done",
+ "Each block: @@keyword [argument] line, optional <<>> payload (markers alone on their own line), then @@end. End with @@eof. No @@command, no JSON, no markdown fence.",
+ "You CAN create/modify files with @@newfile/@@updatefile/@@editfile/@@movefile — you DO execute commands yourself via @@runcommand. Never give the user commands to run.",
+ $"Environment: {envSummary}",
+ gitNote,
+ "Investigate first (read-only commands, readfile) instead of guessing; use @@next to get results before continuing; use @@done to end the turn.",
+ "[/REMINDER]");
+ }
+
+ public async Task RunAsync(
+ string workspaceRoot,
+ string request,
+ IAgentUserInterface ui,
+ bool dryRun = false,
+ CancellationToken cancellationToken = default)
+ {
+ var logger = new TurnLogger(ImanSoftware.FileStorage.ImanFileStorageFactory.CreateStorage(), _time);
+ KeylessOptions options = new();
+ string branch = string.Empty;
+ int turn = 1;
+ var completedByDone = false;
+ string? summary = null;
+ string? stoppedReason = null;
+ string? errorMessage = null;
+ string? lastDirtyChoice = null;
+ AgentSession? savedSession = null;
+ object? changeBaseline = null;
+ var turnChanges = new List();
+ var noGitModeUsed = false;
+ var agentPaths = new HashSet(StringComparer.OrdinalIgnoreCase);
+ var timings = new TimingRecorder();
+ var runCommandCount = 0;
+ var baseBranch = string.Empty;
+ var countingUi = new CommandCountingUserInterface(ui);
+
+ try
+ {
+ // --- Step 1: Load and validate configuration ---
+ timings.Start("config");
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SessionStarting, "Loading keyless.json"));
+ var configOutcome = await _configuration.LoadAsync(workspaceRoot);
+ if (configOutcome.IsFailure)
+ return Failed(configOutcome.Error.Message, dryRun);
+ options = configOutcome.Data;
+ timings.Stop("config");
+
+ if (options.Mode is ApprovalMode.AutoSafe or ApprovalMode.Auto)
+ {
+ // Programs like dotnet/node/python execute arbitrary code; the allow list is
+ // convenience, not a security boundary. Shown once per turn in these modes.
+ ui.Say("[!] AutoSafe is not a security boundary: allow-listed programs (dotnet, node, python, ...) can execute arbitrary code on this machine.");
+ }
+
+ // --- Step 2: Workspace capabilities (probed ONCE per session, reused everywhere) ---
+ timings.Start("git-checks");
+ var capabilitiesOutcome = await _workspaceInspector.InspectAsync(workspaceRoot, options.Git, cancellationToken);
+ if (capabilitiesOutcome.IsFailure)
+ return Failed(capabilitiesOutcome.Error.Message, dryRun);
+ var capabilities = capabilitiesOutcome.Data;
+ var noGitMode = capabilities.NoGitMode;
+
+ if (noGitMode)
+ {
+ // NO-GIT MODE: one warning, no prompts, no branch protection, no commits, no push.
+ ui.Say("[!] Git is not installed. There is no branch protection, no commits and no git history, " +
+ "so changes cannot be reverted with git. File changes made through AgentScript file commands " +
+ "are still backed up in .agent/backups (keyless undo); changes made by commands are not.");
+ noGitModeUsed = true;
+ }
+ else if (!capabilities.IsRepository)
+ {
+ // --- Step 2b: automatic repository initialization ---
+ if (!options.Git.AutoInit)
+ return Failed("The workspace is not a git repository (Git.AutoInit is disabled). Run 'git init' first.", dryRun, ErrorCodes.NotAGitRepository);
+
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SessionStarting, "Not a git repository; initializing one…"));
+ if (!dryRun)
+ {
+ var init = await _git.InitRepositoryAsync(workspaceRoot);
+ if (init.IsFailure)
+ return Failed($"git init failed: {init.Error.Message}", dryRun, ErrorCodes.GitInitFailed);
+
+ var gitignore = await _git.EnsureDotnetGitignoreAsync(workspaceRoot);
+ if (gitignore.IsFailure)
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SessionStarting, $"Could not create .gitignore: {gitignore.Error.Message}"));
+
+ var commit = await _git.CreateInitialCommitAsync(workspaceRoot);
+ if (commit.IsFailure)
+ return Failed($"Initial commit failed: {commit.Error.Message}", dryRun, ErrorCodes.GitInitFailed);
+ }
+
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.SessionStarting,
+ "Initialized a git repository (main) with a .gitignore and an initial commit."));
+
+ var refreshed = await _git.GetRepositoryInfoAsync(workspaceRoot);
+ if (refreshed.IsFailure || !refreshed.Data.IsRepository)
+ return Failed("Repository initialization did not produce a usable git repository.", dryRun, ErrorCodes.GitInitFailed);
+ }
+
+ if (!noGitMode)
+ await _git.EnsureAgentPathsExcludedAsync(workspaceRoot);
+ timings.Stop("git-checks");
+
+ // --- Step 2c: Environment probe (parallel, 3s timeout each) ---
+ timings.Start("env-probe");
+ var environmentProbe = new EnvironmentProbe();
+ var env = await environmentProbe.ProbeAsync(workspaceRoot, cancellationToken);
+ var envBlock = env.IsSuccess ? env.Data.Block : "[ENVIRONMENT unavailable]";
+ var envSummary = env.IsSuccess ? env.Data.Summary : "";
+ timings.Stop("env-probe");
+
+ // --- Step 3+4: Git preconditions (menus) ---
+ branch = noGitMode ? string.Empty : capabilities.CurrentBranch;
+ baseBranch = branch;
+ var isProtected = !noGitMode && _branchGuard.IsProtected(branch, options.Git);
+ var isAgentBranch = !noGitMode && capabilities.IsAgentBranch;
+
+ // Change tracking: baseline before the turn.
+ if (!dryRun)
+ changeBaseline = (await _changeTracker.CaptureAsync(workspaceRoot, !noGitMode, cancellationToken)).Data;
+
+ var repo = await _git.GetRepositoryInfoAsync(workspaceRoot);
+ var repoDirty = !noGitMode && repo.IsSuccess && repo.Data.IsRepository && (repo.Data.IsDirty);
+ var loadedSession = await _sessions.LoadAsync(workspaceRoot);
+ var agentChangedPaths = loadedSession is { IsSuccess: true } && loadedSession.Data.AgentChangedPaths.Count > 0
+ ? loadedSession.Data.AgentChangedPaths
+ : null;
+
+ // Skip the dirty menu when every dirty path was changed by the agent itself (the
+ // set persisted in the session) AND we are already on an agent branch. The check
+ // reads porcelain FRESH: diffing against the baseline captured moments ago would
+ // only ever see the state from before this turn started.
+ var skipDirtyMenu = false;
+ if (repoDirty && isAgentBranch && agentChangedPaths is { Count: > 0 })
+ {
+ var statusNow = await _git.RunStatusPorcelainAsync(workspaceRoot);
+ skipDirtyMenu = statusNow is { IsSuccess: true } && statusNow.Data.All(line =>
+ {
+ // "XY path" — same fixed-column shape ChangeTracker.ParsePorcelain reads.
+ if (line.Length < 4)
+ return true;
+
+ var path = line[3..].Trim('"');
+ var arrow = path.IndexOf(" -> ", StringComparison.Ordinal);
+ if (arrow >= 0)
+ path = path[(arrow + 4)..];
+
+ return agentChangedPaths.Contains(path.Replace('\\', '/'), StringComparer.OrdinalIgnoreCase);
+ });
+ }
+
+
+ if (!noGitMode && repoDirty && !skipDirtyMenu)
+ {
+ // Dirty tree menu: numbered, no typing. "carry" is only offered when we are NOT
+ // already on an agent branch; the default is "carry" off-branch, "here" on-branch.
+ var dirtyOptions = new List();
+ if (!isAgentBranch)
+ dirtyOptions.Add(new("carry", "Carry my uncommitted changes to a new agent branch"));
+ dirtyOptions.Add(new("stash", "Stash my changes first (git stash, restore later yourself)"));
+ dirtyOptions.Add(new("here", $"Continue on the current branch ('{branch}') with my changes in place"));
+ dirtyOptions.Add(new("cancel", "Cancel"));
+
+ var dirtyMenu = new ChoiceRequest(
+ "The working tree has uncommitted changes. What should I do?",
+ dirtyOptions,
+ DefaultId: isAgentBranch ? "here" : "carry");
+
+ var dirtyChoice = await ui.ChooseAsync(dirtyMenu, cancellationToken);
+ lastDirtyChoice = dirtyChoice.SelectedId;
+ if (dirtyChoice.Cancelled || dirtyChoice.SelectedId == "cancel")
+ return Failed("Cancelled by the user (dirty working tree).", dryRun);
+
+ if (dirtyChoice.SelectedId == "stash")
+ {
+ var stash = await _git.StashAsync(workspaceRoot);
+ if (stash.IsFailure)
+ return Failed($"git stash failed: {stash.Error.Message}", dryRun);
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SessionStarting, "Changes stashed (restore later with 'git stash pop')."));
+ }
+ }
+
+ if (isProtected)
+ {
+ // Protected branch: no choice — the agent branch is created automatically.
+ var agentBranch = _branchNames.Build(request, options.Git);
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.SessionStarting,
+ $"Branch '{branch}' is protected; creating agent branch {agentBranch}" + (repoDirty ? " (carrying your uncommitted changes)." : ".")));
+ var created = dryRun
+ ? Outcome.Successful()
+ : await _git.CreateAndSwitchBranchAsync(workspaceRoot, agentBranch);
+ if (created.IsFailure)
+ return Failed($"Cannot create agent branch: {created.Error.Message}", dryRun);
+ branch = agentBranch;
+ }
+ else if (repoDirty)
+ {
+ // The dirty menu already decided: carry → new agent branch, here → stay.
+ if (lastDirtyChoice == "carry")
+ {
+ var agentBranch = _branchNames.Build(request, options.Git);
+ var created = dryRun
+ ? Outcome.Successful()
+ : await _git.CreateAndSwitchBranchAsync(workspaceRoot, agentBranch);
+ if (created.IsFailure)
+ return Failed($"Cannot create agent branch: {created.Error.Message}", dryRun);
+ branch = agentBranch;
+ }
+ }
+ else if (!noGitMode && !dryRun)
+ {
+ // Clean tree on a non-protected branch: one menu (new / stay / cancel).
+ var branchMenu = new ChoiceRequest(
+ $"You are on '{branch}'. Which branch should I work on?",
+ new List
+ {
+ new("new", $"Create a new agent branch from '{branch}' (recommended)"),
+ new("stay", $"Continue on '{branch}'"),
+ new("cancel", "Cancel"),
+ },
+ DefaultId: "new");
+
+ var branchChoice = await ui.ChooseAsync(branchMenu, cancellationToken);
+ if (branchChoice.Cancelled || branchChoice.SelectedId == "cancel")
+ return Failed("Cancelled by the user (branch choice).", dryRun);
+
+ if (branchChoice.SelectedId == "new")
+ {
+ var agentBranch = _branchNames.Build(request, options.Git);
+ var created = await _git.CreateAndSwitchBranchAsync(workspaceRoot, agentBranch);
+ if (created.IsFailure)
+ return Failed($"Cannot create agent branch: {created.Error.Message}", dryRun);
+ branch = agentBranch;
+ }
+ }
+
+ // --- Step 5: Session state (resume or new chat) ---
+ var sessionOutcome = await _sessions.LoadAsync(workspaceRoot);
+ var session = sessionOutcome.IsSuccess ? sessionOutcome.Data : null;
+
+ // MaxTurnsPerChat: the limit is checked BEFORE connecting, so the request never
+ // goes to a chat that has already served its turns.
+ if (session is not null && session.TurnsInChat >= options.Browser.MaxTurnsPerChat)
+ {
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SessionStarting,
+ $"Chat has served {session.TurnsInChat} turns; starting a new chat."));
+ await _sessions.ClearAsync(workspaceRoot);
+ session = null;
+ }
+
+ turn = (session?.Turn ?? 0) + 1;
+
+ // The agent-changed path set persists across turns and is cleared only with the session.
+ agentPaths.UnionWith(session?.AgentChangedPaths ?? []);
+ await logger.StartAsync(workspaceRoot, turn, cancellationToken);
+ await logger.AppendAsync("turn-started", new { Turn = turn, Request = request, Branch = branch, DryRun = dryRun }, cancellationToken);
+
+ timings.Start("connect");
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.ConnectingToBrowser, "Connecting to the chat"));
+ var chatOutcome = await _chat.StartSessionAsync(
+ new Abstractions.Models.Chat.ChatSessionOptions
+ {
+ Browser = options.Browser,
+ WorkspaceRoot = workspaceRoot,
+ ResumeChatUrl = session?.ChatUrl,
+ Timings = timings,
+ }, cancellationToken);
+ if (chatOutcome.IsFailure)
+ return Failed(chatOutcome.Error.Message, dryRun);
+
+ await using var chatSession = chatOutcome.Data;
+ timings.Stop("connect");
+ savedSession = new AgentSession
+ {
+ ChatUrl = chatSession.ChatUrl,
+ Branch = branch,
+ Turn = turn,
+ SystemPromptSent = session?.SystemPromptSent ?? false,
+ CreatedAtUtc = session?.CreatedAtUtc ?? _time.Now.ToUniversalTime().ToString("O"),
+ AgentChangedPaths = [.. agentPaths],
+
+ // Incremented at turn start, not end, so the limit check sees the true count.
+ TurnsInChat = (session?.TurnsInChat ?? 0) + 1,
+ };
+ await _sessions.SaveAsync(workspaceRoot, savedSession);
+
+ // --- Step 6: Snapshot refresh when stale ---
+ var staleOutcome = await _snapshot.IsStaleAsync(workspaceRoot);
+ if (staleOutcome.IsSuccess && staleOutcome.Data)
+ {
+ timings.Start("snapshot-refresh");
+ await RefreshSnapshotAsync(ui, workspaceRoot, logger);
+ timings.Stop("snapshot-refresh");
+ }
+
+ // --- Step 7: Send the request (system prompt on the first message of a new chat) ---
+ timings.Start("attachments");
+ var attachmentsOutcome = await _snapshot.BuildAttachmentsAsync(workspaceRoot, options);
+ timings.Stop("attachments");
+ var attachments = attachmentsOutcome.IsSuccess ? attachmentsOutcome.Data : [];
+ if (attachmentsOutcome.IsFailure)
+ {
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.UploadingAttachments,
+ $"Snapshot unavailable: {attachmentsOutcome.Error.Message}"));
+ }
+ else
+ {
+ // Before sending: what exactly is going to the chat (files, size, filtering).
+ var totalKb = attachments.Sum(a => (long)a.Content.Length) / 1024;
+ var marker = attachments.FirstOrDefault(a => a.Kind == SnapshotAttachmentKind.TreeView)
+ ?.Content.Split('\n').FirstOrDefault();
+ var suffix = marker is ['[', ..] ? $" - {marker}" : string.Empty;
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.UploadingAttachments,
+ $"Attachments: {attachments.Count} files, {totalKb} KB{suffix}"));
+ }
+
+ // Message 1 of a new chat: the AgentScript rules ALONE, with the instruction that
+ // they apply to the whole conversation. Mixing them into the request made the model
+ // skim the grammar and invent formats ("@@command", JSON blocks).
+ var sendSystemPrompt = session is null || !session.SystemPromptSent;
+ if (sendSystemPrompt)
+ {
+ var prompt = SystemPromptBuilder.Build(options);
+ if (prompt.IsFailure)
+ return Failed(prompt.Error.Message, dryRun);
+
+ if (noGitModeUsed)
+ prompt = Outcome.Successful(prompt.Data +
+ "\n\nIMPORTANT: git is NOT installed in this workspace. There is no version control: " +
+ "do not use @@commit and do not run any git commands (they will fail with command_not_found). " +
+ "File changes are backed up by the program automatically.");
+
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SendingMessage, "Sending the AgentScript rules (first message of this chat)"));
+ if (!dryRun)
+ {
+ Outcome sentPrompt;
+ using (ui.BeginWait("Sending the AgentScript rules to DeepSeek"))
+ {
+ sentPrompt = await chatSession.SendMessageAsync(prompt.Data, [], cancellationToken);
+ }
+
+ if (sentPrompt.IsFailure)
+ return Failed(sentPrompt.Error.Message, dryRun);
+
+ savedSession = savedSession! with { SystemPromptSent = true };
+ await _sessions.SaveAsync(workspaceRoot, savedSession);
+ await logger.AppendAsync("system-prompt-sent", new { Turn = turn }, cancellationToken);
+ }
+ }
+
+ // Message 2: the user's request + a compact AgentScript reminder + the workspace
+ // snapshot attachments. The full system prompt stays on the first message only.
+ var reminder = BuildPerMessageReminder(envSummary, noGitModeUsed);
+ var message = reminder + "\n\n" + request;
+ if (!dryRun)
+ {
+ timings.Start("upload");
+ Outcome sent;
+ using (ui.BeginWait("Sending the request to DeepSeek"))
+ {
+ sent = await chatSession.SendMessageAsync(message, attachments, cancellationToken);
+ }
+ timings.Stop("upload");
+
+ if (sent.IsFailure)
+ return Failed(sent.Error.Message, dryRun);
+ }
+
+ // --- Step 8: The conversation loop ---
+ var formatRetries = 0;
+ var batch = 0;
+ while (batch < options.Limits.MaxIterationsPerTurn)
+ {
+ batch++;
+ cancellationToken.ThrowIfCancellationRequested();
+
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.WaitingForReply, $"Waiting for reply {batch}"));
+ timings.Start("reply-wait");
+ Outcome replyOutcome;
+ using (ui.BeginWait($"Waiting for reply {batch}"))
+ {
+ replyOutcome = await chatSession.WaitForReplyAsync(cancellationToken);
+ }
+ timings.Stop("reply-wait");
+ if (replyOutcome.IsFailure)
+ {
+ if (replyOutcome.Error.Code == ErrorCodes.ChatExhausted)
+ {
+ // The chat cannot take more messages. This batch's commands already ran;
+ // resending would re-execute them. Clearing the session makes the next
+ // request start a new chat — the user re-runs with their progress.
+ await _sessions.ClearAsync(workspaceRoot);
+ savedSession = null;
+ stoppedReason = "chat exhausted; a new chat will be used on the next request. Re-run with your current progress.";
+ break;
+ }
+
+ stoppedReason = $"no reply: {replyOutcome.Error.Message}";
+ break;
+ }
+
+ var reply = replyOutcome.Data;
+
+ var replySource = chatSession.LastReplyOrigin;
+ await logger.AppendAsync("reply-received", new { Turn = turn, Batch = batch, Reply = reply, Source = replySource }, cancellationToken);
+
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.ValidatingReply, "Validating AgentScript"));
+ timings.Start("parse");
+ Outcome verdictOutcome;
+ try
+ {
+ verdictOutcome = _guard.Check(reply, options.Limits);
+ }
+ finally
+ {
+ timings.Stop("parse");
+ }
+ if (verdictOutcome.IsFailure)
+ {
+ stoppedReason = $"guard failure: {verdictOutcome.Error.Message}";
+ break;
+ }
+
+ var verdict = verdictOutcome.Data;
+ var script = verdict.Script!;
+
+ // Compact per-reply summary (the raw panel stays reserved for invalid replies
+ // and -v debugging): one line instead of the raw event dump. Script is null
+ // for invalid replies, where the raw panel below tells the user what broke.
+ if (verdict.Script is not null)
+ {
+ var kinds = script.Commands
+ .GroupBy(c => c.Kind)
+ .Select(g => $"{g.Key} x{g.Count()}");
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ValidatingReply,
+ $"Reply: {script.Commands.Count} commands ({string.Join(", ", kinds)})"));
+ }
+
+ // Show the raw reply panel ONLY when invalid (the user needs to see what went
+ // wrong). The loop has no verbose flag; for valid replies a compact summary
+ // suffices, and the full reply text is always in .agent/logs.
+ if (!verdict.IsValid)
+ {
+ const int MaxConsoleReplyChars = 2000;
+ var visibleReply = reply.Length <= MaxConsoleReplyChars
+ ? reply
+ : reply[..MaxConsoleReplyChars] + $"… ({reply.Length - MaxConsoleReplyChars} more; full text in .agent/logs)";
+ ui.Say($"[raw reply]{Environment.NewLine}{visibleReply}");
+ }
+ else
+ {
+ var commandKinds = string.Join(", ", script.Commands.Select(c => c.Kind.ToString().ToLowerInvariant()));
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.ValidatingReply,
+ $"Reply: {script.Commands.Count} commands ({commandKinds}) via {replySource}"));
+ }
+ if (!verdict.IsValid)
+ {
+ if (formatRetries >= options.Limits.MaxFormatRetries)
+ {
+ stoppedReason = "format retries exhausted; the last raw reply is below";
+ ui.Say("[FORMAT RETRIES EXHAUSTED] The model kept sending invalid AgentScript. Last raw reply:\n" + reply);
+ break;
+ }
+
+ formatRetries++;
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.RequestingResend,
+ $"Invalid reply ({verdict.Problems.Count} problems); asking for a resend ({formatRetries}/{options.Limits.MaxFormatRetries})"));
+ var correction = CorrectionMessageBuilder.Build(verdict.Problems, verdict.LooksTruncated);
+ if (correction.IsFailure)
+ {
+ stoppedReason = correction.Error.Message;
+ break;
+ }
+
+ await logger.AppendAsync("correction-sent", new { Turn = turn, Problems = verdict.Problems.Select(p => p.ToDisplayString()).ToList() }, cancellationToken);
+ if (!dryRun)
+ {
+ Outcome resent;
+ using (ui.BeginWait("Sending the format correction"))
+ {
+ resent = await chatSession.SendMessageAsync(correction.Data, [], cancellationToken);
+ }
+
+ if (resent.IsFailure)
+ {
+ stoppedReason = $"cannot send correction: {resent.Error.Message}";
+ break;
+ }
+ }
+
+ // The resend replaces the rejected response within the same batch slot.
+ batch--;
+ continue;
+ }
+
+ formatRetries = 0;
+
+ // --- Step 8b: Execute (say always; others up to the batch limit; rest skipped) ---
+ var executed = new List();
+ var usedSlots = 0;
+ var stopBatch = false;
+ var failedCommandNumber = 0;
+ foreach (var command in script.Commands)
+ {
+ if (command.Kind == CommandKind.Say)
+ {
+ timings.Start("execute");
+ var sayResult = await _executor.ExecuteAsync(command, Context());
+ timings.Stop("execute");
+ executed.Add(sayResult);
+ continue;
+ }
+
+ if (usedSlots >= options.Limits.MaxCommandsPerResponse)
+ {
+ executed.Add(new CommandExecutionResult
+ {
+ Number = command.Number,
+ Kind = command.Kind,
+ Target = command.Target,
+ Status = CommandStatus.Skipped,
+ ErrorMessage = "batch limit reached",
+ });
+ continue;
+ }
+
+ usedSlots++;
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.ExecutingCommand,
+ $"#{command.Number} {command.Kind} {command.Target}"));
+
+ if (stopBatch)
+ {
+ executed.Add(new CommandExecutionResult
+ {
+ Number = command.Number,
+ Kind = command.Kind,
+ Target = command.Target,
+ Status = CommandStatus.Skipped,
+ ErrorMessage = $"batch stopped at command #{failedCommandNumber}",
+ });
+ continue;
+ }
+
+ timings.Start("execute");
+ var result = await _executor.ExecuteAsync(command, Context());
+ timings.Stop("execute");
+ executed.Add(result);
+ await logger.AppendResultAsync(result, cancellationToken);
+
+ if (result.Kind == CommandKind.RunCommand)
+ runCommandCount++;
+
+ if (command.Kind == CommandKind.Done)
+ {
+ completedByDone = true;
+ summary = ((DoneCommand)command).Summary;
+
+ // Blocks after @@done are leftovers, not instructions: they are
+ // reported as skipped WITHOUT executing, and the batch ends here.
+ // (If @@done itself failed — it cannot today — the old behavior of
+ // running the remaining commands is unchanged.)
+ if (result.Status == CommandStatus.Ok)
+ {
+ // @@say blocks after @@done are plain user-facing text: they still
+ // reach the user and never count toward MaxCommandsPerResponse.
+ // Every other remaining command is reported as skipped.
+ foreach (var remaining in script.Commands.Where(c => c.Number > command.Number))
+ {
+ if (remaining.Kind == CommandKind.Say)
+ {
+ timings.Start("execute");
+ var sayAfterDone = await _executor.ExecuteAsync(remaining, Context());
+ timings.Stop("execute");
+ executed.Add(sayAfterDone);
+ await logger.AppendResultAsync(sayAfterDone, cancellationToken);
+ continue;
+ }
+
+ var skipped = new CommandExecutionResult
+ {
+ Number = remaining.Number,
+ Kind = remaining.Kind,
+ Target = remaining.Target,
+ Status = CommandStatus.Skipped,
+ ErrorMessage = "after @@done",
+ };
+ executed.Add(skipped);
+ await logger.AppendResultAsync(skipped, cancellationToken);
+ }
+
+ break;
+ }
+ }
+
+ // Strict failure precedence inside the batch: the command's own
+ // on_error option wins over the configured batch failure mode, and
+ // read-only probes never stop a StopOnMutationFailure batch.
+ if (result.Status == CommandStatus.Error)
+ {
+ if (command is RunCommandCommand run && run.OnError is not null)
+ {
+ if (run.OnError.Equals("stop", StringComparison.OrdinalIgnoreCase))
+ {
+ stopBatch = true;
+ failedCommandNumber = command.Number;
+ }
+ continue;
+ }
+
+ if (options.Commands.BatchFailureMode.Equals("Continue", StringComparison.OrdinalIgnoreCase))
+ continue;
+ if (options.Commands.BatchFailureMode.Equals("StopOnAnyFailure", StringComparison.OrdinalIgnoreCase))
+ {
+ stopBatch = true;
+ failedCommandNumber = command.Number;
+ continue;
+ }
+
+ var tier = command.Kind == CommandKind.ReadFile ? CommandRiskTier.ReadOnly : result.Tier;
+ if (tier == CommandRiskTier.ReadOnly)
+ continue;
+
+ stopBatch = true;
+ failedCommandNumber = command.Number;
+ }
+ }
+
+
+ if (completedByDone)
+ {
+ await logger.AppendAsync("turn-done", new { Turn = turn, Summary = summary }, cancellationToken);
+ await logger.AppendAsync("timings", new { Turn = turn, Timings = timings.FormatTable() }, cancellationToken);
+ break;
+ }
+
+ if (batch >= options.Limits.MaxIterationsPerTurn)
+ {
+ stoppedReason = $"iteration limit reached ({options.Limits.MaxIterationsPerTurn} responses)";
+ break;
+ }
+
+ // --- Step 8c: Send the per-command results back ---
+ timings.Start("attachments");
+ var feedbackAttachments = options.Snapshot.AttachOnFeedback
+ ? (await _snapshot.BuildAttachmentsAsync(workspaceRoot, options)).Data ?? []
+ : [];
+ timings.Stop("attachments");
+
+ var resultsMessage = _resultFormatter.Format(turn, batch, executed, options.Limits.MaxCommandsPerResponse, options.Limits);
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.SendingResults, "Sending results"));
+ await logger.AppendAsync("results-sent", new { Turn = turn, Batch = batch, Results = executed.Count }, cancellationToken);
+ if (!dryRun)
+ {
+ timings.Start("results-send");
+ Outcome sent;
+ using (ui.BeginWait("Sending the results to DeepSeek"))
+ {
+ sent = await chatSession.SendMessageAsync(resultsMessage, feedbackAttachments, cancellationToken);
+ }
+ timings.Stop("results-send");
+
+ if (sent.IsFailure)
+ {
+ stoppedReason = $"cannot send results: {sent.Error.Message}";
+ break;
+ }
+
+ // Per-batch snapshot refresh (off by default): the model sees the workspace
+ // as of right now when the next attachments are built.
+ if (options.Snapshot.RefreshAfterEachBatch)
+ await RefreshSnapshotAsync(ui, workspaceRoot, logger);
+ }
+ }
+
+ cancellationToken.ThrowIfCancellationRequested();
+ }
+ catch (OperationCanceledException)
+ {
+ stoppedReason = "cancelled by the user";
+ }
+ catch (Exception ex)
+ {
+ errorMessage = ex.Message;
+ stoppedReason = "internal error";
+ _log.LogError(ex, "Agent turn failed");
+ }
+ finally
+ {
+ // --- Step 9: Always refresh the snapshot so the next request starts fresh ---
+ timings.Start("snapshot-refresh");
+ await RefreshSnapshotAsync(ui, workspaceRoot, logger);
+ timings.Stop("snapshot-refresh");
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.TurnFinished, stoppedReason ?? "turn finished"));
+
+ // Persist the agent-changed path set for the next turn's dirty-menu skip — even when
+ // the turn never reached @@done, the files it changed are still agent-made. The same
+ // diff feeds ChangeSet on the turn result (Cancellation: the turn is over, so a
+ // cancelled token must not abort the diff inside the finally block).
+ if (!dryRun && changeBaseline is not null)
+ {
+ var changed = (await _changeTracker.DiffAsync(workspaceRoot, changeBaseline, !noGitModeUsed, CancellationToken.None)).Data;
+ if (changed is not null)
+ {
+ turnChanges.AddRange(changed);
+ agentPaths.UnionWith(changed.Select(c => c.Path));
+ }
+ }
+
+ if (errorMessage is null && savedSession is not null)
+ {
+ savedSession = savedSession with { AgentChangedPaths = [.. agentPaths] };
+ await _sessions.SaveAsync(workspaceRoot, savedSession);
+ }
+ }
+
+ var commits = new List();
+ if (errorMessage is null && !dryRun && branch.Length > 0)
+ {
+ var log = await _git.ListCommitsAsync(workspaceRoot, 20);
+ if (log.IsSuccess)
+ commits = log.Data.ToList();
+ }
+
+ // Diff stat against the branch the turn started from; git problems never fail the turn.
+ var diffStat = string.Empty;
+ if (errorMessage is null && !dryRun && !noGitModeUsed && baseBranch.Length > 0)
+ {
+ var diff = await new ProcessRunner().RunAsync(
+ "git", $"diff --stat \"{baseBranch}\"...HEAD", workspaceRoot, 30);
+ if (diff.ExitCode == 0)
+ diffStat = diff.Output.TrimEnd();
+ }
+
+ return new AgentTurnResult
+ {
+ Branch = branch,
+ Turn = turn,
+ Commits = commits,
+ Summary = summary,
+ CompletedByDone = completedByDone,
+ StoppedReason = stoppedReason,
+ ErrorMessage = errorMessage,
+ ChangeSet = turnChanges,
+ Timings = timings.FormatTable(),
+ AutoApprovedCommands = Math.Max(0, runCommandCount - countingUi.ConfirmedCommands),
+ ConfirmedCommands = countingUi.ConfirmedCommands,
+ DiffStat = diffStat,
+ DryRun = dryRun,
+ };
+
+ AgentExecutionContext Context() => new()
+ {
+ WorkspaceRoot = workspaceRoot,
+ Options = options,
+ DryRun = dryRun,
+ Ui = countingUi,
+ NoGitMode = noGitModeUsed,
+ CancellationToken = cancellationToken,
+ };
+
+ AgentTurnResult Failed(string message, bool dry, string? errorCode = null)
+ {
+ // No TurnFinished here: the finally block shows it once with the same message
+ // (stoppedReason drives the finally's display).
+ stoppedReason = message;
+ return new AgentTurnResult { ErrorMessage = message, DryRun = dry, Turn = 0, ErrorCode = errorCode };
+ }
+ }
+
+ ///
+ /// Counts command approvals flowing through the executor's UI without changing behaviour:
+ /// ConfirmAsync calls titled "Run command" come from RunCommandExecutor and are counted as
+ /// confirmed; the deletion confirmation ("Delete file") and everything else delegate
+ /// unchanged. Auto-approved commands are derived in the loop as executed runcommands minus
+ /// confirmed ones.
+ ///
+ private sealed class CommandCountingUserInterface(IAgentUserInterface inner) : IAgentUserInterface
+ {
+ public int ConfirmedCommands { get; private set; }
+
+ public void Say(string message) => inner.Say(message);
+
+ public void ShowProgress(AgentProgressEvent progressEvent) => inner.ShowProgress(progressEvent);
+
+ public Task ConfirmAsync(ConfirmRequest request)
+ {
+ if (request.Title.StartsWith("Run command", StringComparison.Ordinal))
+ ConfirmedCommands++;
+ return inner.ConfirmAsync(request);
+ }
+
+ public Task AskAsync(string question) => inner.AskAsync(question);
+
+ public void ShowDiff(FileChange change) => inner.ShowDiff(change);
+
+ public Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default)
+ => inner.ChooseAsync(request, cancellationToken);
+
+ public IDisposable BeginWait(string message) => inner.BeginWait(message);
+ }
+
+ private async Task RefreshSnapshotAsync(IAgentUserInterface ui, string workspaceRoot, TurnLogger logger)
+ {
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.RefreshingSnapshot, "Refreshing workspace snapshot"));
+ Outcome refreshed;
+ using (ui.BeginWait("Running tree-view.bat and code-dump.bat"))
+ {
+ refreshed = await _snapshot.RefreshAsync(workspaceRoot);
+ }
+ if (refreshed.IsFailure)
+ {
+ ui.ShowProgress(new AgentProgressEvent(AgentEventType.RefreshingSnapshot,
+ $"Snapshot refresh failed: {refreshed.Error.Message}"));
+ await logger.AppendAsync("snapshot-failed", new { refreshed.Error.Message }, CancellationToken.None);
+ return;
+ }
+
+ await logger.AppendAsync("snapshot-refreshed", new { }, CancellationToken.None);
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/CorrectionMessageBuilder.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/CorrectionMessageBuilder.cs
new file mode 100644
index 0000000..4b51059
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/CorrectionMessageBuilder.cs
@@ -0,0 +1,44 @@
+using System.Reflection;
+using ImanSoftware.KeylessAgent.Abstractions.Models;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Parser;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// Builds the format-correction message from the embedded format-correction.txt template.
+///
+internal static class CorrectionMessageBuilder
+{
+ private const string EmbeddedResourceName = "ImanSoftware.KeylessAgent.Core.Resources.format-correction.txt";
+
+ ///
+ /// Fills the template with the guard's problems (and truncation advice when the reply looked cut off).
+ ///
+ /// Every problem found in the rejected reply.
+ /// Whether the reply appears cut off.
+ /// The correction message to send back to the model.
+ public static Outcome Build(IReadOnlyList problems, bool looksTruncated)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ using var stream = assembly.GetManifestResourceStream(EmbeddedResourceName);
+ if (stream is null)
+ {
+ return Outcome.Failure(new OutcomeError(
+ $"Embedded resource {EmbeddedResourceName} is missing.", ErrorCodes.Internal, OutcomeErrorType.Problem));
+ }
+
+ using var reader = new StreamReader(stream);
+ var template = reader.ReadToEnd();
+
+ var problemLines = string.Join("\n", problems.Select(p => $"- {p.ToDisplayString()}"));
+ var truncationAdvice = looksTruncated
+ ? "Your response looks truncated. Resend it in SMALLER pieces (fewer commands per response)."
+ : string.Empty;
+
+ return Outcome.Successful(template
+ .Replace("{problems}", problemLines, StringComparison.Ordinal)
+ .Replace("{truncationAdvice}", truncationAdvice, StringComparison.Ordinal)
+ .TrimEnd());
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs
new file mode 100644
index 0000000..2d4eb98
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/ResultFormatter.cs
@@ -0,0 +1,86 @@
+using System.Globalization;
+using ImanSoftware.Extensions;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.KeylessAgent.Core.Implementation.Execution;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// Builds the [KEYLESSAGENT RESULTS] message: one block per result, each mapping
+/// output to the command's number, name and target, plus the skipped-commands note.
+///
+internal sealed class ResultFormatter : IResultFormatter
+{
+ ///
+ public string Format(int turn, int batch, IReadOnlyList results, int batchLimit, LimitsOptions limits)
+ {
+ var builder = new System.Text.StringBuilder();
+ builder.AppendLine(CultureInfo.InvariantCulture, $"[KEYLESSAGENT RESULTS] turn={turn} batch={batch}");
+
+ for (var i = 0; i < results.Count; i++)
+ {
+ var result = results[i];
+ builder.AppendLine(CultureInfo.InvariantCulture, $"--- result {i + 1}/{results.Count} | #{result.Number} {CommandName(result.Kind)} | {TrimTarget(result.Target)} ---");
+
+ switch (result.Status)
+ {
+ case CommandStatus.Skipped:
+ builder.AppendLine("status: skipped");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"reason: {result.ErrorMessage ?? "batch limit reached"}");
+ break;
+
+ case CommandStatus.Error:
+ builder.AppendLine("status: error");
+ builder.AppendLine(CultureInfo.InvariantCulture, $"reason: {result.ErrorMessage ?? "unknown error"}");
+ break;
+
+ default:
+ builder.AppendLine("status: ok");
+ break;
+ }
+
+ if (result.ExitCode.HasValue)
+ builder.Append("exit code: ").Append(result.ExitCode.Value).AppendLine(result.DurationSeconds.HasValue ? $" | duration: {result.DurationSeconds.Value:F1}s" : string.Empty);
+
+ if (!string.IsNullOrEmpty(result.Output))
+ {
+ // Caps count characters of the normalized text: CRLF files must not double-count.
+ var output = CapOutput(LineEndings.NormalizeToLf(result.Output), limits.MaxCommandOutputChars);
+ builder.AppendLine("<<>>");
+ }
+ }
+
+ var skipped = results.Count(r => r.Status == CommandStatus.Skipped);
+ if (skipped > 0)
+ builder.AppendLine(CultureInfo.InvariantCulture, $"--- skipped {skipped} commands: batch limit is {batchLimit} per response, resend them next ---");
+
+ builder.AppendLine("[END RESULTS]");
+ builder.Append("Continue with the next commands, or finish with @@done. Reply in AgentScript v1 only.");
+ return builder.ToString();
+ }
+
+ ///
+ /// Keeps the head and tail halves of the cap and marks the cut.
+ ///
+ internal static string CapOutput(string output, int maxChars)
+ {
+ if (output.Length <= maxChars)
+ return output.TrimEnd('\r', '\n');
+
+ var head = maxChars / 2;
+ var tail = maxChars - head;
+ var cut = output.Length - head - tail;
+ return $"{output[..head].TrimEnd()}\n[... {cut} characters truncated ...]\n{output[^tail..].TrimStart()}";
+ }
+
+ private static string CommandName(CommandKind kind)
+ => kind.GetDescription();
+
+ private static string TrimTarget(string target)
+ => target.NormalizeWhitespace().Truncate(80);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SecretRedactor.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SecretRedactor.cs
new file mode 100644
index 0000000..58c8103
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SecretRedactor.cs
@@ -0,0 +1,28 @@
+using System.Text.RegularExpressions;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// Redacts secret-looking values from command OUTPUT before it reaches the turn log:
+/// password/passwd/pwd/secret/token/apikey/api-key/authorization/bearer followed by
+/// : or = and a value become <key>=[redacted] — key names are kept,
+/// values are gone. Applied to the Output FIELD before serialization, never to serialized JSON.
+///
+internal static partial class SecretRedactor
+{
+ // The optional quotes around the key and the value keep JSON-shaped output structured
+ // ("ApiKey": "abc123" -> "ApiKey": "[redacted]"); an optional bearer/basic scheme word is
+ // consumed with the separator so "Authorization: Bearer " loses the whole credential;
+ // the value stops at whitespace, quote, semicolon or comma so `Password=x;Server=y` only
+ // loses the credential.
+ [GeneratedRegex(@"(?\b(?:password|passwd|pwd|secret|token|api[_-]?key|apikey|authorization|bearer)\b""?\s*[=:]\s*""?(?:(?:bearer|basic)\b\s*)?)[^\s"";,]+", RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)]
+ private static partial Regex SecretValueRegex();
+
+ public static string? Redact(string? text)
+ {
+ if (string.IsNullOrEmpty(text))
+ return text;
+
+ return SecretValueRegex().Replace(text, "${key}[redacted]");
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SessionStore.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SessionStore.cs
new file mode 100644
index 0000000..ad7b133
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SessionStore.cs
@@ -0,0 +1,117 @@
+using ImanSoftware.DateTimeProvider;
+using ImanSoftware.FileStorage;
+using ImanSoftware.KeylessAgent.Abstractions.Models;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.KeylessAgent.Core.Implementation.Configuration;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// Persists the agent session in .agent/session.json through IFileStorage.
+///
+internal sealed class SessionStore : ISessionStore
+{
+ private const string SessionFileName = @".agent\session.json";
+
+ private readonly IFileStorage _storage;
+ private readonly IDateTimeProvider _time;
+
+ public SessionStore()
+ : this(ImanFileStorageFactory.CreateStorage(), new DefaultDateTimeProvider())
+ {
+ }
+
+ internal SessionStore(IFileStorage storage, IDateTimeProvider time)
+ {
+ _storage = storage;
+ _time = time;
+ }
+
+ ///
+ public async Task> LoadAsync(string workspaceRoot)
+ {
+ var path = Path.Combine(workspaceRoot, SessionFileName);
+ if (!_storage.FileExists(path))
+ return NoSession();
+
+ var read = await _storage.ReadAllTextAsync(path);
+ if (read.IsFailure)
+ return Outcome.Failure(read.Error);
+
+ if (string.IsNullOrWhiteSpace(read.Data))
+ return NoSession();
+
+ // A corrupt session file must never crash the turn: treat it as "no session" and
+ // preserve the broken file under session.json.corrupt for inspection (best effort).
+ AgentSession? session;
+ var corrupt = false;
+ try
+ {
+ session = ConfigurationService.CreateSerializer().Deserialize(read.Data);
+ corrupt = session is null;
+ }
+ catch (Exception)
+ {
+ session = null;
+ corrupt = true;
+ }
+
+ if (corrupt)
+ {
+ await PreserveCorruptFileAsync(path, read.Data);
+ return NoSession();
+ }
+
+ return Outcome.Successful(session!);
+ }
+
+ /// Renames a broken session.json to session.json.corrupt (best effort, never throws).
+ private async Task PreserveCorruptFileAsync(string path, string content)
+ {
+ try
+ {
+ if (!_storage.FileExists(path))
+ return;
+
+ await _storage.WriteAllTextAsync(path + ".corrupt", content);
+ await _storage.DeleteAsync(path);
+ }
+ catch
+ {
+ // Preserving the broken file is best-effort; the session is treated as absent.
+ }
+ }
+
+ private static Outcome NoSession()
+ => Outcome.Failure(new OutcomeError(
+ "No saved session yet.", ErrorCodes.SessionNotFound, Outcomes.OutcomeErrorType.NotFound));
+
+ ///
+ public async Task SaveAsync(string workspaceRoot, AgentSession session)
+ {
+ var path = Path.Combine(workspaceRoot, SessionFileName);
+ var updated = session with
+ {
+ LastActivityUtc = _time.Now.ToUniversalTime().ToString("O"),
+ };
+
+ var mkdir = await _storage.CreateDirectoryAsync(Path.GetDirectoryName(path)!);
+ if (mkdir.IsFailure)
+ return mkdir;
+
+ var json = ConfigurationService.CreateSerializer().Serialize(updated);
+ return await _storage.WriteAllTextAsync(path, json);
+ }
+
+ ///
+ public Task ClearAsync(string workspaceRoot)
+ {
+ var path = Path.Combine(workspaceRoot, SessionFileName);
+ if (!_storage.FileExists(path))
+ return Task.FromResult(Outcome.Successful());
+
+ return _storage.DeleteAsync(path);
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs
new file mode 100644
index 0000000..dee0ca3
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/SystemPromptBuilder.cs
@@ -0,0 +1,43 @@
+using System.Reflection;
+using ImanSoftware.KeylessAgent.Abstractions.Models;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// Builds the system prompt from the embedded system-prompt.md , injecting the
+/// configured limits through {{PLACEHOLDER}} tokens.
+///
+internal static class SystemPromptBuilder
+{
+ private const string EmbeddedResourceName = "ImanSoftware.KeylessAgent.Core.Resources.system-prompt.md";
+
+ ///
+ /// Builds the system prompt for the given configuration.
+ ///
+ /// The effective configuration.
+ /// The prompt text, or a failure when the embedded resource is missing.
+ public static Outcome Build(KeylessOptions options)
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ using var stream = assembly.GetManifestResourceStream(EmbeddedResourceName);
+ if (stream is null)
+ {
+ return Outcome.Failure(new OutcomeError(
+ $"Embedded resource {EmbeddedResourceName} is missing.", ErrorCodes.Internal, OutcomeErrorType.Problem));
+ }
+
+ using var reader = new StreamReader(stream);
+ var template = reader.ReadToEnd();
+
+ return Outcome.Successful(template
+ .Replace("{{MAX_COMMANDS_PER_RESPONSE}}", options.Limits.MaxCommandsPerResponse.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal)
+ .Replace("{{MAX_RESPONSE_CHARS}}", options.Limits.MaxResponseChars.ToString("N0", System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal)
+ .Replace("{{MAX_FILE_CONTENT_CHARS}}", options.Limits.MaxFileContentChars.ToString("N0", System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal)
+ .Replace("{{MAX_COMMAND_OUTPUT_CHARS}}", options.Limits.MaxCommandOutputChars.ToString("N0", System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal)
+ .Replace("{{MAX_ITERATIONS_PER_TURN}}", options.Limits.MaxIterationsPerTurn.ToString(System.Globalization.CultureInfo.InvariantCulture), StringComparison.Ordinal)
+ .Replace("{{BRANCH_PREFIX}}", options.Git.BranchPrefix, StringComparison.Ordinal)
+ .Replace("{{PROTECTED_BRANCH_PATTERNS}}", string.Join(", ", options.Git.ProtectedBranchPatterns), StringComparison.Ordinal));
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/TurnLogger.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/TurnLogger.cs
new file mode 100644
index 0000000..eabcc0c
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Agent/TurnLogger.cs
@@ -0,0 +1,114 @@
+using ImanSoftware.DateTimeProvider;
+using ImanSoftware.FileStorage;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Execution;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.KeylessAgent.Core.Implementation.Configuration;
+using ImanSoftware.Logging;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Agent;
+
+///
+/// Writes the per-turn transcript as JSON lines to .agent/logs/turn-<n>.jsonl :
+/// every message sent, every reply received, every parsed command and every result.
+/// Never logs cookies, tokens or page HTML. Logging is best-effort: once a write fails
+/// (unwritable path, I/O error), logging is disabled for the rest of the turn with a
+/// single warning — a failing log never fails the turn.
+///
+internal sealed class TurnLogger
+{
+ private readonly IFileStorage _storage;
+ private readonly IDateTimeProvider _time;
+ private readonly Action? _warn;
+ private bool _disabled;
+
+ public TurnLogger(IFileStorage storage, IDateTimeProvider time, Action? warn = null)
+ {
+ _storage = storage;
+ _time = time;
+ _warn = warn;
+ }
+
+ internal string LogPath { get; private set; } = string.Empty;
+
+ ///
+ /// Starts a new log file for the turn.
+ ///
+ /// The workspace directory.
+ /// The 1-based turn number.
+ /// Cancellation token.
+ public async Task StartAsync(string workspaceRoot, int turn, CancellationToken cancellationToken = default)
+ {
+ var directory = Path.Combine(workspaceRoot, ".agent", "logs");
+ LogPath = Path.Combine(directory, $"turn-{turn}.jsonl");
+
+ try
+ {
+ var mkdir = await _storage.CreateDirectoryAsync(directory, cancellationToken);
+ if (mkdir.IsFailure)
+ Disable($"Turn log disabled for this turn: {mkdir.Error.Message}");
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Disable($"Turn log disabled for this turn: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Appends one event to the log.
+ ///
+ /// The event type (message-sent, reply-received, command, result, note).
+ /// The event payload object, serialized as JSON.
+ /// Cancellation token.
+ public async Task AppendAsync(string type, object payload, CancellationToken cancellationToken = default)
+ {
+ if (LogPath.Length == 0 || _disabled)
+ return;
+
+ var entry = new
+ {
+ TimestampUtc = _time.Now.ToUniversalTime().ToString("O"),
+ Type = type,
+ Payload = payload,
+ };
+
+ try
+ {
+ var json = ConfigurationService.CreateSerializer().Serialize(entry);
+ var append = await _storage.AppendAllTextAsync(LogPath, json + Environment.NewLine, cancellationToken);
+ if (append.IsFailure)
+ Disable($"Turn log disabled for this turn: {append.Error.Message}");
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ Disable($"Turn log disabled for this turn: {ex.Message}");
+ }
+ }
+
+ ///
+ /// Appends one command result to the log. The Output is redacted BEFORE serialization —
+ /// command output can contain credentials (connection strings, tokens) and the transcript
+ /// must not store them. Other payloads (requests, replies) are logged as received.
+ ///
+ /// The executed command's result.
+ /// Cancellation token.
+ public Task AppendResultAsync(CommandExecutionResult result, CancellationToken cancellationToken = default)
+ => AppendAsync("result", result with { Output = SecretRedactor.Redact(result.Output) }, cancellationToken);
+
+ /// Stops logging for the rest of the turn and warns exactly once.
+ private void Disable(string reason)
+ {
+ if (_disabled)
+ return;
+
+ _disabled = true;
+ _warn?.Invoke(reason);
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs
new file mode 100644
index 0000000..5f7735a
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserConnector.cs
@@ -0,0 +1,309 @@
+using System.Diagnostics;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.KeylessAgent.Abstractions.Models;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Enums;
+using ImanSoftware.Logging;
+using ImanSoftware.Outcomes;
+using Microsoft.Playwright;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// Establishes the browser connection with explicit, reported decisions:
+/// 1. probe the debug port → attach; 2. optionally auto-launch the REAL Chrome with a dedicated
+/// profile (mandatory since Chrome 136 — the default user-data-dir ignores the debug port);
+/// 3. only with an explicit opt-in, a Playwright-managed fallback browser. Never navigates the
+/// user's existing tabs; reuses an existing chat.deepseek.com tab or opens a new TAB; focuses it.
+///
+internal sealed class BrowserConnector : IBrowserConnector
+{
+ public const string ChatBaseUrl = "https://chat.deepseek.com/";
+
+ private readonly IBrowserEnvironment _environment;
+ private readonly ILoggerAdapter _log;
+
+ ///
+ /// Test seam: when set, replaces the Playwright tab-open/focus path.
+ ///
+ internal Func>>? OpenTabOverride { get; set; }
+
+ public BrowserConnector()
+ : this(new BrowserEnvironment(), null)
+ {
+ }
+
+ internal BrowserConnector(IBrowserEnvironment environment, ILoggerAdapter? log)
+ {
+ _environment = environment;
+ _log = log ?? new LoggerAdapter(Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance);
+ }
+
+ ///
+ public async Task> ConnectAsync(
+ BrowserOptions options,
+ IAgentUserInterface ui,
+ CancellationToken cancellationToken = default,
+ ITimingRecorder? timings = null)
+ {
+ try
+ {
+ var userDataDir = ResolveUserDataDir(options);
+ var playwright = await Playwright.CreateAsync();
+
+ // --- Step 1: probe the debug port and attach when it is already open ---
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ConnectingToBrowser,
+ $"Probing port {options.ChromeDebugPort}…"));
+ if (await _environment.IsDebugPortOpenAsync(options.ChromeDebugPort, cancellationToken))
+ {
+ var attach = await AttachOverCdpAsync(playwright, options, ui, cancellationToken);
+ if (attach.IsSuccess)
+ {
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ConnectingToBrowser,
+ $"Attached to Chrome on port {options.ChromeDebugPort}."));
+ return await OpenChatTabAsync(attach.Data, userDataDir, options, ui, timings, cancellationToken);
+ }
+
+ // The port answers but CDP cannot attach: report the reason, do not fall through.
+ _log.LogWarning("Port {Port} is open but CDP attach failed: {Reason}",
+ options.ChromeDebugPort, attach.Error.Message);
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ConnectingToBrowser,
+ $"Port {options.ChromeDebugPort} is open but attach failed: {attach.Error.Message}"));
+ return Outcome.Failure<(BrowserAttachment, object)>(new OutcomeError(
+ $"Port {options.ChromeDebugPort} is open but CDP attach failed: {attach.Error.Message}. " +
+ $"Close the Chrome that owns port {options.ChromeDebugPort} or change Browser.ChromeDebugPort.",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem));
+ }
+
+ // --- Step 2: auto-launch the real Chrome with the dedicated profile ---
+ if (options.AutoLaunchChrome)
+ {
+ var launch = await LaunchRealChromeAsync(playwright, options, userDataDir, ui, cancellationToken);
+ if (launch.IsSuccess)
+ {
+ return await OpenChatTabAsync(launch.Data, userDataDir, options, ui, timings, cancellationToken);
+ }
+
+ // Chrome not found / not started: actionable failure with the exact command line.
+ var commandLine = $"chrome --remote-debugging-port={options.ChromeDebugPort} " +
+ "--remote-debugging-address=127.0.0.1 " +
+ $"--user-data-dir=\"{userDataDir}\" --no-first-run --no-default-browser-check " +
+ "https://chat.deepseek.com/";
+ return Outcome.Failure<(BrowserAttachment, object)>(new OutcomeError(
+ $"Chrome could not be located or started ({launch.Error.Message}). " +
+ $"Start it manually once with:{Environment.NewLine} {commandLine}" +
+ $"{Environment.NewLine}or set Browser.ChromePath in keyless.json.",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem));
+ }
+
+ // --- Step 3: explicit opt-in Playwright fallback ---
+ if (options.AllowPlaywrightLaunch)
+ {
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ConnectingToBrowser,
+ "Starting a separate Playwright-managed browser (its own profile — not your Chrome)."));
+ var fallback = await playwright.Chromium.LaunchPersistentContextAsync(
+ userDataDir,
+ new BrowserTypeLaunchPersistentContextOptions { Headless = false });
+ var attachment = new BrowserAttachment
+ {
+ Context = fallback,
+ Kind = BrowserConnectionKind.PlaywrightLaunched,
+ LaunchedProcess = true,
+ UserDataDir = userDataDir,
+ };
+ var fallbackPage = fallback.Pages.Count > 0 ? fallback.Pages[0] : await fallback.NewPageAsync();
+ return Outcome.Successful((attachment, (object)fallbackPage));
+ }
+
+ return Outcome.Failure<(BrowserAttachment, object)>(new OutcomeError(
+ "The debug port is not open and Browser.AutoLaunchChrome is disabled. " +
+ "Start Chrome manually with the command line shown in the documentation, or enable AutoLaunchChrome.",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Problem));
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return Outcome.Failure<(BrowserAttachment, object)>(new OutcomeError(
+ $"Browser connection failed: {ex.GetType().Name}: {ex.Message}",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Failure));
+ }
+ }
+
+ internal static string ResolveUserDataDir(BrowserOptions options)
+ {
+ if (!string.IsNullOrWhiteSpace(options.UserDataDir))
+ return Path.GetFullPath(options.UserDataDir);
+
+ return Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
+ "KeylessAgent", "chrome-profile");
+ }
+
+ private async Task> AttachOverCdpAsync(
+ IPlaywright playwright, BrowserOptions options, IAgentUserInterface ui, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var (browser, context) = await _environment.AttachCdpAsync(playwright, options.ChromeDebugPort, cancellationToken);
+
+ return Outcome.Successful(new BrowserAttachment
+ {
+ Browser = browser,
+ Context = context,
+ Kind = BrowserConnectionKind.AttachedToRunningChrome,
+ LaunchedProcess = false,
+ UserDataDir = ResolveUserDataDir(options),
+ });
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return Outcome.Failure(new OutcomeError(
+ $"CDP attach failed: {ex.Message}",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Failure));
+ }
+ }
+
+ private async Task> LaunchRealChromeAsync(
+ IPlaywright playwright, BrowserOptions options, string userDataDir, IAgentUserInterface ui, CancellationToken cancellationToken)
+ {
+ var chromePath = await _environment.LocateChromeAsync(options.ChromePath, cancellationToken);
+ if (chromePath is null)
+ {
+ return Outcome.Failure(new OutcomeError(
+ "chrome.exe was not found (set Browser.ChromePath).",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.NotFound));
+ }
+
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ConnectingToBrowser,
+ $"Starting Chrome with the KeylessAgent profile ({userDataDir})…"));
+ var processId = await _environment.StartChromeAsync(chromePath, options.ChromeDebugPort, userDataDir, options.WindowMode, cancellationToken);
+
+ // Wait until /json/version answers (30s), then attach.
+ var deadline = DateTime.UtcNow.AddSeconds(30);
+ while (DateTime.UtcNow < deadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (await _environment.IsDebugPortOpenAsync(options.ChromeDebugPort, cancellationToken))
+ {
+ var attach = await AttachOverCdpAsync(playwright, options, ui, cancellationToken);
+ if (attach.IsSuccess)
+ {
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.ConnectingToBrowser,
+ "Started Chrome with the KeylessAgent profile. Log in to DeepSeek in that window once."));
+ return Outcome.Successful(attach.Data with
+ {
+ Kind = BrowserConnectionKind.AutoLaunchedChrome,
+ LaunchedProcess = true,
+ LaunchedProcessId = processId,
+ });
+ }
+ }
+
+ await Task.Delay(500, cancellationToken);
+ }
+
+ return Outcome.Failure(new OutcomeError(
+ $"Chrome was started (pid {processId}) but the debug port did not open within 30s.",
+ ErrorCodes.BrowserConnectionFailed, OutcomeErrorType.Failure));
+ }
+
+ ///
+ /// Picks an existing chat.deepseek.com tab (never navigating the user's other tabs) or
+ /// opens a new TAB in the existing window, then brings it to front.
+ ///
+ private async Task> OpenChatTabAsync(
+ BrowserAttachment attachment,
+ string userDataDir,
+ BrowserOptions options,
+ IAgentUserInterface ui,
+ ITimingRecorder? timings,
+ CancellationToken cancellationToken)
+ {
+ timings?.Start("tab-open");
+ try
+ {
+ if (OpenTabOverride is not null)
+ {
+ var pageOutcome = await OpenTabOverride(attachment, cancellationToken);
+ if (pageOutcome.IsFailure)
+ return Outcome.Failure<(BrowserAttachment, object)>(pageOutcome.Error);
+ return Outcome.Successful((attachment, pageOutcome.Data));
+ }
+
+ var context = (IBrowserContext)attachment.Context;
+ IPage page;
+
+ var existing = options.ReuseExistingTab
+ ? context.Pages.FirstOrDefault(p =>
+ p.Url.StartsWith(ChatBaseUrl, StringComparison.OrdinalIgnoreCase))
+ : null;
+
+ if (existing is not null)
+ {
+ page = existing;
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.OpeningChat, "Reusing the existing DeepSeek tab."));
+ }
+ else
+ {
+ // A browser-level CDP session opens a TAB in the existing window when one exists.
+ var anchorPage = context.Pages.Count > 0 ? context.Pages[0] : await context.NewPageAsync();
+ var openSession = await context.NewCDPSessionAsync(anchorPage);
+ await openSession.SendAsync("Target.createTarget",
+ new Dictionary { ["url"] = ChatBaseUrl, ["newWindow"] = false });
+ await openSession.DetachAsync();
+
+ page = await WaitForPageAsync(context, ChatBaseUrl, cancellationToken);
+
+ ui.ShowProgress(new AgentProgressEvent(
+ AgentEventType.OpeningChat, "Opened a new DeepSeek tab."));
+ }
+
+ // Normal operation: no focus, no restore — the window stays minimized.
+
+ return Outcome.Successful((attachment, (object)page));
+ }
+ finally
+ {
+ timings?.Stop("tab-open");
+ }
+ }
+
+ private static async Task WaitForPageAsync(IBrowserContext context, string urlPrefix, CancellationToken cancellationToken)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(15);
+ while (DateTime.UtcNow < deadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var match = context.Pages.FirstOrDefault(p =>
+ p.Url.StartsWith(urlPrefix, StringComparison.OrdinalIgnoreCase));
+ if (match is not null)
+ return match;
+
+ await Task.Delay(250, cancellationToken);
+ }
+
+ throw new TimeoutException("The new DeepSeek tab did not appear within 15s.");
+ }
+
+ private static async Task GetTargetIdAsync(ICDPSession session)
+ {
+ var response = await session.SendAsync("Target.getTargetInfo");
+ var element = (System.Text.Json.JsonElement)response;
+ return element.GetProperty("targetInfo").GetProperty("targetId").GetString() ?? string.Empty;
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs
new file mode 100644
index 0000000..df07ee4
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/BrowserEnvironment.cs
@@ -0,0 +1,155 @@
+using System.Diagnostics;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Configuration;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.Outcomes;
+using Microsoft.Playwright;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// Seam over OS/HTTP concerns of the browser connection: probe the debug port, locate
+/// chrome.exe, and start the real Chrome. Faked in the connector tests.
+///
+internal interface IBrowserEnvironment
+{
+ ///
+ /// Whether http://127.0.0.1:{port}/json/version answers.
+ ///
+ Task IsDebugPortOpenAsync(int port, CancellationToken cancellationToken = default);
+
+ ///
+ /// Locates chrome.exe: the configured path, then the registry App Paths, then the common
+ /// install locations. Returns null when Chrome cannot be found.
+ ///
+ Task LocateChromeAsync(string configuredPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// Starts the real Chrome with the dedicated profile and the debugging port.
+ ///
+ /// Minimized, Offscreen or Normal; unknown/empty defaults to Minimized.
+ /// The process id of the started Chrome.
+ Task StartChromeAsync(string chromePath, int port, string userDataDir, string windowMode, CancellationToken cancellationToken = default);
+
+ ///
+ /// Connects over CDP to the Chrome on the debug port. Playwright-typed on the real
+ /// implementation; faked in connector tests so no real browser is needed.
+ ///
+ Task<(object? Browser, object Context)> AttachCdpAsync(IPlaywright playwright, int port, CancellationToken cancellationToken = default);
+}
+
+///
+/// Real implementation: HttpClient probe, registry + file-system chrome lookup, Process.Start.
+///
+internal sealed class BrowserEnvironment : IBrowserEnvironment
+{
+ public async Task IsDebugPortOpenAsync(int port, CancellationToken cancellationToken = default)
+ {
+ // A closed port must read as "port closed" (false), never as cancellation: HttpClient's
+ // own timeout surfaces as TaskCanceledException (an OCE subclass) which the agent loop
+ // would misreport as "cancelled by the user". Only the CALLER's token cancels.
+ try
+ {
+ using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
+ timeoutCts.CancelAfter(TimeSpan.FromSeconds(2));
+ using var client = new HttpClient { Timeout = Timeout.InfiniteTimeSpan };
+ using var response = await client.GetAsync(
+ $"http://127.0.0.1:{port}/json/version", timeoutCts.Token);
+ return response.IsSuccessStatusCode;
+ }
+ catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
+ {
+ return false; // probe timeout: port closed or silently dropped
+ }
+ catch (OperationCanceledException)
+ {
+ throw; // the caller really cancelled
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ public Task LocateChromeAsync(string configuredPath, CancellationToken cancellationToken = default)
+ {
+ var candidates = new List();
+
+ if (!string.IsNullOrWhiteSpace(configuredPath))
+ candidates.Add(configuredPath);
+
+ // Registry: HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe
+ try
+ {
+ using var key = Microsoft.Win32.Registry.LocalMachine.OpenSubKey(
+ @"SOFTWARE\Microsoft\Windows\CurrentVersion\App Paths\chrome.exe");
+ if (key?.GetValue(null) is string registered && File.Exists(registered))
+ candidates.Add(registered);
+ }
+ catch
+ {
+ // Registry unavailable: fall through to the file-system locations.
+ }
+
+ var programFiles = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFiles);
+ var programFilesX86 = Environment.GetFolderPath(Environment.SpecialFolder.ProgramFilesX86);
+ var localAppData = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
+ candidates.Add(Path.Combine(programFiles, "Google", "Chrome", "Application", "chrome.exe"));
+ candidates.Add(Path.Combine(programFilesX86, "Google", "Chrome", "Application", "chrome.exe"));
+ candidates.Add(Path.Combine(localAppData, "Google", "Chrome", "Application", "chrome.exe"));
+
+ return Task.FromResult(candidates.FirstOrDefault(File.Exists));
+ }
+
+ public async Task<(object? Browser, object Context)> AttachCdpAsync(IPlaywright playwright, int port, CancellationToken cancellationToken = default)
+ {
+ var browser = await playwright.Chromium.ConnectOverCDPAsync(
+ $"http://127.0.0.1:{port}",
+ new BrowserTypeConnectOverCDPOptions { Timeout = 15_000 });
+
+ var contexts = browser.Contexts;
+ if (contexts.Count == 0)
+ {
+ throw new InvalidOperationException(
+ "The Chrome on the debug port exposes no browser context (is it still starting?).");
+ }
+
+ return (browser, contexts[0]);
+ }
+
+ public Task StartChromeAsync(string chromePath, int port, string userDataDir, string windowMode, CancellationToken cancellationToken = default)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = chromePath,
+ Arguments = ComposeChromeArguments(port, userDataDir, windowMode),
+ UseShellExecute = true,
+ };
+
+ using var process = Process.Start(startInfo);
+ return Task.FromResult(process?.Id ?? 0);
+ }
+
+ ///
+ /// Maps the window mode to its Chrome flag: Minimized → --start-minimized, Offscreen → a
+ /// window position far off-screen, Normal → no flag (never forced focus). Unknown or empty
+ /// modes default to Minimized, matching the configuration default.
+ ///
+ internal static string WindowModeFlag(string windowMode) => windowMode.Trim().ToLowerInvariant() switch
+ {
+ "normal" => "",
+ "offscreen" => "--window-position=-32000,-32000",
+ _ => "--start-minimized",
+ };
+
+ internal static string ComposeChromeArguments(int port, string userDataDir, string windowMode)
+ {
+ var windowFlag = WindowModeFlag(windowMode);
+ var windowPrefix = windowFlag.Length > 0 ? windowFlag + " " : string.Empty;
+ return windowPrefix +
+ $"--remote-debugging-port={port} --remote-debugging-address=127.0.0.1 " +
+ $"--user-data-dir=\"{userDataDir}\" --no-first-run --no-default-browser-check " +
+ "--disable-renderer-backgrounding --disable-background-timer-throttling " +
+ "--disable-backgrounding-occluded-windows " +
+ "https://chat.deepseek.com/";
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs
new file mode 100644
index 0000000..96b20ea
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/ChatDiagnosticsService.cs
@@ -0,0 +1,381 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Agent;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.Logging;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// The doctor report builder over an : classifies every
+/// selectors.json key as Matched / NotFound / NotApplicableNow, reports login, toggle and
+/// input state, and writes trimmed snapshots when requested. Never sends a message and never
+/// changes the chat.
+///
+internal sealed class ChatDiagnosticsService : IChatDiagnostics
+{
+ private readonly Func Dispose, IChatPage Page)>>> _openPage;
+
+ public ChatDiagnosticsService()
+ : this((session, ui, cancellationToken) => OpenRealPageAsync(session, ui, cancellationToken))
+ {
+ }
+
+ internal ChatDiagnosticsService(
+ Func, IChatPage)>>> openPage)
+ {
+ _openPage = openPage;
+ }
+
+ ///
+ /// The keys that must match for the doctor to exit 0. Conditional keys (Stop, chips,
+ /// Reasoning, FinalAnswer without messages, LoginIndicator) are never required.
+ ///
+ internal static readonly IReadOnlyList RequiredKeys =
+ [
+ "InputBox", "SendButton", "FileInput", "NewChatButton",
+ "MessageItems", "AssistantMessage", "DeepThinkToggle", "SearchToggle",
+ ];
+
+ /// Keys that legitimately cannot exist in some page states.
+ internal static readonly IReadOnlyList ConditionalKeys =
+ [
+ "StopIndicator", "AttachmentChips", "Reasoning", "FinalAnswer", "LoginIndicator",
+ ];
+
+ ///
+ public async Task> InspectAsync(
+ string workspaceRoot,
+ ImanSoftware.KeylessAgent.Abstractions.Models.Configuration.BrowserOptions browser,
+ string? captureDirectory,
+ bool watch,
+ CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ var selectors = await SelectorsCatalog.LoadAsync(workspaceRoot, browser.SelectorsFile, cancellationToken);
+ if (selectors.IsFailure)
+ return Outcome.Failure(selectors.Error);
+
+ var opened = await _openPage(new ImanSoftware.KeylessAgent.Abstractions.Models.Chat.ChatSessionOptions
+ {
+ Browser = browser,
+ WorkspaceRoot = workspaceRoot,
+ }, new DiagnosticsUi(), cancellationToken);
+ if (opened.IsFailure)
+ return Outcome.Failure(opened.Error);
+
+ var (inspector, dispose, chatPage) = opened.Data;
+ try
+ {
+ return await BuildReportAsync(inspector, selectors.Data, captureDirectory, watch, cancellationToken);
+ }
+ finally
+ {
+ await dispose();
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch (Exception ex)
+ {
+ return Outcome.Failure(new OutcomeError(ex));
+ }
+ }
+
+ private static async Task, IChatPage)>> OpenRealPageAsync(
+ ImanSoftware.KeylessAgent.Abstractions.Models.Chat.ChatSessionOptions session,
+ IAgentUserInterface ui,
+ CancellationToken cancellationToken)
+ {
+ var connector = new BrowserConnector();
+ var connection = await connector.ConnectAsync(session.Browser, ui, cancellationToken);
+ if (connection.IsFailure)
+ return Outcome.Failure<(IChatPageInspector, Func, IChatPage)>(connection.Error);
+
+ var (attachment, rawPage) = connection.Data;
+ var page = (Microsoft.Playwright.IPage)rawPage;
+
+ var selectors = await SelectorsCatalog.LoadAsync(session.WorkspaceRoot, session.Browser.SelectorsFile, cancellationToken);
+ if (selectors.IsFailure)
+ return Outcome.Failure<(IChatPageInspector, Func, IChatPage)>(selectors.Error);
+
+ var provider = new PlaywrightChatProvider();
+
+ IChatPage chatPage = new PlaywrightChatPage(
+ page, selectors.Data, session.Browser.Debug,
+ new ImanSoftware.Logging.LoggerAdapter(
+ Microsoft.Extensions.Logging.Abstractions.NullLoggerFactory.Instance));
+ await provider.WaitForLoginIfNeeded(chatPage, selectors.Data, session, cancellationToken);
+ await chatPage.PrepareReplyCaptureAsync(cancellationToken);
+
+ IChatPageInspector inspector = new PlaywrightChatInspector(page);
+ return Outcome.Successful((inspector, (Func)(() => DisposePageAsync(page)), chatPage));
+ }
+
+ /// Doctor closes only the tab it opened — the Chrome window stays.
+ private static async ValueTask DisposePageAsync(Microsoft.Playwright.IPage page)
+ {
+ try
+ {
+ await page.CloseAsync();
+ }
+ catch
+ {
+ // Closing must never crash diagnostics.
+ }
+ }
+
+ private static async ValueTask DisposeAsync(Microsoft.Playwright.IBrowser? browser, bool ownsBrowser)
+ {
+ try
+ {
+ if (ownsBrowser && browser is not null)
+ {
+ var contexts = browser.Contexts;
+ if (contexts is { Count: > 0 })
+ await contexts[0].CloseAsync();
+ }
+ else if (browser is not null)
+ {
+ await browser.CloseAsync();
+ }
+ }
+ catch
+ {
+ // Closing must never crash diagnostics.
+ }
+ }
+
+ internal static async Task BuildReportAsync(
+ IChatPageInspector inspector,
+ SelectorsCatalog catalog,
+ string? captureDirectory,
+ bool watch,
+ CancellationToken cancellationToken)
+ {
+ var captured = new List();
+ var login = await inspector.ProbeAsync(catalog.LoginIndicator, cancellationToken);
+ var loggedIn = login is null;
+
+ var checks = new List();
+ foreach (var (key, selectors) in SelectorEntries(catalog))
+ {
+ checks.Add(await ClassifyAsync(key, selectors, inspector, login, cancellationToken));
+ }
+
+ var deepThink = await inspector.GetToggleStateAsync(catalog.DeepThinkToggle, cancellationToken);
+ var search = await inspector.GetToggleStateAsync(catalog.SearchToggle, cancellationToken);
+ var inputEditable = await inspector.IsInputEditableAsync(catalog.InputBox, cancellationToken);
+
+ if (captureDirectory is not null)
+ {
+ captured.AddRange(await CaptureAsync(inspector, catalog, captureDirectory, cancellationToken));
+ }
+
+ if (watch)
+ {
+ captured.AddRange(await CaptureWatchAsync(inspector, catalog, captureDirectory ?? DefaultCaptureDir(), cancellationToken));
+ }
+
+ var allRequired = checks
+ .Where(c => RequiredKeys.Contains(c.Key))
+ .All(c => c.Status == SelectorCheckStatus.Matched);
+
+ return new ChatDiagnosticsReport(
+ inspector.Url,
+ loggedIn,
+ deepThink,
+ search,
+ inputEditable,
+ checks,
+ allRequired,
+ captured);
+ }
+
+ internal static IEnumerable<(string Key, string[] Selectors)> SelectorEntries(SelectorsCatalog catalog)
+ {
+ yield return ("InputBox", catalog.InputBox);
+ yield return ("SendButton", catalog.SendButton);
+ yield return ("FileInput", catalog.FileInput);
+ yield return ("NewChatButton", catalog.NewChatButton);
+ yield return ("MessageItems", catalog.MessageItems);
+ yield return ("AssistantMessage", catalog.AssistantMessage);
+ yield return ("FinalAnswer", catalog.FinalAnswer);
+ yield return ("Reasoning", catalog.Reasoning);
+ yield return ("StopIndicator", catalog.StopIndicator);
+ yield return ("MessageCopyButton", catalog.MessageCopyButton);
+ yield return ("DeepThinkToggle", catalog.DeepThinkToggle);
+ yield return ("SearchToggle", catalog.SearchToggle);
+ yield return ("AttachmentChips", catalog.AttachmentChips);
+ yield return ("LoginIndicator", catalog.LoginIndicator);
+ yield return ("ChatLimitNotice", catalog.ChatLimitNotice);
+ }
+
+ internal static async Task ClassifyAsync(
+ string key, string[] selectors, IChatPageInspector inspector,
+ (int Index, int Count)? login, CancellationToken cancellationToken)
+ {
+ if (key == "LoginIndicator")
+ {
+ return login is not null
+ ? new SelectorCheck(key, SelectorCheckStatus.Matched, login.Value.Index, login.Value.Count,
+ "Login page detected: log in manually; doctor does not proceed past this state.")
+ : new SelectorCheck(key, SelectorCheckStatus.NotApplicableNow, -1, 0,
+ "Not on the login page (the session looks logged in).");
+ }
+
+ var probe = await inspector.ProbeAsync(selectors, cancellationToken);
+ if (probe is not null)
+ {
+ var detail = key switch
+ {
+ "FinalAnswer" => "Final-answer element exists (a reply is present or in progress).",
+ "Reasoning" => "A reasoning block exists (a thinking message is present).",
+ "StopIndicator" => "Generating is in progress.",
+ "AttachmentChips" => "Attachment chips are visible (files attached).",
+ "MessageCopyButton" => "Message-level Copy control exists (hover the reply shows it).",
+ "ChatLimitNotice" => "The chat has reached its length limit (exhausted).",
+ _ => "Selector matched.",
+ };
+ return new SelectorCheck(key, SelectorCheckStatus.Matched, probe.Value.Index, probe.Value.Count, detail);
+ }
+
+ // Nothing matched: is that legitimate for the current state?
+ var notApplicable = key switch
+ {
+ "StopIndicator" => "Nothing is generating right now.",
+ "AttachmentChips" => "Nothing is attached right now.",
+ "MessageCopyButton" => "No assistant message is visible — the Copy button appears only on a reply.",
+ "Reasoning" => "No message with a reasoning block is visible right now.",
+ "FinalAnswer" => "No assistant message is visible right now (new chat).",
+ "LoginIndicator" => "Not on the login page.",
+ "ChatLimitNotice" => "The chat has not reached its length limit.",
+ _ => string.Empty,
+ };
+
+ return notApplicable.Length > 0
+ ? new SelectorCheck(key, SelectorCheckStatus.NotApplicableNow, -1, 0, notApplicable)
+ : new SelectorCheck(key, SelectorCheckStatus.NotFound, -1, 0, "No fallback selector matched — this key is required and must be fixed.");
+ }
+
+ internal static async Task> CaptureAsync(
+ IChatPageInspector inspector, SelectorsCatalog catalog, string directory, CancellationToken cancellationToken)
+ {
+ var files = new List();
+ Directory.CreateDirectory(directory);
+
+ async Task SaveAsync(string name, string? html)
+ {
+ if (string.IsNullOrEmpty(html))
+ return;
+ var path = Path.Combine(directory, name);
+ await File.WriteAllTextAsync(path, html, cancellationToken);
+ files.Add(path);
+ }
+
+ await SaveAsync("composer.html", await inspector.CaptureTrimmedAsync(catalog.InputBox, cancellationToken));
+ await SaveAsync("newest-message.html", await inspector.CaptureTrimmedAsync(catalog.AssistantMessage, cancellationToken));
+ await SaveAsync("sidebar.html", await inspector.CaptureTrimmedAsync(catalog.NewChatButton, cancellationToken));
+ await SaveAsync("attachment-chips.html", await inspector.CaptureTrimmedAsync(catalog.AttachmentChips, cancellationToken));
+ return files;
+ }
+
+ ///
+ /// --watch: waits for the user to send a prompt manually, then captures the generating
+ /// state (stop indicator) and the finished state once each.
+ ///
+ internal static async Task> CaptureWatchAsync(
+ IChatPageInspector inspector,
+ SelectorsCatalog catalog,
+ string directory,
+ CancellationToken cancellationToken)
+ {
+ var files = new List();
+ Directory.CreateDirectory(directory);
+
+ var deadline = DateTime.UtcNow.AddMinutes(10);
+ while (DateTime.UtcNow < deadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var generating = await inspector.ProbeAsync(catalog.StopIndicator, cancellationToken);
+ if (generating is not null)
+ break;
+ await Task.Delay(500, cancellationToken);
+ }
+
+ var stopHtml = await inspector.CaptureTrimmedAsync(catalog.StopIndicator, cancellationToken);
+ if (!string.IsNullOrEmpty(stopHtml))
+ {
+ var path = Path.Combine(directory, "watch-generating-stop.html");
+ await File.WriteAllTextAsync(path, stopHtml, cancellationToken);
+ files.Add(path);
+ }
+
+ var newestDuringGenerating = await inspector.CaptureTrimmedAsync(catalog.AssistantMessage, cancellationToken);
+ if (!string.IsNullOrEmpty(newestDuringGenerating))
+ {
+ var path = Path.Combine(directory, "watch-generating-message.html");
+ await File.WriteAllTextAsync(path, newestDuringGenerating, cancellationToken);
+ files.Add(path);
+ }
+
+ // Wait for the finished state: stop indicator gone.
+ while (DateTime.UtcNow < deadline)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ if (await inspector.ProbeAsync(catalog.StopIndicator, cancellationToken) is null)
+ break;
+ await Task.Delay(500, cancellationToken);
+ }
+
+ var finished = await inspector.CaptureTrimmedAsync(catalog.AssistantMessage, cancellationToken);
+ if (!string.IsNullOrEmpty(finished))
+ {
+ var path = Path.Combine(directory, "watch-finished-message.html");
+ await File.WriteAllTextAsync(path, finished, cancellationToken);
+ files.Add(path);
+ }
+
+ return files;
+ }
+
+ private static string DefaultCaptureDir()
+ => Path.Combine(Path.GetTempPath(), "keylessagent-capture");
+
+ ///
+ /// The connector reports its decisions; doctor surfaces them as progress lines via the
+ /// same channel the agent loop uses.
+ ///
+ private sealed class DiagnosticsUi : IAgentUserInterface
+ {
+ public void Say(string message)
+ {
+ }
+
+ public void ShowProgress(AgentProgressEvent progressEvent)
+ => Console.WriteLine($"[{progressEvent.Type}] {progressEvent.Detail}");
+
+ public Task ConfirmAsync(ConfirmRequest request) => Task.FromResult(true);
+
+ public Task AskAsync(string question) => Task.FromResult(string.Empty);
+
+ public void ShowDiff(FileChange change)
+ {
+ }
+
+ public IDisposable BeginWait(string message) => new NopScope();
+
+ public Task ChooseAsync(ChoiceRequest request, CancellationToken cancellationToken = default)
+ => Task.FromResult(ChoiceResult.CancelledMenu());
+
+ private sealed class NopScope : IDisposable
+ {
+ public void Dispose()
+ {
+ }
+ }
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/FakeChatProvider.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/FakeChatProvider.cs
new file mode 100644
index 0000000..3221329
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/FakeChatProvider.cs
@@ -0,0 +1,77 @@
+using ImanSoftware.KeylessAgent.Abstractions.Models.Chat;
+using ImanSoftware.KeylessAgent.Abstractions.Models.Snapshot;
+using ImanSoftware.KeylessAgent.Abstractions.Services;
+using ImanSoftware.Outcomes;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// A scripted chat provider for tests, the Playground, and dry runs:
+/// replies come from a queue set by the caller; everything the agent sends is recorded.
+///
+internal sealed class FakeChatProvider : IChatProvider
+{
+ private readonly Queue _replies;
+
+ public FakeChatProvider(params string[] replies)
+ {
+ _replies = new Queue(replies);
+ }
+
+ ///
+ /// Replies left in the queue.
+ ///
+ public int RemainingReplies => _replies.Count;
+
+ ///
+ /// How many chat sessions this provider has started; each session gets a distinct ChatUrl.
+ ///
+ public int StartCount { get; private set; }
+
+ ///
+ /// Messages sent per session, indexed by session number minus one.
+ ///
+ public List MessagesPerSession { get; } = [];
+
+ ///
+ /// Everything sent to the fake chat, in order.
+ ///
+ public List Sent { get; } = [];
+
+ public Task> StartSessionAsync(ChatSessionOptions options, CancellationToken cancellationToken = default)
+ {
+ StartCount++;
+ MessagesPerSession.Add(0);
+ IChatSession session = new FakeChatSession(this, StartCount, $"https://chat.deepseek.com/a/fake-session-{StartCount}");
+ return Task.FromResult(Outcome.Successful(session));
+ }
+
+ internal sealed record SentMessage(string Text, IReadOnlyList AttachmentNames);
+
+ private sealed class FakeChatSession(FakeChatProvider owner, int sessionIndex, string url) : IChatSession
+ {
+ public string ChatUrl => url;
+
+ public string LastReplyOrigin => "Dom";
+
+ public Task SendMessageAsync(string message, IReadOnlyList attachments, CancellationToken cancellationToken = default)
+ {
+ owner.MessagesPerSession[sessionIndex - 1]++;
+ owner.Sent.Add(new SentMessage(message, attachments.Select(a => a.Name).ToList()));
+ return Task.FromResult(Outcome.Successful());
+ }
+
+ public Task> WaitForReplyAsync(CancellationToken cancellationToken = default)
+ {
+ if (owner._replies.Count == 0)
+ {
+ return Task.FromResult(Outcome.Failure(new OutcomeError(
+ "The fake chat has no more scripted replies.", "KA_REPLY_TIMEOUT", OutcomeErrorType.Failure)));
+ }
+
+ return Task.FromResult(Outcome.Successful(owner._replies.Dequeue()));
+ }
+
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/HtmlTrimmer.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/HtmlTrimmer.cs
new file mode 100644
index 0000000..228a1da
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/HtmlTrimmer.cs
@@ -0,0 +1,128 @@
+using System.Text.RegularExpressions;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// Diagnostics-only HTML sanitizing for keyless doctor --capture : removes scripts,
+/// styles and inline svg, masks every text node except an allowlist of UI labels, and masks
+/// attribute values that could identify content or the user. The sidebar (chat history
+/// titles), file names and the user's own messages must never appear in a capture.
+///
+internal static partial class HtmlTrimmer
+{
+ /// UI labels that are safe to keep verbatim.
+ internal static readonly IReadOnlyList AllowedLabels =
+ [
+ "DeepThink", "Search", "New chat", "Stop", "Message DeepSeek",
+ "Thought for", "seconds", "Copy", "Download", "Regenerate",
+ ];
+
+ /// Attributes that never carry content or identity.
+ private static readonly HashSet StructuralAttributes = new(StringComparer.OrdinalIgnoreCase)
+ {
+ "class", "role", "aria-pressed", "aria-disabled", "aria-expanded",
+ "type", "accept", "multiple", "tabindex", "rows", "autocomplete", "name", "id",
+ };
+
+ private static readonly Regex ScriptRegex = Compile("");
+ private static readonly Regex StyleRegex = Compile("");
+ private static readonly Regex SvgRegex = Compile("]*>.*? ");
+ private static readonly Regex CommentRegex = Compile("");
+ private static readonly Regex AttributeRegex = Compile("([a-zA-Z-]+)\\s*=\\s*(\"[^\"]*\"|'[^']*')");
+ private static readonly Regex TextRunRegex = Compile(">(?[^<>]+)<");
+ private static readonly Regex LongRunRegex = Compile(">[^<>]{200,}<");
+
+ [GeneratedRegex("Thought for \\d+")]
+ private static partial Regex ThoughtForRegex();
+
+ private static Regex Compile(string pattern)
+ => new(pattern, RegexOptions.Singleline, TimeSpan.FromSeconds(1));
+
+ ///
+ /// Sanitizes an HTML fragment for bug reports: structural attributes survive, everything
+ /// personal is masked, and the result stays well-formed HTML.
+ ///
+ /// Raw HTML.
+ /// Sanitized HTML, or an empty string for empty input.
+ public static string Trim(string html)
+ {
+ if (string.IsNullOrEmpty(html))
+ return string.Empty;
+
+ var text = ScriptRegex.Replace(html, "");
+ text = StyleRegex.Replace(text, "");
+ text = SvgRegex.Replace(text, "");
+ text = CommentRegex.Replace(text, string.Empty);
+
+ // Very long runs are cut before attribute masking (defensive; masked again below).
+ text = LongRunRegex.Replace(text, ">...<");
+
+ text = AttributeRegex.Replace(text, MaskAttribute);
+ text = TextRunRegex.Replace(text, MaskTextRun);
+ return text;
+ }
+
+ private static string MaskAttribute(Match match)
+ {
+ var name = match.Groups[1].Value;
+ var rawValue = match.Groups[2].Value;
+ var inner = rawValue[1..^1];
+
+ if (StructuralAttributes.Contains(name))
+ return match.Value;
+
+ // data-virtual-list-item-key survives structurally, but the key itself is an
+ // identifier — replace its value with a short deterministic marker.
+ if (name.Equals("data-virtual-list-item-key", StringComparison.OrdinalIgnoreCase))
+ return $"{name}=\"[key:{StableHash(inner)}]\"";
+
+ // Everything else (href, src, title, alt, value, placeholder, aria-label, data-*)
+ // is masked unless it is exactly a short allowlisted UI label.
+ return IsAllowedLabel(inner) ? match.Value : $"{name}=\"[masked]\"";
+ }
+
+ private static string MaskTextRun(Match match)
+ {
+ var text = match.Groups["text"].Value;
+ return ">" + MaskText(text) + "<";
+ }
+
+ private static string MaskText(string text)
+ {
+ var trimmed = text.Trim();
+ if (trimmed.Length == 0)
+ return text;
+
+ // "Thought for N seconds" is a UI label pattern, not content.
+ if (ThoughtForRegex().IsMatch(trimmed) && trimmed.StartsWith("Thought for", StringComparison.Ordinal))
+ return trimmed;
+
+ return IsAllowedLabel(trimmed) ? trimmed : "[masked]";
+ }
+
+ private static bool IsAllowedLabel(string value)
+ {
+ var trimmed = value.Trim();
+ if (trimmed.Length > 40)
+ return false;
+
+ foreach (var label in AllowedLabels)
+ {
+ if (trimmed.Equals(label, StringComparison.Ordinal))
+ return true;
+ }
+
+ return false;
+ }
+
+ private static long StableHash(string value)
+ {
+ unchecked
+ {
+ var hash = 17L;
+ foreach (var c in value)
+ hash = (hash * 31) + c;
+ return hash;
+ }
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs
new file mode 100644
index 0000000..d38cfc6
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPage.cs
@@ -0,0 +1,146 @@
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// A fingerprint of the newest assistant message, captured before every send so the reply
+/// wait can tell the new reply apart from the previous one. The list is virtualized, so the
+/// visible-count alone is never enough: the message key is the primary identity, with a
+/// hash of the final-answer text as fallback.
+///
+/// The data-virtual-list-item-key of the newest assistant message, when present.
+/// A stable hash of the newest final-answer text (0 when there is none).
+/// How many assistant messages are currently in the DOM.
+internal sealed record AssistantStamp(string? Key, int TextHash, int VisibleCount)
+{
+ ///
+ /// Whether is a different, later message than this one.
+ /// When both carry message keys, only keys are compared (identical reply text is valid
+ /// only when the key changed); otherwise the text hash and visible count decide.
+ ///
+ public bool IsDifferentFrom(AssistantStamp? other)
+ {
+ if (other is null)
+ return true;
+
+ if (Key is not null && other.Key is not null)
+ return !string.Equals(Key, other.Key, StringComparison.Ordinal);
+
+ return TextHash != other.TextHash || VisibleCount != other.VisibleCount;
+ }
+}
+
+///
+/// The page operations the chat session needs — the seam that makes the reply-waiting logic
+/// unit-testable against a fake page. All operations are async and accept a CancellationToken
+/// so Ctrl+C interrupts Playwright waits.
+///
+internal interface IChatPage
+{
+ /// The current chat URL.
+ string Url { get; }
+
+ /// Fingerprint of the newest assistant message (key, text hash, visible count).
+ Task GetNewestAssistantStampAsync(CancellationToken cancellationToken = default);
+
+ /// The outer HTML of the newest assistant message, or null when there is none.
+ Task GetNewestAssistantHtmlAsync(CancellationToken cancellationToken = default);
+
+ /// How many message items (user + assistant) are currently visible in the list.
+ Task GetMessageItemCountAsync(CancellationToken cancellationToken = default);
+
+ /// Whether the stop/generating indicator is currently visible.
+ Task IsStopIndicatorVisibleAsync(CancellationToken cancellationToken = default);
+
+ /// Whether the input box is empty (the text was consumed by the send).
+ Task IsInputClearedAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// The send control state: true/false when a send button matched (enabled/disabled),
+ /// null when no send-control selector matched at all (undetectable — callers must not block on it).
+ ///
+ Task IsSendEnabledAsync(CancellationToken cancellationToken = default);
+
+ /// Whether uploaded-file chips are visible (the upload has landed in the UI).
+ Task AreAttachmentChipsVisibleAsync(CancellationToken cancellationToken = default);
+
+ /// The file names the hidden file input currently reports (the upload was registered).
+ Task> GetInputFileNamesAsync(CancellationToken cancellationToken = default);
+
+ /// Whether an element near the composer (a chip or its label) shows the given file name.
+ Task IsFileNameVisibleAsync(string fileName, CancellationToken cancellationToken = default);
+
+ /// Inserts the whole text into the input at once — never per-character typing.
+ Task InsertTextAsync(string text, CancellationToken cancellationToken = default);
+
+ /// Clicks the send button; false when the button cannot be clicked.
+ Task TryClickSendAsync(CancellationToken cancellationToken = default);
+
+ /// Presses Enter in the input box.
+ Task PressEnterAsync(CancellationToken cancellationToken = default);
+
+ /// Uploads files through the chat's file input; false when the input cannot be used.
+ Task TryUploadFilesAsync(IReadOnlyList filePaths, CancellationToken cancellationToken = default);
+
+ /// Saves a debug screenshot to the given absolute path (best-effort; may be a no-op).
+ Task SaveDebugScreenshotAsync(string fullPath, CancellationToken cancellationToken = default);
+
+ ///
+ /// The rendered text of the newest assistant message's reasoning block
+ /// (.ds-think-content), or null when there is none.
+ ///
+ Task GetNewestReasoningTextAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Hovers the newest assistant message and waits for its message-level Copy control.
+ /// False when the control does not appear in time (the bar may be hover-only).
+ ///
+ Task WaitForMessageCopyButtonAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
+
+ ///
+ /// Installs the clipboard interception (wraps navigator.clipboard.writeText and the
+ /// execCommand('copy') path) gated on an agent-only flag; the user's manual copying is
+ /// never affected and the OS clipboard is never written by interception.
+ ///
+ Task PrepareReplyCaptureAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Captures the newest message's copied final answer: hover, click its Copy control,
+ /// prefer the intercepted text; when nothing is intercepted within the timeout, grant
+ /// clipboard-read/write, save the current OS clipboard text, read
+ /// navigator.clipboard.readText() and ALWAYS restore the previous text. Null when both
+ /// strategies fail.
+ ///
+ Task TryCaptureCopiedReplyAsync(TimeSpan timeout, CancellationToken cancellationToken = default);
+
+ ///
+ /// The aria-pressed state of the first visible toggle matching one of the selectors
+ /// ("true"/"false"/other raw attribute text), or null when no selector matched at all.
+ ///
+ Task GetToggleStateAsync(string[] selectors, CancellationToken cancellationToken = default);
+
+ ///
+ /// Clicks the first visible toggle matching one of the selectors; true when a click landed.
+ ///
+ Task ClickToggleAsync(string[] selectors, CancellationToken cancellationToken = default);
+
+ ///
+ /// Whether a chat-length-limit notice is currently visible (the chat is exhausted and
+ /// cannot take more messages).
+ ///
+ Task IsChatLimitNoticeVisibleAsync(CancellationToken cancellationToken = default);
+
+ ///
+ /// Instant presence+visibility check across the fallback list in ONE round trip (no
+ /// waiting) — for "is it there right now" questions: polling loops, login probes, notices.
+ ///
+ Task IsAnyVisibleAsync(string[] selectors, CancellationToken cancellationToken = default);
+
+ ///
+ /// Waits ONCE (single parallel wait over the whole fallback list, one timeout) for any
+ /// selector to become visible and returns the matched fallback index — null on timeout.
+ /// Used for the positive-first login probe (input OR login indicator race).
+ ///
+ Task WaitForAnyVisibleAsync(string[] selectors, int timeoutMs, CancellationToken cancellationToken = default);
+
+ /// Brings the tab to the front so the user can act on it (best-effort).
+ Task FocusTabAsync(CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs
new file mode 100644
index 0000000..544516b
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/IChatPageInspector.cs
@@ -0,0 +1,36 @@
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// The probing seam behind keyless doctor : checks selector fallback lists against the
+/// live page and captures trimmed snapshots. Implemented by Playwright (real) and faked in tests.
+///
+internal interface IChatPageInspector
+{
+ /// The inspected page URL.
+ string Url { get; }
+
+ ///
+ /// Probes an ordered fallback list.
+ ///
+ /// The fallback list (first match wins).
+ /// Cancellation token.
+ /// The matching fallback index and the element count, or null when nothing matched.
+ Task<(int Index, int Count)?> ProbeAsync(string[] selectors, CancellationToken cancellationToken = default);
+
+ ///
+ /// The aria-pressed value of the first element matched by the fallback list, or null.
+ ///
+ Task GetToggleStateAsync(string[] selectors, CancellationToken cancellationToken = default);
+
+ ///
+ /// Whether the input box exists and is editable, probed through the given fallback list
+ /// (usually InputBox from the catalog).
+ ///
+ Task IsInputEditableAsync(string[] selectors, CancellationToken cancellationToken = default);
+
+ ///
+ /// Captures a trimmed outerHTML snapshot of the first element matched by the fallback list
+ /// (scripts, styles, svg removed, personal text masked), or null when nothing matched.
+ ///
+ Task CaptureTrimmedAsync(string[] selectors, CancellationToken cancellationToken = default);
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs
new file mode 100644
index 0000000..c05b3de
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatInspector.cs
@@ -0,0 +1,139 @@
+using Microsoft.Playwright;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// The Playwright-backed page inspector behind keyless doctor . Fully async; Playwright
+/// waits race the CancellationToken through .
+///
+internal sealed class PlaywrightChatInspector(IPage page) : IChatPageInspector
+{
+ private readonly IPage _page = page;
+
+ ///
+ public string Url => _page.Url;
+
+ ///
+ public async Task<(int Index, int Count)?> ProbeAsync(string[] selectors, CancellationToken cancellationToken = default)
+ {
+ for (var index = 0; index < selectors.Length; index++)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ try
+ {
+ var count = await WithCancellationAsync(_page.Locator(selectors[index]).CountAsync(), cancellationToken);
+ if (count > 0)
+ return (index, (int)count);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // A broken selector is a finding, not a crash: try the next fallback.
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ public async Task GetToggleStateAsync(string[] selectors, CancellationToken cancellationToken = default)
+ {
+ var probe = await ProbeAsync(selectors, cancellationToken);
+ if (probe is null)
+ return null;
+
+ try
+ {
+ var value = await WithCancellationAsync(
+ _page.Locator(selectors[probe.Value.Index]).First.GetAttributeAsync("aria-pressed"),
+ cancellationToken);
+ return value?.ToString();
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ ///
+ public async Task IsInputEditableAsync(string[] selectors, CancellationToken cancellationToken = default)
+ {
+ if (selectors.Length == 0)
+ return false;
+
+ // ONE parallel wait over the whole fallback list (the 2s budget once, never once per
+ // fallback selector) via the shared Or-locator helper.
+ var match = await PlaywrightChatPage.FirstVisibleAsync(
+ _page, selectors, 2_000 * selectors.Length, cancellationToken);
+ if (match is null)
+ return false;
+
+ var readOnly = await EvaluateBoolAsync(match.Value.Locator, "element => element.readOnly", cancellationToken);
+ var disabled = await EvaluateBoolAsync(match.Value.Locator, "element => element.disabled", cancellationToken);
+ return !readOnly && !disabled;
+ }
+
+ ///
+ public async Task CaptureTrimmedAsync(string[] selectors, CancellationToken cancellationToken = default)
+ {
+ var probe = await ProbeAsync(selectors, cancellationToken);
+ if (probe is null)
+ return null;
+
+ try
+ {
+ var html = await WithCancellationAsync(
+ _page.Locator(selectors[probe.Value.Index]).First.EvaluateAsync("element => element.outerHTML"),
+ cancellationToken);
+ return HtmlTrimmer.Trim(html?.ToString() ?? string.Empty);
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private static async Task EvaluateBoolAsync(ILocator locator, string script, CancellationToken cancellationToken)
+ {
+ try
+ {
+ var result = await WithCancellationAsync(locator.EvaluateAsync(script), cancellationToken);
+ return result is System.Text.Json.JsonElement { ValueKind: System.Text.Json.JsonValueKind.True };
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ return false;
+ }
+ }
+
+ ///
+ /// Playwright's waits carry their own timeouts but no CancellationToken; racing the token
+ /// makes Ctrl+C interrupt any in-flight wait promptly.
+ ///
+ private static async Task WithCancellationAsync(Task task, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var cancellation = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ await using var registration = cancellationToken.Register(() => cancellation.TrySetResult(true));
+ var completed = await Task.WhenAny(task, cancellation.Task);
+ if (completed != task)
+ throw new OperationCanceledException(cancellationToken);
+
+ return await task;
+ }
+}
diff --git a/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs
new file mode 100644
index 0000000..be7cb4e
--- /dev/null
+++ b/src/Core/ImanSoftware.KeylessAgent.Core/Implementation/Browser/PlaywrightChatPage.cs
@@ -0,0 +1,872 @@
+using ImanSoftware.Logging;
+using Microsoft.Playwright;
+
+namespace ImanSoftware.KeylessAgent.Core.Implementation.Browser;
+
+///
+/// The Playwright-backed IChatPage: talks to the real chat DOM through the selector catalog,
+/// preferring structural selectors over hashed class names. Playwright's own waits do not take
+/// a CancellationToken, so every operation races the token — Ctrl+C interrupts promptly.
+///
+internal sealed class PlaywrightChatPage : IChatPage
+{
+ private readonly IPage _page;
+ private readonly SelectorsCatalog _selectors;
+ private readonly bool _debug;
+ private readonly ILoggerAdapter _log;
+
+ public PlaywrightChatPage(IPage page, SelectorsCatalog selectors, bool debug, ILoggerAdapter log)
+ {
+ _page = page;
+ _selectors = selectors;
+ _debug = debug;
+ _log = log;
+ }
+
+ ///
+ public string Url => _page.Url;
+
+ ///
+ public async Task GetNewestAssistantStampAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var newest = await GetNewestAssistantLocatorAsync(cancellationToken);
+ if (newest is null)
+ return null;
+
+ var key = await EvaluateAsync(newest,
+ "element => element.closest('[data-virtual-list-item-key]')?.getAttribute('data-virtual-list-item-key') ?? element.getAttribute('data-virtual-list-item-key')", cancellationToken);
+ var text = await EvaluateAsync(newest,
+ "element => { const final = element.querySelector('.ds-markdown.ds-assistant-message-main-content'); return final ? final.innerText : ''; }", cancellationToken);
+ var count = await CountAssistantMessagesAsync(cancellationToken);
+
+ return new AssistantStamp(
+ Key: string.IsNullOrWhiteSpace(key) ? null : key.Trim(),
+ TextHash: text is null ? 0 : StableHash(text),
+ VisibleCount: count);
+ }
+
+ ///
+ public async Task GetNewestAssistantHtmlAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ var newest = await GetNewestAssistantLocatorAsync(cancellationToken);
+ if (newest is null)
+ return null;
+
+ var html = await EvaluateAsync(newest, "element => element.outerHTML", cancellationToken);
+ return string.IsNullOrEmpty(html) ? null : html;
+ }
+
+ ///
+ public async Task GetMessageItemCountAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ for (var index = 0; index < _selectors.MessageItems.Length; index++)
+ {
+ var selector = _selectors.MessageItems[index];
+ try
+ {
+ _log.LogDebug("MessageItems matched selector #{Index}: {Selector}", index, selector);
+ return (int)await WithCancellationAsync(_page.Locator(selector).CountAsync(), cancellationToken);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // The virtualized list re-renders constantly; try the next fallback selector.
+ }
+ }
+
+ return 0;
+ }
+
+ ///
+ public async Task IsStopIndicatorVisibleAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ // Instant check: polling loops call this every ~100 ms, so an absent Stop button
+ // must cost one round trip, never a per-selector wait.
+ return await IsAnyVisibleAsync(_page, _selectors.StopIndicator, cancellationToken);
+ }
+
+ ///
+ public async Task IsInputClearedAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ foreach (var selector in _selectors.InputBox)
+ {
+ try
+ {
+ var value = await EvaluateAsync(_page.Locator(selector).First, "element => element.value ?? ''", cancellationToken);
+ return string.IsNullOrEmpty(value);
+ }
+ catch (Exception ex) when (ex is not OperationCanceledException)
+ {
+ // Try the next fallback selector.
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ public async Task IsSendEnabledAsync(CancellationToken cancellationToken = default)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ foreach (var selector in _selectors.SendButton)
+ {
+ try
+ {
+ var locator = _page.Locator(selector).First;
+
+ // Instant probe: absent elements cost one Evaluate round trip, never a wait.
+ // The disable state crosses as a JSON string so no bool/string mangling can
+ // flip the result (a raw bool would ToString as "True" and compare unequal).
+ // The disable-value assert is manual via `keyless doctor` (the unit tests run
+ // against FakeChatPage, which returns the bool directly).
+ var disabledJson = await EvaluateAsync(locator,
+ "element => JSON.stringify({ d: element.getAttribute('aria-disabled') === 'true' || String(element.className).includes('ds-button--disabled') })",
+ cancellationToken);
+ if (disabledJson is null)
+ continue; // selector matched nothing; try the next fallback.
+
+ var isDisabled = IsDisabledFromJson(disabledJson);
+ return !isDisabled;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Try the next fallback selector.
+ }
+ }
+
+ // No send-control selector matched: undetectable. Callers must not block on it —
+ // Enter is the fallback anyway.
+ return null;
+ }
+
+ ///
+ public async Task GetToggleStateAsync(string[] selectors, CancellationToken cancellationToken = default)
+ {
+ foreach (var selector in selectors)
+ {
+ // Instant probe: one round trip per fallback, no waiting.
+ var state = await EvaluateAsync(_page.Locator(selector).First,
+ "element => element.getAttribute('aria-pressed')", cancellationToken);
+ if (state is not null)
+ return state;
+ }
+
+ return null;
+ }
+
+ ///
+ public async Task ClickToggleAsync(string[] selectors, CancellationToken cancellationToken = default)
+ {
+ foreach (var selector in selectors)
+ {
+ try
+ {
+ var locator = _page.Locator(selector).First;
+ await locator.ClickAsync(new LocatorClickOptions { Timeout = 3_000 });
+ return true;
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Try the next fallback selector.
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ public async Task IsChatLimitNoticeVisibleAsync(CancellationToken cancellationToken = default)
+ => await IsAnyVisibleAsync(_page, _selectors.ChatLimitNotice, cancellationToken);
+
+ ///
+ public async Task IsAnyVisibleAsync(string[] selectors, CancellationToken cancellationToken = default)
+ => await IsAnyVisibleAsync(_page, selectors, cancellationToken);
+
+ ///
+ public async Task WaitForAnyVisibleAsync(string[] selectors, int timeoutMs, CancellationToken cancellationToken = default)
+ {
+ var winner = await FirstVisibleAsync(_page, selectors, timeoutMs, cancellationToken);
+ return winner?.Index;
+ }
+
+ private static async Task GetTargetIdAsync(ICDPSession session)
+ {
+ var response = await session.SendAsync("Target.getTargetInfo");
+ var element = (System.Text.Json.JsonElement)response;
+ return element.GetProperty("targetInfo").GetProperty("targetId").GetString() ?? string.Empty;
+ }
+
+ ///
+ public async Task FocusTabAsync(CancellationToken cancellationToken = default)
+ {
+ try
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+ await WithCancellationAsync(_page.BringToFrontAsync(), cancellationToken);
+ var focusSession = await WithCancellationAsync(_page.Context.NewCDPSessionAsync(_page), cancellationToken);
+ await focusSession.SendAsync("Target.activateTarget",
+ new Dictionary { ["targetId"] = await GetTargetIdAsync(focusSession) });
+ await focusSession.DetachAsync();
+ }
+ catch (OperationCanceledException)
+ {
+ throw;
+ }
+ catch
+ {
+ // Focus is best-effort.
+ }
+ }
+
+ ///