Skip to content

Stabilize KeylessAgent CLI - #1

Merged
ImanEstiri merged 102 commits into
mainfrom
feature/keyless-agent
Oct 31, 2026
Merged

ImanEstiri merged 102 commits into
mainfrom
feature/keyless-agent

Conversation

@ImanEstiri

Copy link
Copy Markdown
Owner

Summary

  • Stabilize the CLI agent implementation
  • Complete the current CLI development milestone
  • Prepare the codebase for WPF desktop UI development

Testing

  • Core and CLI tests reviewed
  • Build and test results documented

Next Step

Start WPF development in a separate feature branch after merging.

… tooling

- KeylessAgent.slnx with Abstractions, Core, Cli, Playground, and Tests projects
- Directory.Build.props (net10.0, analyzers, package metadata) and Directory.Packages.props (CPM)
- All NuGet packages installed via dotnet CLI (ImanSoftware.* set, Playwright, Spectre, xunit, FluentAssertions 7.x)
- nuget.config with package source mapping, publish.ps1, build-and-test workflow
- .editorconfig, .gitattributes, .gitignore, global.json, LICENSE.txt, tool icon
- CommandKind with Description attribute per AgentScript v1 command name
- CommandStatus, ApprovalMode, SnapshotMode, and AgentEventType enums
- Line-based state-machine parser: all 10 commands, case-insensitive keywords,
  multiline payloads, nonce markers (<<<CONTENT:x7), code-fence stripping,
  line-numbered problems for every grammar violation
- AgentScriptValidator: response/file-content size limits and path shape rules
- ResponseFormatGuard: single gate producing FormatGuardResult (valid script or problems)
- ErrorCodes catalog (KA_*), configuration options model, format-correction.txt template
- 28 xunit tests covering all parser and guard paths
…ckups

- GitService via short-lived git processes (info, branch, stash, stage,
  commit, push -u, log, .git/info/exclude upkeep); never force or reset
- BranchGuard enforcing protected branch patterns (main/master/release*) in code
- BranchNameBuilder: agent/<yyyyMMdd-HHmm>-<slug> with request-derived kebab slug
- PathSandbox: workspace-relative only, no .. or absolute paths, .git/.agent
  blocked, symlink escape detection
- CommandPolicy: deny list (format/shutdown/force-push/global-config/pipe-to-shell)
  plus configurable allow list for AutoSafe
- Per-command executors (say/newfile/updatefile/editfile/deletefile/readfile/
  runcommand/ask/commit/done) behind WorkspaceCommandExecutor with dry-run,
  Ask-mode diffs/confirmations, BOM preservation, and backup-before-change
- BackupService into .agent/backups with undo-latest restore
- Tests for path sandbox (.., absolute, .git/.agent, junction escape),
  command policy (deny list, segments, allowlist), branch guard
  (main/MASTER/release*/releases vs working branches), branch name builder
  (fake clock, empty/non-Latin/symbol/long slugs), and all executors
  (new/update/edit/delete/read/run/ask/commit incl. BOM preservation,
  backups, dry-run, declines, push failure, non-zero exits)
- Fix sandbox: symlink check no longer rejects paths whose parent
  folders do not exist yet
- Slug: whitespace collapses to dashes, repeated dashes collapse
- Relax analyzers for the test project (not shipped)
- SnapshotService runs tree-view.bat/code-dump.bat via cmd /c without a
  window, stdin closed and hard timeout so the trailing pause can never hang;
  staleness check ignores the snapshot outputs themselves
- SnapshotFilter: tree lines filtered by folder name including nested blocks;
  dump sections dropped by heading; chunking only at ### section boundaries
- ResultFormatter builds [KEYLESSAGENT RESULTS] messages mapping every result
  to command number/name/target with head+tail output capping and the
  skipped-commands note
- Tests: filter/chunk, bat-run-without-hanging, staleness, attachments,
  formatter ids/truncation/skipped
…extractor, fake

- PlaywrightChatProvider: ConnectOverCDP to the user's Chrome (default) with
  LaunchPersistentContext fallback, manual-login wait, new-chat click, and
  DeepThink/Search toggle enforcement via aria-pressed (warn and continue
  when a toggle cannot be found)
- PlaywrightChatSession: rate-limited sends, whole-text insert (never
  per-character typing), attachment upload via file input with paste
  fallback, reply waiting on stop-indicator + stable final-answer read,
  debug screenshots
- Default selectors.json built from the real DOM sample (structural
  selectors over hashed classes) with fallback lists, editable without
  recompiling, auto-written to the workspace
- ReplyExtractor: tag-scanner returning only the final answer, skipping
  .ds-think-content and code-block banners, preserving pre indentation
- FakeChatProvider (scripted replies) + local RateLimiter
- Playground harness running one scripted conversation end to end
- 139 tests green; solution builds with zero warnings
- AgentLoop orchestrating one turn in numbered steps: config load, git
  preconditions (repo check, dirty-tree continue/stash/cancel), protected-
  branch guard with agent-branch creation, session resume/new chat,
  stale-snapshot refresh, first message with system prompt + attachments,
  the conversation loop (wait -> guard -> execute with batch limits and
  skipped reporting -> results message), and an always-on snapshot refresh
  in finally
- Automatic format re-ask with correction template up to MaxFormatRetries,
  raw reply shown to the user when exhausted
- ConfigurationService: tolerant keyless.json (comments, case, trailing
  commas, string enums), default file written when missing, range validation
- SessionStore persisting chat URL/branch/turn in .agent/session.json
  (no-session is a KA_SESSION_NOT_FOUND failure, matching the Outcome
  package's rule that Data is never null)
- SystemPromptBuilder injecting limits into the embedded system-prompt.md
- KeylessAgentServiceFactory wiring everything by hand (no DI container)
- TurnLogger JSON-lines transcript, FileLoggerProvider for debug diagnostics
- 156 tests green including end-to-end loop tests with the fake provider
…ckaging

- SpectreAgentUserInterface: say messages as panels, progress through the
  status spinner, prompts for confirm/ask, colored +/- diffs before changes
- Commands: 'run <request>' (--dry-run, -v), interactive default command
  (prompt-per-turn session), 'resume <request>' (reuses .agent/session.json
  chat), 'undo' (restores newest backup batch), 'config' (prints effective
  keyless.json, writing defaults when missing)
- AgentCliRunner: shared spinner + branch/commit summary at turn end
- KeylessAgentServiceFactory.CreateSessionStore/CreateFileLoggerFactory;
  SingleProviderLoggerFactory for verbose diagnostics logging
- publish.bat calling the root publish.ps1; tool verified to pack (nupkg+snupkg)
- Full README (what/why, requirements, install, usage, Mermaid agent-loop
  flowchart, AgentScript summary, safety model, known limitations, roadmap
  incl. WinForms UI and official-API provider, ToS/account-risk notice,
  not-affiliated statement) and the short tool README
- docs/: architecture.md, agentscript-spec.md (grammar, commands, limits,
  full KA_ error-code catalog), configuration.md, browser-automation.md
  (selectors table + manual verification checklist), security.md,
  git-workflow.md
- Checklist review: inject IDateTimeProvider into the browser classes so
  Core has no direct DateTime.Now/UtcNow; verified zero warnings, zero
  csproj package versions, exactly six ImanSoftware packages, no
  Spectre/console usage in Core — 156 tests green
…nt directory

- All commands now take the workspace path as their first positional
  argument: 'keyless run <workspace> <request>', 'resume <workspace>
  <request>', 'undo <workspace>', 'config <workspace>'
- When omitted, the very first prompt asks for it, validating that the
  directory exists (current directory shown only as the default, never
  silently assumed); run/resume also prompt for a missing request
- Interactive default command prompts for the workspace before the first
  request; -w removed in favor of the positional argument
- README (root + tool) usage examples updated to workspace-first form
…-fatal

Bug: wrapping a whole agent turn in AnsiConsole.Status made every Say/
Confirm/Ask during the turn throw Spectre's 'Trying to run one or more
interactive functions concurrently' and kill the process with exit 1.

- IAgentUserInterface gains BeginWait(message): Core now marks only actual
  waiting phases (chat reply, sends, runcommand process, snapshot scripts)
  with a disposable wait scope — never the whole turn
- New ConsoleUserInterface owns all console access through one semaphore;
  the spinner is a background task cancelled+joined before ANY other UI
  call (Say/Ask/Confirm/ShowDiff/ShowProgress), overlapping BeginWait is
  safe (previous spinner stopped first), scopes tolerate double stops
- AgentCliRunner no longer wraps turns in Status; a failing or cancelled
  turn is printed (message + .agent/logs pointer) instead of crashing
- InteractiveSession loop: turn failures return to the keyless> prompt,
  Ctrl+C cancels only the current turn (fresh linked token per turn);
  exit code 1 reserved for startup errors
- Console Output/InputEncoding set to UTF-8 at startup when not
  redirected; console markers switched to plain ASCII ([ok] [!] [x] [i])
  with proper markup escaping
- Tests: nested-exclusivity reproduction of the crash, BeginWait+Confirm
  stops spinner first, Say during a wait, overlapping waits, Ask after a
  disposed wait, and failing/cancelled turns returning to the prompt
  (Spectre.Console.Testing 0.57.2 via CPM); 164 tests green
Before every send the newest assistant message is fingerprinted
(data-virtual-list-item-key, else a hash of the final-answer text, plus
the visible count); the send itself must be confirmed within
Browser.SendConfirmSeconds (new, default 15: new message item, or empty
input + generating indicator) with one retry (click then Enter) before
failing KA_SEND_NOT_CONFIRMED; WaitForReplyAsync accepts only a reply
whose fingerprint differs from the pre-send one (identical text valid
only with a new message key), whose final answer exists and is stable
for two reads, and once the stop indicator is gone. Attachment sends
wait for chips + enabled send (Browser.UploadWaitSeconds, default 60)
and fall back to pasting on timeout.

Session logic now sits behind the IChatPage seam (PlaywrightChatPage =
real DOM; FakeChatPage in tests), enabling scenario tests (a)-(d).
Docs updated; 168 tests green.
- LineEndings helper: detect dominant ending (mixed keeps the majority,
  tie means LF), normalize to LF, apply back; new files resolve their
  ending from .editorconfig (end_of_line, section globs), then
  .gitattributes (eol=), then the dominant ending of sibling source files
  (dot/config files excluded), then Environment.NewLine
- editfile: match and count FIND/REPLACE on LF-normalized text (the
  'matched N times' message included) and write back with the file's own
  ending; updatefile converts model content to the existing ending;
  newfile applies the resolved ending; BOM handling unchanged
- FileChange diffs are LF-normalized (no stray \r in the CLI diff) and
  MaxCommandOutputChars counts normalized characters in result messages
- Tests: CRLF+LF FIND matches once and stays CRLF, multi-line find,
  mixed-ending file, BOM+CRLF, newfile obeys .editorconfig and Windows
  NewLine, updatefile keeps endings, editorconfig globs, gitattributes,
  sibling dominance, diff without \r; 185 tests green
- deletefile now asks for confirmation in Ask AND AutoSafe modes; only
  Auto skips it (a backup is always made in every mode)
- AutoSafe/Auto turns show a one-time warning at turn start:
  'AutoSafe is not a security boundary' (dotnet/node/python can run
  arbitrary code)
- docs/configuration.md, docs/security.md and the README approval table
  now state exactly what the code does
- Tests: deletefile asked+declined in Ask and AutoSafe, not asked in
  Auto, and the AutoSafe boundary warning; 186 tests green
- BeginWait returns only after the Status display actually started
  (ManualResetEventSlim set from inside the display), so a prompt can
  never start in the gap; StopSpinner no longer gives up after 3s — it
  joins fully (30s upper bound with a warning), so any prompt/write
  always runs after the spinner is really gone
- ConsoleWriter becomes an instance over one IAnsiConsole (Shared for
  command output); commands and the runner route everything through the
  same console surface as the agent UI
- Turn failures print exception type + message; with -v the full stack
  trace is shown and appended to .agent/logs/diagnostics.log; the log
  path is always printed; WorkspacePrompt uses the same ASCII [x] marker
- Tests: prompt immediately after BeginWait never throws, slow spinner
  shutdown still lets the following prompt run, failing turns show the
  exception type and return to the keyless> prompt; 190 tests green
- SendButton fallback order per the sample: icon button in the textarea's
  sibling container, iconLabelPrimary, filled-circle, filled next to the
  file input, submit — each candidate checked for visibility and
  aria-disabled before clicking (a disabled send is skipped, never the
  target); Enter stays the last fallback
- Every selector loop logs (Debug) which fallback INDEX matched, so a
  broken selector is one log line away
- Toggle and MessageItems selectors made pure-CSS first (:has-text kept
  as a Playwright-only later fallback); AttachmentChips added earlier
- Fragment tests with AngleSharp run the default catalog against real
  fragments of deepseek-dom-sample-trimmed.html: input placeholder, file
  input multiple, toggles aria-pressed, virtualized item keys, final
  answer vs reasoning, send fallbacks matching and skipping the disabled
  button; 198 tests green
- /workspace <path> validates the directory, refuses while the loop is
  not ready (a failing switch keeps the old workspace), re-runs the git
  preconditions and clears the target workspace's .agent/session.json
  (fresh chat session), then prints the new header; every turn and
  callback receives the workspace explicitly — no CurrentDirectory
- /help lists the slash commands; any other /-prefixed input is rejected
  with a hint instead of being sent to the model
- InteractiveSession takes the workspace as a parameter and a
  switchWorkspace callback; InteractiveCommand wires preconditions via
  the git service and session store
- Tests: help listed without a model send, unknown /command rejected,
  switch recorded + used for subsequent turns, missing directory
  refused, failed switch keeps the old workspace, usage message;
  204 tests green
- Send retry happens only when the input STILL holds the text; a cleared
  input means the message was sent even when the virtualized item count
  did not change (treated as sent, then WaitForReplyAsync). Retry exactly
  once, and each attempt is the send button when clickable, otherwise
  Enter — never both in the same attempt (first attempt included)
- Attachment delivery judged by several signals: SetInputFiles result,
  the hidden file input's reported file names, composer-near elements
  showing a file name, chips visibility. Paste only when SetInputFiles
  itself failed or there is NO sign of the files after
  Browser.UploadWaitSeconds; uncertainty counts as attached with a
  warning — the same content is never both attached and pasted
- FakeChatPage gains FailClick, InputFileNames, VisibleFileNames and
  LastInsertedText; tests (a)-(e) cover all five scenarios; 209 tests green
- IChatDiagnostics (Core, GUI-reusable) + ChatDiagnosticsService over the
  IChatPageInspector seam: probes every selectors.json key through the
  fallback lists and classifies MATCHED (index + count) / NOT FOUND /
  NOT APPLICABLE NOW (Stop idle, no chips, no reasoning, fresh chat,
  login); reports login state, DeepThink/Search aria-pressed, input
  editability and the page URL; exit 0 only when all required keys
  matched; never sends a message
- PlaywrightChatInspector (real DOM) with the shared connection logic
  from PlaywrightChatProvider; a new tab, never touching the user's chat
- --capture <dir>: trimmed HTML snapshots (composer, newest message,
  sidebar, chips) via HtmlTrimmer — scripts/styles/svg removed, long
  text replaced with '...' so no personal content leaves the machine
- --watch: waits for a manually sent prompt, captures generating and
  finished states once each
- keyless doctor CLI command with a state table; docs section 'How to
  verify selectors: keyless doctor'; 5 tests with a scripted inspector;
  214 tests green
- All IChatPage operations are Task-returning with a CancellationToken;
  Playwright's waits have no token support, so each call races the token
  (WithCancellationAsync) — Ctrl+C interrupts any in-flight wait promptly
  instead of waiting out its timeout; FakeChatPage and all session tests
  updated
- KA_SEND_NOT_CONFIRMED and KA_REPLY_TIMEOUT errors now carry a pointer:
  the debug screenshot path under .agent/logs when Browser.Debug is on
  (the page writes to the exact path), otherwise a hint to run
  'keyless doctor'; screenshot path bug fixed (the destination path, not
  the name, is passed to the page)
- docs/browser-automation.md: send/confirm and attachment decision
  tables, the still-unverified selector list (Stop, New Chat, chips),
  and the cancellation note
- 216 tests green; build with zero warnings
…ibutes

- HtmlTrimmer rewritten: removal tags became HTML comments (the old
  '<script removed>' literal parsed as a real element); attribute values
  (href/src/title/alt/value/placeholder/aria-label/data-*) are masked to
  '[masked]' unless they are short allowlisted UI labels; structural
  attributes (class/role/aria-pressed/aria-disabled/type/accept/multiple)
  survive; data-virtual-list-item-key is replaced with a stable '[key:hash]'
- Text runs longer than the cap no longer swallow the following '<'
  (old '>...<' bug) — text nodes are masked per-node instead of one greedy run
- Allowlist: DeepThink, Search, New chat, Stop, Message DeepSeek,
  'Thought for N seconds' pattern, Copy, Download, Regenerate
- Tests with AngleSharp: sidebar titles/hrefs, user messages and
  attachment file names never appear; UI labels + structural attributes
  survive; trimmed HTML parses to the same element structure (minus the
  removed script/style/svg); 223 tests green
… readiness

- IsSendEnabledAsync is tri-state now: true/false when a send control
  matched, null when no send-control selector matches at all (fake page
  and real page agree); a null state never blocks the send
- After SetInputFiles succeeds, the session waits (up to
  Browser.UploadWaitSeconds) for BOTH a positive upload sign (files
  registered in the input, a chip showing a file name, chips visible)
  AND an enabled send control, whenever that control can be detected;
  an upload registered while the control stays disabled is sent anyway
  with a warning after the timeout
- Tests: send enabled only after N polls -> the click happens after
  enablement; undetectable send control -> no deadlock (returns well
  before the timeout); 225 tests green
- SelectorsCatalog.LoadAsync, PlaywrightChatInspector (ProbeAsync/
  GetToggleStateAsync/IsInputEditableAsync/CaptureTrimmedAsync) and the
  whole diagnostics pipeline are async with CancellationToken
- FileLoggerProvider no longer blocks inside ILogger.Log: Log enqueues
  into a Channel<string> and one background task performs all storage
  I/O; the provider constructor does no I/O at all
- TurnLogger.StartAsync/AppendAsync/AppendResultAsync; AgentLoop awaits
  them; /workspace switch flow is async end to end (git preconditions and
  session clearing happen on the async path with the turn's token)
- New NoSyncOverAsyncRuleTests greps all src/ production sources for
  GetAwaiter().GetResult()/.Result/.Wait() and fails on any violation
  (tests are exempt); 225 tests green, zero warnings
…tab rules

Observed bug: a separate logged-out Chrome window opened (never focused)
showing the DeepSeek login page instead of the user's session.

Root causes fixed:
- Chrome 136 ignores --remote-debugging-port for the DEFAULT
  user-data-dir: the docs claiming a daily-profile attach were wrong and
  are corrected everywhere (README requirements, browser-automation
  Connection section, configuration.md). A dedicated profile
  (Browser.UserDataDir, default %LOCALAPPDATA%\KeylessAgent\chrome-profile)
  is now mandatory and the login persists in it
- The silent LaunchPersistentContext fallback is removed; it is opt-in
  (Browser.AllowPlaywrightLaunch=false) with an explicit warning
- pages[last] navigation (which could destroy a user's tab) is gone:
  an existing chat.deepseek.com tab is reused (Browser.ReuseExistingTab),
  otherwise a new TAB opens via CDP Target.createTarget newWindow:false,
  matched by URL polling; the tab is focused (BringToFrontAsync +
  Target.activateTarget), again when login is required
- Connection is now IBrowserConnector in Core (probe -> attach;
  auto-launch the REAL Chrome via IBrowserEnvironment: port probe,
  chrome.exe locate via config/registry/paths, Process.Start, wait 30s,
  attach; actionable KA_BROWSER_CONNECTION_FAILED with the exact manual
  command line; port-open-but-attach-failed reports the reason instead
  of falling through), shared by run, doctor and the new
  'keyless chrome <workspace>' command (dedicated Chrome + one-time login)
- Dispose policy: only the tab the agent opened is closed; the Chrome
  window stays running (Browser.CloseChromeOnExit=false default)
- New config keys: UserDataDir, AutoLaunchChrome, ChromePath,
  ReuseExistingTab, AllowPlaywrightLaunch, CloseChromeOnExit
- Tests with faked environment: port open -> attach (no relaunch),
  port closed -> launch then attach, Chrome missing -> actionable
  failure, auto-launch off -> fail instead of fall through, Playwright
  fallback opt-in surfaces as failure without a real browser, profile
  default resolution; 232 tests green
- IAgentUserInterface.ChooseAsync with ChoiceRequest/ChoiceOption/ChoiceResult
  (UI-agnostic); ConsoleUserInterface numbered menu: arrows move, digits 1-9
  pick immediately, Enter confirms the highlighted (default marked), Esc/Ctrl+C
  cancels; non-interactive input returns the default and prints it; runs inside
  the console gate (spinner stopped first)
- AgentLoop preconditions rewritten as one combined flow: at most ONE menu per
  turn, none when the tree is clean and the branch is protected (agent branch
  auto-created); dirty menu carry/stash/here/cancel (here omitted on protected
  branches); branch menu new/stay/cancel on clean non-protected branches; the
  typed 'type continue/stash/cancel' and 'press Enter or type keep' prompts removed
- Auto git init (Git.AutoInit default true): git missing -> KA_GIT_NOT_INSTALLED
  with download link + manual steps; git init -b main (fallback init + branch -M
  main); .NET .gitignore via dotnet new gitignore or built-in template when the
  workspace has .sln/.slnx/.csproj; initial commit 'Initial commit (created by
  KeylessAgent)' with one-shot -c user.name/user.email identity fallback
- CommitExecutor: no 'origin' remote -> commit locally and skip push with a
  notice in the result sent to the model; origin present + push failure reported
  as before; KA_GIT_INIT_FAILED for init/commit failures; AgentTurnResult.ErrorCode
- ConfirmAsync shows the bracketed default, Enter accepts, no empty-input loop
- 233 tests green
…back

- Browser.ReplySource = Auto | Copy | Dom (default Auto); Auto tries the
  Copy capture, validates, falls back to Dom; Copy never falls back
  silently (KA_REPLY_TIMEOUT); Dom is the original behavior
- New selector key MessageCopyButton (message-level Copy copies ONLY the
  final answer; bar may be hover-only — the newest message is hovered
  first, and only the NEWEST message's control is ever clicked)
- IChatPage capture members + Playwright implementation: ExposeBinding
  interception wrapping navigator.clipboard.writeText, execCommand('copy')
  and the copy event, gated on an agent-only flag (user's manual copying
  and the OS clipboard are never affected); clipboard fallback grants
  clipboard-read/write, saves the previous text, reads readText() and
  ALWAYS restores it in a finally block
- ReplyReader: strategy + validation (normalized markdown/punctuation-
  stripped comparison: containment or similarity >= 0.9; reasoning start
  must be absent; copy >= 50% of DOM length; numbers logged on rejection)
- Completion detection unchanged (fingerprint + final answer + stop +
  stable); IChatSession.LastReplyOrigin surfaces Copy/Dom/Dom (copy
  rejected) and the AgentLoop logs reply-source per reply
- Parser fence tolerance: outer fence stripped only when the first AND
  last non-blank lines are fences of the same or greater length (3 or 4
  backticks); inner fences inside CONTENT payloads untouched; system
  prompt tells DeepSeek not to wrap replies in a code fence
- doctor: MessageCopyButton probe + ReplyCapture check (strategy,
  clipboardUntouched, copiedLength, validation result)
- 247 tests green (14 new: ReplyReader validation/strategies, parser
  fences, interception/clipboard/none flows)
…ODO removed

- README rewritten: tagline, requirements (.NET 10, Windows, git, Chrome with
  the Chrome 136 dedicated-profile rule), install, first run, usage with the
  numbered git menus and auto-init notice, Mermaid agent loop, AgentScript
  summary, reply sources table (Copy/Dom), safety model ('AutoSafe is not a
  security boundary'), known limitations, roadmap, layout, ToS/not-affiliated
- docs: git-workflow.md (auto init, numbered menus, at-most-one-menu flow,
  no-origin push skip), security.md (menu note), configuration.md
  (ReplySource + Git.AutoInit), browser-automation.md (reply sources,
  capture strategies, validation rules), agentscript-spec.md
  (KA_GIT_NOT_INSTALLED, KA_GIT_INIT_FAILED)
- CLI README updated (chrome/doctor commands, menus, auto init)
- DocsConsistencyTests: every ErrorCodes KA_ code must appear in
  agentscript-spec.md; every Browser/Limits/Git/Commands default must appear
  in configuration.md; the documented keyless.json block must deserialize to
  values equal to the default options object
- DOCS-TODO.md deleted; 250 tests green
MoveFileExecutor mutated silently: no diff, no confirmation — unlike
deletion. After the sandbox/existence checks and before the backup, Ask
and AutoSafe modes now show a FileChange diff for the move and ask for
confirmation ('Move file', with the destination and the backup note);
a decline returns KA_COMMAND_DECLINED and nothing is moved. Auto keeps
running without asking, and dry-run still previews without prompting.
Tests: declined move leaves both files untouched with KA_COMMAND_DECLINED;
confirmed move relocates the file and records exactly one diff. The test
suite's executor registry gained the MoveFileExecutor it was missing
(which is why the move path had never been exercised end-to-end there).
454 passing.
keyless undo only restored backed-up files: a @@newfile creation survived
undo and a @@movefile left its destination behind. Each backup batch now
carries a journal.jsonl - {op:create,path} written by NewFileExecutor
after a successful create, {op:move,from,to} written by MoveFileExecutor
into the same batch that holds the backed-up source (BackupAsync was
split into BackupToBatchAsync returning the stamp plus an
AppendJournalAsync; both best-effort - a journal failure never fails the
command). UndoLatestAsync processes the journal first (delete the created
file, delete the move destination, reported as 'removed: <path>' entries)
and then runs the existing copy restore, skipping the journal file
itself. Tests drive the real executors in Auto mode: undo deletes an
agent-created file; undo restores the moved source and removes the
destination. 456 passing.
FirstVisibleAsync raced fallback selectors SEQUENTIALLY with the full
timeout each, so an absent element cost N x timeout: login probing
(3 x 5s = 15s per start), stop-indicator polls (3 x 400ms per reply
poll). The fallback list now races in ONE combined Or-locator with ONE
timeout (winner identified by instant probes afterwards), and a new
instant IsAnyVisibleAsync (single round trip, no waiting) answers
'is it there right now' - stop-indicator polling, chat-limit notices,
attachment chips, toggles and send-enabled all use it; the session's
default reply poll drops to 100 ms. Login detection is positive-first:
ONE wait races InputBox against LoginIndicator (Browser.LoginProbeSeconds,
default 15) via IChatPage.WaitForAnyVisibleAsync; only a winning login
indicator focuses the tab (IChatPage.FocusTabAsync) and waits for the
manual login. The minimize call moved to the provider caller so the
probe method is pure. Per-phase timings now cover login-probe, toggles,
upload, paste, send, send-confirm, first-token, completion and copy
(ChatSessionOptions.Timings; the loop adds parse and passes its recorder
into the session), plus a compact 'Reply: N commands' progress line for
valid replies. 456 passing.
PlaywrightChatProviderTests: the positive-first login probe with a fake
page - a logged-in start costs ONE parallel wait and never focuses the
tab or runs the login loop; a winning login indicator focuses once and
then polls with instant checks (never per-fallback waits); an
inconclusive probe (nothing matched) is treated as logged in. FakeChatPage
gains round-trip counters (stop polls, chat-limit probes, any-visible,
parallel waits, focus) and scripted results. PlaywrightChatSessionTests:
the stop-indicator poll interval stays at most 100 ms (polls >= elapsed/
100ms with instant checks, no serialized timeouts) and the chat-limit
probe costs exactly one round trip per reply wait. 461 passing.
Ctrl+C was only handled during keyless run; the interactive session died
with the process default. The session now owns Console.CancelKeyPress via
one RequestCancel seam: at the idle prompt a press exits gracefully
(exit 0; per-turn log flushes and connector disposal already happen at
turn end), during a turn the first press cancels only the turn and a
second press within 2 seconds exits gracefully. The idle prompt races the
exit signal (WhenAny) so the exit does not wait for stdin. Tests: idle
cancel exits 0; first cancel during a turn cancels exactly the turn,
second cancel within 2s exits 0. 461 passing.
ShowProgress printed raw enum names ('ValidatingReply ...'); it now
renders one consistent '› step: detail' shape with stable lowercase step
labels per event type, and a test pins the compact 'Reply: N commands'
line for valid replies. 463 passing.
browser-automation.md: the positive-first login probe (one parallel
InputBox-vs-LoginIndicator wait, LoginProbeSeconds=15, focus only when
the user must act), parallel Or-locator waits and instant checks with the
~100 ms reply-poll cadence, the ChatLimitNotice selector row, and the
interactive Ctrl+C behavior. configuration.md: LoginProbeSeconds key,
login-probe/fast-wait/--timing paragraphs. README + CLI README: --timing,
/newchat, --verbose, Ctrl+C semantics. DOCS-TODO.md deleted (it was
empty). DocsConsistencyTests extended: configuration keys, the recoverable
KA_ codes, README CLI options and slash commands, CLI README slash
commands and Ctrl+C. 467 passing.
A PlaywrightChatSession test with a scripted TimingRecorder asserts the
phases recorded around the real session flow: toggles, upload, send,
send-confirm, first-token, completion and copy all land in the table.
468 passing.
Three wait sites still paid one timeout per fallback: the InputBox wait
(10 s x N in InsertTextAsync), the SendButton wait (3 s x N in
TryClickSendAsync) and the MessageCopyButton wait (full timeout x N).
All three now use the combined Or-locator FirstVisibleAsync helper -
one wait, one timeout, the matched fallback index returned; the same
total upper bound as today (the per-fallback timeout) applies once.
TryClickSendAsync keeps its disabled/paperclip screen, now on the single
matched candidate (a disable/paperclip verdict falls back to Enter
instead of paying more waits). FirstVisibleAsync gained a scope-based
overload (resolve delegate) so the copy-button race runs scoped to the
newest message, and the winner probes resolve at the same scope. The
clipboard fallback's fixed 500 ms sleep is now a 75 ms poll of the read
with the same 500 ms budget. Remaining WaitForAsync in the page: exactly
one (the combined wait itself). 468 passing.
…ter visible one

The Or-locator's .First is the first match in DOM ORDER - a hidden element
listed first made the visible-wait time out even though a later fallback
was visible. The combined wait now applies Filter(Visible = true)
(supported by the pinned Playwright 1.64.1; the EvaluateAsync fallback is
not viable because catalog selectors use Playwright-only pseudo-classes
like :has-text), so the race is over visible matches only. Winner
selection is extracted into SelectVisibleWinnerAsync - the first selector
whose instant visibility probe passes, in fallback order; hidden earlier
matches are skipped and nothing visible yields null. FakeChatPage models
DOM-order visibility (DomVisibility) for the parallel wait: a provider
test proves a hidden first InputBox selector does not stop a visible
login indicator from winning (focus happens, login poll runs), and a unit
test pins the winner rule itself. 470 passing.
'Choose or open the chat tab' was the one untimed connect step.
IBrowserConnector.ConnectAsync gains an optional ITimingRecorder (the
provider passes ChatSessionOptions.Timings), and OpenChatTabAsync wraps
its whole choose-or-open body (reuse-existing-tab, Target.createTarget,
page wait, and the test-seam override) in a 'tab-open' phase - a null
recorder costs nothing. Tests: the auto-launch path records tab-open
with a recorder and works without one. 472 passing.
NSubstitute joins the test project so the real Playwright IPage/ILocator
surface can be stubbed. PlaywrightChatPageRoundTripTests count the
Playwright round trips of the fallback waits: an absent InputBox costs
exactly one WaitForAsync with the 10s single budget (then the documented
no-input exception), an absent SendButton one WaitForAsync with the 3s
budget (then false), an absent MessageCopyButton one scoped WaitForAsync
with the caller's timeout (then false), the stop-indicator check one
IsVisibleAsync round trip, and a hidden-first DOM shows the winner
probes walking in fallback order until the later VISIBLE selector and
the text being inserted there. 477 passing.
docs/configuration.md documented MinDelayMs 400 / MaxDelayMs 1500 while
the code defaults are 0 and 150, and placed Browser.MaxTurnsPerChat under
the Git section. Fixed. The new
DocsConsistencyTests.DefaultKeylessOptions_SerializeMatchesDocumentedJson
parses the documented jsonc blocks (string-aware // stripping), compares
them against the SERIALIZED default KeylessOptions with the production
serializer in both directions - every documented key must exist with the
same value (arrays element-wise, numbers numerically), and every
options property must be documented - so default drift can no longer
happen silently. 478 passing.
code-dump attachments carried .json/.config files verbatim: connection
strings, API keys and whole environment files reached the DeepSeek chat.
Snapshot.Filter gains SensitiveFilePatterns (env files, key material,
*secret*/*password*/*credential*, production appsettings - case-
insensitive wildcards against the file name) and MaskSensitiveLines
(default true). BuildAttachmentsAsync now drops dump sections whose file
name matches a sensitive pattern and redacts secret-looking values in
the remaining sections - Password/Pwd/ApiKey/secret/token/credential
assignments keep the key name and become key=[redacted], JSON keys keep
their quoted shape, Authorization Bearer/Basic tokens are redacted,
PEM private-key blocks are redacted line-wise between BEGIN and END.
The tree attachment gets a '[N files filtered and M values masked as
sensitive]' marker line (omitted when both are 0). The attachment copy
only: code-dump-result.md, tree-view-result.txt and every source file on
disk stay byte-identical. New keys documented in configuration.md (the
consistency test caught them immediately) with a DOCS-TODO bullet.
Tests: .env/secrets.json/appsettings.Production.json filtered with
marker, secret values masked with keys kept, disk outputs unchanged,
and a comment mentioning 'password' untouched. 481 passing.
After 'Port N is open but attach failed' the connector logged and showed
progress but fell through to the auto-launch step - starting a SECOND
Chrome on a port already owned by another Chrome, which could only fail
again. The comment said 'do not fall through'; the code now honors it:
the connect fails right there with KA_BROWSER_CONNECTION_FAILED, the
attach error, and the hint 'Close the Chrome that owns port N or change
Browser.ChromeDebugPort.' FakeEnvironment gains FailAttach (attach
throws); the regression test asserts the failure code, the hint, and
that StartChrome was never called. 482 passing.
RunTurnAsync printed the stopped-reason warning and then returned 0 - CI
and scripts could not tell a stopped turn from a successful one. Exit
codes are now 0 success / 1 error / 2 stopped early / 130 cancelled; the
summary output (commits, summary, change set, timings) still prints for
stopped turns since they may have done real work. Also fixed a latent
test-flake root: AgentCliRunner used ConsoleWriter.Shared, which binds
AnsiConsole.Console at first touch - under the parallel suite the
warning could go to another test's captured console; the writer is now
resolved per call. Tests: stopped turn returns 2 with the warning, failed
turn returns 1, successful turn returns 0; the exit-code table is in
DOCS-TODO.md. 485 passing.
SessionStore.LoadAsync deserialized without try/catch - invalid JSON in
.agent/session.json threw and killed the turn. It now treats any
deserialize failure (or null result) as no-session with
KA_SESSION_NOT_FOUND and renames the broken file to session.json.corrupt
(best effort) so the content survives for inspection; valid sessions are
never renamed. TurnLogger.StartAsync/AppendAsync wrote through storage
unguarded - an I/O error would fail the turn. Writes are now wrapped
(outcome failure AND exception), and the first failure disables logging
for the rest of the turn with exactly one warning through the new
optional warning callback; cancellation still propagates. Tests: corrupt
session returns KA_SESSION_NOT_FOUND with the .corrupt file preserved
and valid sessions never renamed; a logs path blocked by a file disables
logging with one warning and no throws; healthy logging stays
warning-free. 489 passing.
The batch loop ran EVERY command in the reply: after @@done set
completedByDone, the foreach kept executing later blocks (a newfile
after @@done really created the file) and the done-check only broke
after the whole foreach. Now a SUCCESSFUL @@done reports every remaining
non-say command as Skipped with the reason 'after @@done' (executed list
+ turn log via AppendResultAsync - the results message is intentionally
not sent when the turn is complete) and breaks the batch. A failed
@@done keeps the old behavior. Test: say + done + newfile leaves x.txt
uncreated with a Skipped/after @@done result entry in turn-1.jsonl.
490 passing.
AppendResultAsync serialized CommandExecutionResult verbatim: command
output with credentials (connection strings, tokens, printed env vars)
landed in .agent/logs/turn-N.jsonl in plain text. New SecretRedactor
(compiled, case-insensitive): password/passwd/pwd/secret/token/apikey/
api-key/authorization/bearer followed by : or = and a value becomes
'<key>=[redacted]' - optional quotes around key and value keep JSON
output structured, an optional bearer/basic scheme word is consumed so
'Authorization: Bearer <token>' loses the whole credential, and the
value stops at whitespace/quote/semicolon/comma so 'Password=x;Server=y'
only loses the credential. AppendResultAsync redacts the Output FIELD
before serialization (never regex over serialized JSON); requests and
replies are logged as received. Tests: password= line, JSON ApiKey and
Bearer token redacted with structure and non-secret lines kept. 492
passing.
PlaywrightChatInspector.IsInputEditableAsync hardcoded two textarea
selectors and paid a 2s wait per fallback. IChatPageInspector
.IsInputEditableAsync now takes the fallback list (catalog.InputBox from
ChatDiagnosticsService) and probes it with the shared parallel
Or-locator helper - one wait with the same 2s-per-fallback total budget.
FakeChatPage's inspector records the lists it receives; the regression
test asserts the doctor passes exactly catalog.InputBox. Also: the
stop-poll interval test's wall-clock upper bound was flaky under
parallel-CPU jitter (33 polls on this machine vs a 30 cap) - the
discriminating lower bound (instant checks, not serialized timeouts)
is kept without the jittery cap. 493 passing.
1. Upload fallback: SetInputFiles success + no upload sign no longer
   pastes the content on top (it would double it) - treated as uploaded
   with a warning; paste only when the upload itself failed.
2. CloseChromeOnExit: the dispose lambda now knows the connection kind -
   a launched Chrome is closed for real (the old pages.Count == 0 guard
   was almost never true with our own tab open); an attached Chrome is
   NEVER closed even with the flag set.
3. ProcessRunner: timeout/cancel keep the partial stdout/stderr (readers
   drained up to 2 s) and expose Reason (timeout/cancelled); the executor
   reports cancelled commands as KA_CANCELLED, not KA_TIMEOUT.
4. Attachment size report: the UploadingAttachments progress event now
   carries 'Attachments: N files, X KB' plus the filtered/masked marker.
5. SnapshotFilter invariants exposed a REAL leak: root-level bin/obj
   folders' children share the folder's marker column, so the depth <=
   pop consumed the exclusion on the first child. Folder/file lines now
   follow different pop rules (siblings pop at <=, files drop while
   covered at >=); nested same-name, case differences and near names
   pinned by tests.
6. MessageCopyButton reported by keyless doctor (NotApplicableNow when no
   assistant message is visible).
7. Prompt-injection tests already existed (NOT REPRODUCED).
8. /newchat new-chat-URL test already existed (NOT REPRODUCED).
KA_CANCELLED documented in agentscript-spec.md (consistency test caught
it). 504 passing.
Program.cs hardcoded SetApplicationVersion("1.0.0") while the version
also lived in the CLI csproj - two sources that could drift. Now:
<Version> lives in Directory.Build.props (inherited by the csproj), the
csproj's own <Version> is removed, Program.cs reports the assembly
version (major.minor.patch, fallback 0.0.0), and publish.ps1 reads and
bumps <Version> in Directory.Build.props (Get/Set-BuildPropsVersion)
instead of the csproj. DocsConsistencyTests.VersionMatchesDirectoryBuildProps
compares the props version with the built assembly version and asserts
the csproj carries no second <Version>. 505 passing.
(a) The NUGET_API_KEY check ran before packing and demanded the key even
with -SkipPush; it now happens only right before the push, so pack-only
runs work keyless.
(b) The package was picked as 'newest *.nupkg by LastWriteTime' - any
later touch of the artifacts folder could grab the wrong file. The exact
$ProjectName.$version.nupkg is selected now (missing = hard error), and
all push/cleanup references use that path.
(c) NOT REPRODUCED: dotnet pack already checks $LASTEXITCODE, summaries
the failure and stops via Write-Error before anything is deleted; the
existing delete-artifact-on-failed-push behavior is unchanged.
Verified live: two -SkipPush runs WITHOUT NUGET_API_KEY pack 1.0.0 and
auto-bump-pack 1.0.1, both exiting cleanly with no key demand.
configuration.md: a Snapshot section paragraph for
SensitiveFilePatterns/MaskSensitiveLines (what is dropped and redacted,
attachment copy only, disk unchanged, marker line) and Other-files entries
for session.json corruption tolerance and the best-effort turn log with
output redaction. agentscript-spec.md: @@done ends the turn - later
commands are skipped with 'after @@done'. README: the 0/1/2/130 exit-code
table and a Building-from-source note on the single version source
(Directory.Build.props) and publish.ps1 -SkipPush being keyless. CLI
README: the exit-codes one-liner. DOCS-TODO.md deleted. Consistency tests
(config keys/defaults, KA_ codes, CLI options/slash commands) stay green:
505 passing.
The last sequential fallback waits are gone: both capture strategies'
Copy-control click loops (3s ClickAsync timeout per MessageCopyButton
fallback) now use ClickNewestCopyControlAsync - ONE visible-filtered
Or-wait over the whole fallback list carrying the same 3s-per-fallback
total budget, then a single click on the matched element. A click
failure is logged and reported as not-landed (Enter-safe), never a
per-fallback retry. Test: with the first two MessageCopyButton fallbacks
absent and the third visible, the capture clicks exactly the visible
fallback after ONE combined wait per strategy - zero ClickAsync calls on
the absent fallbacks. Stub lessons baked in: NSubstitute is last-config
wins, the Or-chain must be closed on its own stub, and
ILocator.EvaluateAsync is the 3-arg Task<JsonElement?> overload. 506
passing.
1. agentscript-spec error catalog: four duplicated rows removed
   (CONFLICTING_CONTROL, BAD_OPTION, COMMAND_NOT_FOUND, GIT_NOT_INSTALLED
   each kept once with the richer description). New
   ErrorCodes_AppearExactlyOnceInTheCatalogTest parses only the catalog
   rows and asserts every ErrorCodes constant appears exactly once.
2. @@next optional MSG: the parser's required/optional payload split now
   allows an optional <<<MSG on @@next (bare @@next and empty MSG stay
   valid; unknown sections still rejected with KA_PARSE_UNEXPECTED_
   PAYLOAD). system-prompt.md and the per-message reminder show the
   optional MSG. Tests: NextWithMsg, BareNext, NextWithUnknownSection.
3. @@say after @@done now still reaches the user (executed after the
   done, not counted toward MaxCommandsPerResponse); every other
   remaining command is skipped with 'after @@done' as before.
   SayAfterDone_IsShown asserts both messages shown, x.txt uncreated,
   newfile skipped in the turn log.
4. IsSendEnabledAsync's inline JSON parse extracted into
   IsDisabledFromJson (disposed JsonDocument, false for null/empty/
   invalid). PlaywrightChatPageTests covers true/false/null/not-json.
515 passing.
@ImanEstiri
ImanEstiri merged commit 08962b1 into main Oct 31, 2026
1 check failed
ImanEstiri added a commit that referenced this pull request Oct 31, 2026
…ed duplicates

(a) User interactions (approvals, menus, @@ask) are timed as a separate
'user-wait' phase via a UserWaitTimingUi decorator that wraps the UI the
loop and executors use - so approval/menu/ask time no longer inflates
the execute or git-checks phases.
(b) The environment probe now STARTS before the browser connection and
runs concurrently with it, and is cached in .agent/env.json for 24h
(keyed by the workspace; invalidated when older, missing, or with the
new --refresh-env flag on run/interactive). The awaited result is read
just before the first send, so the probe cost hides inside the connect
phase.
(c) FormatTable numbers duplicate phases (#1..#N) when a phase appears
more than once; single occurrences keep their plain name.
(d) TOTAL now shows 'TOTAL (wall clock)' and 'TOTAL without user-wait'
(= wall clock minus accumulated user-wait).
Tests: duplicate numbering, wall clock/without user-wait rows,
user-wait subtraction; session timing test updated for send-confirm#1.
536 passing.
ImanEstiri added a commit that referenced this pull request Nov 5, 2026
main's PR #1 was a squash of an EARLIER snapshot of this same branch, so
every overlapping file conflicted add/add; each resolves to the feature
version (the newest state - main's README, for example, is byte-identical
to the feature's pre-restructure copy). Everything main added that the
feature never touched (.editorconfig, .gitattributes, .github workflow,
global.json, nuget.config, LICENSE.txt, Directory.Packages.props,
src/Playground) carries over unchanged.

Merged tree: build clean, 641 core + 211 WPF tests green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant