Compact the home parliament chip, animate crab states like the HTML kit,
ship hermit-icons.ttf (sbix) + frames, and install the font from make/
bootstrap/package paths.
Co-authored-by: Cursor <cursoragent@cursor.com>
Point readers at WHC/Hermit as canonical; add MIGRATION.md and
migrate_remote_to_hermit.sh so existing checkouts can retarget origin.
Co-authored-by: Cursor <cursoragent@cursor.com>
Ship hermit binary (cst-agent symlink), ~/.config/hermit with legacy fallback,
Hermit color theme and mini crab frames, plus SVG→PUA font build pipeline.
Co-authored-by: Cursor <cursoragent@cursor.com>
Rewrite the homepage for day-to-day use, move paper/eval drafts under
docs/archive, and add a docs index so theory no longer leads the project.
Co-authored-by: Cursor <cursoragent@cursor.com>
Bring back Gitea CI + auto-publish; share release helpers; strengthen multi-turn
tool/job prompts with a unit contract; ccache/make help and --release bootstrap.
Co-authored-by: Cursor <cursoragent@cursor.com>
Password Realm no longer auto-enables parliament (keeps task/worker tools);
finished jobs fan out to agent notes + TUI; raise PAR_MAX/grep caps and tag truncations.
Co-authored-by: Cursor <cursoragent@cursor.com>
Sanitize session titles, stamp CST on the topbar, fill work/sync/keys so
the sidebar never looks unfinished, drop duplicate ctx from the foot, and
tone down the scroll banner plus composer mode line.
Co-authored-by: Cursor <cursoragent@cursor.com>
Seat role already lives in the Seat Deck. Remove the lonely right-edge
chip and paint the session strip on panel ground so it no longer reads
as a raised bar with a hairline seam.
Co-authored-by: Cursor <cursoragent@cursor.com>
Appending the scanning caret after trailing newlines parked a lonely
block at the left margin. Overlay the tip character instead and let the
decode crest sweep through the wake.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace noisy glyph spam with a layered frontier: bright cyan plasma at
the tip, a traveling braille wake that ghosts the real text, a lock flash
as glyphs settle, and a scanning write-head caret.
Co-authored-by: Cursor <cursoragent@cursor.com>
Network tokens fill md_live immediately; the UI reveals md_shown at a
non-linear rate (slow at the tip, accelerates with backlog) so glitch
and text stay readable without lagging far behind a fast model.
Co-authored-by: Cursor <cursoragent@cursor.com>
leaveok on chrome panes and always redraw input after side/log motion
so the caret no longer sticks on the Seat Deck version line. Live
assistant text briefly scrambles at the tip then settles to plaintext.
Co-authored-by: Cursor <cursoragent@cursor.com>
Replace the OpenCode-style inspector with a seat/fleet/ctx/work telemetry
rail, default-on for wide sessions, driven by 80ms ASCII micro-motion.
Co-authored-by: Cursor <cursoragent@cursor.com>
Cooler brand palette, compact CST-agent home with tagline and framed
composer, session topbar (model + chair chip), sidebar off by default.
Screenshot pipeline in tools/tui_shot.py; docs/screenshots for before/after.
Co-authored-by: Cursor <cursoragent@cursor.com>
Run inbound peer handling on its own thread so outbound dials cannot starve
:9100. Failed peers cool down exponentially. Also land --join (member seat)
and realm/peers.json seed discovery for Tailscale meshes.
Co-authored-by: Cursor <cursoragent@cursor.com>
Peer dial used per-step read timeouts so a stuck handshake could block the
single-threaded daemon for tens of seconds; inbound hellos piled up unread
(CLOSE-WAIT). Use a wall-clock dial budget, drain accepts before each dial,
skip self, and only one side initiates idle polls.
Co-authored-by: Cursor <cursoragent@cursor.com>
Seats publish via parliament_note, sync pushes on write, member join without
stealing the chair, and worker tools are hard-gated when parliament is active.
Co-authored-by: Cursor <cursoragent@cursor.com>
CLI/Docker assert AEAD sync; status shows sealed channel; headless denials no longer pretend the user refused.
Co-authored-by: Cursor <cursoragent@cursor.com>
Pidfile records instance+port; login restarts the service when the seat
changes. TUI /realm status|login|stop; prompts discourage filler chatter.
Co-authored-by: Cursor <cursoragent@cursor.com>
Prompt for Realm password when CST_REALM_PASSWORD is unset; map worker
request-gate exhaustion to a clear budget error with partial progress;
hard-skip identical tool failures after 3 tries. Add docs/parliament.md.
Co-authored-by: Cursor <cursoragent@cursor.com>
Inject shared parliament context into the agent environment, switch
prompts away from in-process workers when parliament is active, clear
login tickets on chair sync/new-project, and surface realm/chair/service
in /status. Tighten empty-reply handling and prefer-tool principles.
Co-authored-by: Cursor <cursoragent@cursor.com>
Login claims the chair, writes a session ticket, and starts a background
service for discovery plus revision-based context sync. Password Realm
opens require --login (or CST_REALM_PASSWORD); --service/--service-stop
manage the resident agent.
Co-authored-by: Cursor <cursoragent@cursor.com>
Derive Realm keys from a user password (Argon2id, no private key on disk).
Login claims the sole chair with handover; peers share parliament context;
--new-project clears it. CLI peer/discover helpers and Docker alpine recipe
included; headless empty-answer retries raised for unattended runs.
Co-authored-by: Cursor <cursoragent@cursor.com>
Exchange enrolled certificates and Ed25519 challenge signatures so
same-Realm instances can authenticate each other after discovery.
Co-authored-by: Cursor <cursoragent@cursor.com>
Advertise instances with a multicast beacon and a config-dir registry so
same-machine and LAN peers that share a Realm fingerprint can find each
other without TOFU pairing.
Co-authored-by: Cursor <cursoragent@cursor.com>
Introduce a user Realm keypair (Monocypher) that signs instance
certificates so same-domain peers can later auto-discover and connect.
Co-authored-by: Cursor <cursoragent@cursor.com>
Building the Windows TUI by hand -- the first local build since the CI file
went away -- stopped in src/tools/session_tools.c: `getline` was an implicit
declaration there, which this GCC treats as an error. src/session.c had a
private fgetc() shim for exactly that; the session-list scan is newer and used
the POSIX call directly, and only the Windows build notices.
The shim moves to os_compat as os_getline(): getline(3) itself on Unix, the
fgetc() loop on Windows, one prototype in the header so a caller reads the same
everywhere. src/session.c drops its copy and calls it; the scan does too (its
`ssize_t` becomes `long long`, which Windows has).
make check is green on Linux, the Windows cross build now finishes and
packages (dist/cst-agent-0.9.7-windows-x86_64.zip plus the NSIS setup), and the
static Linux tarball was rebuilt with it.
- consent: on_permission / on_ask_user wait at most two / five minutes, and in
the background (a phone off screen, a window that is gone) the answer is
refused at once with a reason -- a dialog nobody can see blocked the worker
for good, which is what "the app hangs while the agent works" looked like.
- finishTurn() no longer joins the thread it is called from; the event loop
reaps the Turn instead.
- AndroidTools: the hop to the GUI thread pings first and gives up after 3 s
instead of waiting forever on a busy GUI thread.
- the session list stopped reading every transcript: cst_agent_store_info()
counts newlines in 64 KiB blocks (first_user only within the first 256 KiB),
and AgentCore::sessions() reuses a row while (mtime, size) is unchanged.
- session_search gets a budget (max_bytes 32 MB, max_seconds 5 s), skips lines
over 64 KiB, and says when the budget stopped the walk.
- the header shows the phase ("shell: make -j8"), so a long turn no longer
looks like a stuck one.
- md_to_pdf: the front end's own Qt tool (owner app:qt, every platform):
markdown or a workspace .md/.html becomes an A4 PDF with 15 mm margins,
paginated by hand, written into the workspace and delivered as a file card.
No PrintSupport, so the phone gets it too.
- --uitest runs md_to_pdf through the registry with Chinese text and checks the
bytes are a PDF; app/CMakeLists.txt no longer drops src/web_md.c from the
standalone engine build, which the window needs to render markdown.
Version 0.9.7.
Four tools and a document writer, plus the storage half of the phone: until
now the picker's answer (a content:// URI) reached the engine as a path, and
it said "cannot read" -- the app had no way to hand a phone's file over.
- md_to_html (new, src/tools/web.c + cst_web_md_document()): markdown in, one
self-contained HTML document out -- doctype, utf-8, viewport, escaped
title, a small stylesheet; no script, no external request. It is written
into the workspace and delivered as a file card (for_user, so it never
enters a request), the way deliver_file does. The default name comes from
the source file's stem or, for pasted markdown, from the title with what a
path cannot carry turned into '-'. It has a preview, so an approval prompt
says "render x -> y.html" before anything is written.
- download (src/tools/web.c): an http(s) URL into a file in the workspace,
streamed with a 128 MB ceiling; a redirect chain, the same CA handling and
the same refusals as web_fetch, but the bytes stay on disk (a half file is
removed on any failure, including HTTP >= 400). The name comes from the URL
when the caller names only a directory.
- read_pdf (src/tools/fs_tools.c): the text layer and page count of a PDF,
which read_file refuses because the bytes are binary -- and a PDF is
exactly what arrives from a phone. Same extractor an attachment uses, its
own 4 MiB input ceiling, 256 KiB of text by default with a note when cut.
- session_search (new, src/tools/session_tools.c): every stored session --
this one and all the ones before it -- searched for a substring, reporting
the session's id, message count and title, then the matching lines with
their number and role. Case-insensitive by default, capped at 20 matches.
- device_info (same file, perm_name "device"): version, system/arch/CPU
count, workspace, sandbox, model and permissions, config/temp/program
directories, pid -- so the model stops guessing where it runs.
- Tools 3 and 4 come with tests/test_tools_ext.c (also in both build files):
the document is a whole page, a script tag and a javascript: link do not
survive, a hostile title stays text and cannot write outside the workspace,
the preview names the file, read_pdf/download refuse what they must (no
network in a test), session_search finds a fixture session and says so when
nothing matches, device_info names the workspace. README's tool table grew
five rows.
- Android storage: the picker (SAF) answers with a content:// URI, and the
engine opens files by path, so the app now copies what it points at into
its own directory first -- app/android/src/xyz/whcself/cstagent/
AttachFile.java, called through a new app::materializeAttachment()
(ToolProvider.h: one API for every platform; a desktop strips file:// and
refuses content:// with a reason). The copy needs no permission at all: the
grant travels with the URI.
- "All files access" (MANAGE_EXTERNAL_STORAGE) is the other half: it is what
lets the engine read a path in shared storage that a task names rather than
picks. Android has no runtime dialog for it, so main.cpp opens the one
Settings screen once (AttachFile.java's requestAllFilesAccess, with the
all-apps screen as a fallback) and never insists: without it everything
works, one picked file at a time. On the NX789J the screen came up, the
app-op reads back "allow", and a relaunch no longer asks.
- Composer.qml hands a content:// URI through untouched (it is not a path,
and decoding it would corrupt it); AgentCore::attachFile() turns whatever
the picker gave into a path first.
Desktop: build clean, make check 36/36 (the new test included, also under
ASan), --uitest all green, --selftest and cst-app-selftest OK.
The APK builds and installs (86 MB, release-signed) with the new Java class
in its dex.
Version 0.9.6.
A session's transcript lived beside its attachments, both named after an id
that was a timestamp. Two things fell out of that: on a workspace two
machines share, a timestamp is not an id (the same second on both is enough
to hand out the same name), and "delete this conversation" had to chase the
jsonl, the title and a directory in a shared attachments/ tree, guessing what
belonged to whom. A phone's app data has nobody to tidy it by hand.
Now one session is one directory:
<workspace>/.agent/sessions/
<uuid>/session.jsonl the transcript
<uuid>/title the window title, one line
<uuid>/attachments/ images and delivered files (img-…, art-…)
<uuid>/sub-1.jsonl an agent role's own transcript
CURRENT the id the next run opens
- src/agent_store.c: new_id() makes a uuid v4 (16 bytes from /dev/urandom,
clock+pid only as a fallback), dir_for_id/flat_for_id/nested_for_id/
shape_of_id decide which shape an id has, so every reader takes either.
An id is a directory name, so it is checked as one (no "/", no "..",
nothing starting with ".", not CURRENT or attachments). path_for_id() keeps
the shape a session already has and makes the directory for a new id, so
nothing had to change in the callers. store_list() lists the directories
that hold a transcript plus the flat files, newest first. delete() is one
rm_tree of the session's directory, plus the old shared attachments/<id>
and CURRENT when it pointed there.
- cst_agent_store_migrate() (--migrate-store, or plain cst-agent for a
session): moves a flat store in — <id>.jsonl to <id>/session.jsonl,
<id>.title in, the shared attachments/<id>/ to <id>/attachments/. rename
first, copy when the filesystem says no, and counts what it moved, so a
second run is a no-op that says "nothing to move". On the real workspace:
6 sessions moved, the shared directory gone, --list-sessions reads them
back unchanged, a second run moved 0.
- cst_agent_store_id_of_path(): the id of a session path. This is the bug the
whole change had been hiding: cst_agent_session_id() took the id from the
file name, and the new file name is session.jsonl -- every session would
have called itself "session" (the sidebar, CURRENT, undo, an agent role's
directory, and the metrics file, which now also goes through the helper).
- src/attach.c: a session that owns its directory keeps its payloads inside
it (<id>/attachments/); only the flat layout needs the shared directory and
the id stem. Attachments behave exactly as before otherwise.
- tests/test_store.c: uuid shape and uniqueness, a small buffer refused, a
new session's path and directory, write/list/info/title read back, the
payload landing in <id>/attachments/, delete taking the directory with it;
then an old-layout store: readable before the move, moved byte for byte,
its payload readable through the new path, listed once, a second migration
a no-op, deletion covering the migrated payload. Also in the Makefile and
CMakeLists test lists.
- app/src/ui/UiTest.cpp: the image directory check wanted "/attachments/",
which the new layout has no trailing slash for.
Desktop: build clean, make check 35/35, --uitest all green, --selftest and
cst-app-selftest OK, and --selftest opens a session of the migrated workspace.
Version 0.9.5.
A phone freezes an app it cannot see, and the front end had nothing holding
it: a turn that took minutes -- a long shell command, a provider that thinks,
a build -- stopped quietly the moment the user pressed home, and the answer
was only there if the window happened to come back. The engine's own /attach
flow made this visible: an APK turn survived, a pocket did not.
- app/android/src/xyz/whcself/cstagent/CstService.java: a foreground service
started with an explicit component (nothing outside the app can reach it,
no intent filter) for as long as a turn runs. Being a foreground service
makes the process one Android neither freezes nor kills; a partial wake
lock (30 min ceiling) keeps the CPU awake with the screen off. Its ongoing
notification says what is running -- "CST-agent is working" plus the first
line of the prompt -- on the same cst-agent channel the notification_send
tool uses, and taps back into the app. A phone that refuses the foreground
start (Android 12+ does, from the background) loses the protection, not the
turn: the failure is caught and the service stops itself.
- Manifest: FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC (API 34+ wants
the typed one) and WAKE_LOCK, plus the <service> with
foregroundServiceType="dataSync". The first build of this failed with
"Expected '>', but got ' '" -- a manifest comment cannot contain "--".
- Front end: app::holdForeground(text) / app::releaseForeground() in
ToolProvider.h, one call for every platform -- Android starts and stops the
service through JNI, a desktop has nothing to hold (its window is the app).
AgentCore::send() calls it when the turn starts (firstLine() of the prompt
is what the notification says) and finishTurn() releases it, so the
notification exists exactly as long as the work does. CST_APP_NO_SERVICE=1
keeps a test run from touching the phone.
- On the NX789J, with the app sent home and the screen off: the service read
back isForeground=true with its notification, the wake lock cst-agent:turn
was taken at the start of the turn and dropped at its end, run_shell
"sleep 60" finished with the screen dark, and the service was gone after.
Desktop: build clean, --uitest all green, --selftest and cst-app-selftest OK
(the hold is a no-op there, so nothing about the Qt side changed).
Version 0.9.4.
The tool was the last Android one that never worked on the NX789J: it would
fail with "Notification.Builder.build() failed: no exception detail" and the
shade stayed empty. The detail was the whole story -- Qt clears a Java
exception before C++ gets to look at it, so a build() that throws and a
build() that returns null look exactly the same from here, and the only way
to catch the difference is to let Java catch it.
- app/android/src/xyz/whcself/cstagent/Notifier.java: the builder, the
PendingIntent, build() and notify() in one try/catch that returns "" or
the throwable's class and message. androiddeployqt copies app/android/ with
the rest of the package source and Qt's Gradle template puts its src/ on
java.srcDirs, so a class dropped there needs no build-script change (the
first one is this; the foreground service of the next step goes there too).
- AndroidTools.cpp keeps what it can see from C++ (the context, the channel
on API 26+, the small icon looked up by name, the launch intent) and calls
Notifier.post(); the result line now carries the Java failure when there is
one. The javaErrorText() helper of the previous attempt is gone: reading a
pending exception from C++ is exactly what does not work here.
- On the device: the tool answers "notification sent", the system holds the
record (channel cst-agent, importance 3, the framework small icon, the tap
intent back into the app). Earlier attempts in the same session are in the
transcript next to it: four failures from the old path, then this one.
Docs: docs/android.md gets the Java-helper mechanism (and why the tool has to
be wrapped in Java at all) and no longer lists notification_send as missing;
app/README.md's platform table and the Android note say where a Java class
goes.
Version 0.9.3.
A turn could write a report and say "here it is" -- and there was nothing to
open. The engine had no way to hand a finished file to the person, and the
front end had no way to show one. This release adds the whole path: a tool to
deliver, a store that keeps the copy, a card in the transcript, and open /
share through the platform. It also fixes two bugs the phone exposed while it
was being tried there.
deliver_file (engine)
- cst_attachment_t.for_user marks a copy that belongs to the person, not to
the model; cst_attach_import_artifact() stores any bytes up to
CST_ARTIFACT_MAX_BYTES (64 MB) with the mime taken from the name's
extension. Attachments are unchanged (content decides the type, 16 MB).
- It is a read-policy engine tool: path resolves inside the workspace, a
directory is refused, title (optional) is the display name.
- The wire is what keeps it out of the conversation: parts_add_attachments()
skips for_user entries without even a "not sent" note, and a turn that only
delivered a file no longer invents an empty user message. tests/test_artifacts.c
pins that down end to end, including a session-file round trip.
The card (Qt front end)
- filesText() replaced imagesText(): one line per file -- kind, stored name,
name, origin, mime, bytes, pixels -- decoded by ImageStrip.qml. A for_user
entry is a file card (extension badge, name, type and size, Open, Share);
a picture stays a thumbnail and opens on tap. Both shapes are read, so
sessions written by an older version still draw.
- AgentCore::openArtifact() / shareArtifact() accept a name only when one of
the session's own messages carries it, and hand the display name to the
platform layer for the status line.
- Android opens through the app's FileProvider (content:// + ACTION_VIEW,
resolveActivity() checked, read permission granted) and shares through the
share sheet; a desktop uses QDesktopServices / the clipboard.
CST_APP_NO_OPEN=1 makes --uitest run without opening anything.
Two bugs the device found
- The session writer had its own copy of the attachment JSON and forgot
`for_user`: the file survived the file, but came back as an attachment --
no card, and put back in front of the model. It now uses the one writer
(cst_attachments_to_json()), with the round trip covered by a test.
- engineToolImages() looked the card up with findTool(), which only finds a
card that is still running -- and the engine reports the result *before*
the files, so the card was already complete and the files were dropped.
A live turn therefore showed no card while a replay of the same session
did. It now matches on the call id.
- A tap that could not work said nothing: ImageStrip.qml's errorText was
never drawn. The card now carries the reason ("no app on the phone opens
report.md").
Also
- The saved provider key reaches the engine on every start: AgentCore::open()
was handed an empty key and the engine never resolves credentials itself
(only the CLI did), so every start asked for it again. It resolves the same
order main.c uses (saved key, provider environment, CST_API_KEY) and wipes
the copy it made; --selftest checks it against a scratch credentials file.
- Android: notification_send reads the Java exception text into its result,
and keeps failing on the NX789J -- the build() returns null and Qt clears
the exception before C++ can read it, so the call has to be wrapped in Java
next (docs/android.md says so).
- --uitest grew the delivered-file case (card, Open, Share, a forged name
refused), the live order of result-then-files, and the startup row count.
Version 0.9.2.
A workspace is never empty on the second run: cst_agent_open() resumes the
last session of the store, so the engine holds a transcript before the window
exists. The window only filled its view on a session *switch*, and a start is
not a switch -- the pane came up empty next to a sidebar pointing at the very
session it belonged to (on the phone: "hello, 6 messages" highlighted, no
transcript under it).
- Main.qml: Component.onCompleted reloads the session list and replays the
open session, the same call the sessionChanged handler makes.
- AgentCore::switchSession(): a click on the row of the session that is
already open emits sessionChanged instead of returning quietly, so the row
redraws its transcript rather than being a dead spot.
- --uitest: the window is now created over a seeded session (a fixture is
written and switched to before the QML loads, which is what a restart looks
like) and the check "the open session is on screen at startup" pins it --
it failed with 0 rows before the fix. Two qWaits in the settings-dialog
section, whose clicks were racing the dialog's layout: the extra startup
work made that visible (5 failures that had nothing to do with sessions).
Verified: --uitest 79/79 (the new check among them), cst-app-selftest OK, and
on the phone -- the APK rebuilt and installed, a restart of the app draws the
whole stored session (messages, tool cards, thinking blocks, copy icons).
Version 0.9.1.
mode = coding
Two strands land together: the window draws pictures now, and the whole front
end builds, installs and runs on the phone with the engine linked in-process.
Images (the "make a picture actually show up" part)
- Engine, additively: cst_agent_hooks_t.on_tool_images(call, list, n, ud) fires
when a call left attachments behind, and cst_agent_pending_rel() /
cst_agent_pending_is_image() read that list without the app touching the
store. tests/test_attach.c::test_agent_pending pins the three cases (an
image, a stored name that passes cst_attach_rel_ok, out-of-range NULL).
- App: image://cstattach/<stored name> (app/src/ui/SessionImages.cpp) serves
only the current session's attachment directory, checked with
cst_attach_rel_ok, decodes down to the requested sourceSize and never
upscales, and holds a mutex because requestImage() runs on the render
thread while the directory can change underneath it. AgentCore gained
attachmentDir(), pendingImages(), the toolImages()/promptImages() signals
and hookToolImages(); history() gives replayed rows their images too.
- QML: ImageStrip.qml (a thumb strip, <=150 px tall, shaped by the picture,
tooltip with name/size/origin, the file's name when it went missing),
wired into MessageCard and ToolCard. Qt 6's ListModel cannot hold a
list-valued role (append() reads back undefined, setProperty drops it), so
a row carries one image per line as <stored>|<name>|<origin>|<w>x<h> and
ImageStrip decodes that.
Android (the engine is already in the APK; this is the front end)
- app/src/main.cpp: on Android the workspace, config, tmp and HOME go under
the app's own data directory before the engine opens anything, because
QDir::currentPath() is "/" there and /tmp is not writable.
- app/android/: the manifest (QtApplication/QtActivity, INTERNET and
POST_NOTIFICATIONS, adjustResize, no storage permission -- the picker will
be the system one), strings, a vector icon; minSdk 24 and targetSdk 35 come
from app/CMakeLists.txt through QT_ANDROID_MIN/TARGET_SDK_VERSION.
- app/src/platform/android/AndroidTools.cpp: the Android file behind
PlatformInit(), owner "app:android" (notification, clipboard, share sheet,
file picker are stubs for now, so the tool list does not lie about them).
- app/tools/selftest.c decides its scratch workspace with os_temp_dir(), so
the C smoke test runs on the device too.
- scripts/build_apk_android.sh: sources to installed APK in one command
(environment checks, qt-cmake configure, --target apk, adb install), and
the host-side plumbing the NixOS + qt.io combination needs -- the NDK
sysroot include dirs for Qt's FindGLESv2/FindEGL (they only look under
<sysroot>/include), -L$PREFIX/lib on the shared/module link lines as well
as the executables', the Android debug keystore created on first use with
QT_ANDROID_SIGN_APK + QT_ANDROID_KEYSTORE_* (androiddeployqt copies the
unsigned gradle output and signs it itself), and the #!/bin/bash shebang of
the build-tools wrappers, which NixOS does not have.
- scripts/gradle-android-init.gradle (installed into ~/.gradle/init.d/): the
release lint task is switched off -- lintVitalAnalyzeRelease resolves its
own tool jars straight from dl.google.com, bypassing every repository the
build configures, and fails the build after all the real work is done.
- Icons: app/icons/*.svg (Breeze's 24@3x files, the 19 names the window asks
for) are compiled in as :/cst-icons and ThemeIcons.cpp falls back to them
when the platform has no icon theme. On the phone the Qt theme lookup found
nothing and the QML glyph fallback was an empty box in the phone's fonts.
- docs/android.md: "The APK (Qt front end)" -- the SDK assembled without
sdkmanager, the host Qt's missing libraries on NixOS, the Gradle mirrors,
lint, signing -- and a Status that says what runs where. app/README.md
gains the images and APK lines, and stops claiming Android platform tools
that are still stubs.
Verified: engine `make check` green; desktop `cst-app-selftest: OK` and
--uitest all checks pass; phone (NX789J, Android 16, arm64-v8a) -- the APK
installs, starts on the workspace under /data/user/0/xyz.whcself.cstagent/
files/, a turn reaches a configured provider over HTTPS from inside the ART
process and renders the reply, rotation keeps the session and docks the
sidebar, and the toolbar/card icons draw.
Version 0.9.0.
mode = coding
The settings dialog could pick a preset, a model and a key and nothing else.
Two things were missing: a provider the presets do not know, and what the
model is asked to do while it answers. Both live under "Advanced options"
now, next to the entry they belong to.
Custom providers (engine)
- cst_models_user_set_provider() / cst_models_user_drop_provider() write the
engine's own user layer, <config>/cst-agent/models.json:
{"providers": {"myllm": {"api_base": ..., "key_env": ...,
"default_model": ..., "models": [...]}}}
and reload the registry, so the provider is at once in the provider list,
in the wire models[] and in the CLI's /provider -- the file a hand-written
override would go into is the same one, so the GUI and an editor agree.
The name is checked (1-31 of letters, digits, '.', '_', '-'), a preset's
name overrides that preset (a self-hosted endpoint, a proxy), everything
else in the file (model rules) is kept, and a file that is not a JSON
object is never overwritten. Dropping only removes the user's own entry.
- AgentCore::providers() gained "custom" (source == "user"), and
addProvider() / removeProvider() drive the two calls above.
Model parameters (engine + wire)
- cst_agent_config_t and cst_llm_request_t carry temperature, top_p and
max_tokens. 0 means the field is not sent at all -- an absent field is the
provider's own default, which "temperature": 0 is not (deterministic
decoding is a real choice), so cst_llm_build_body() only adds what was set.
tests/test_llm_body.c:test_gen_params pins both halves.
- settings.json keeps them per provider ("params": {...}) via
cst_settings_remember_params(); cst_settings_remember() carries them along
with the provider choice, and cst_agent_create() reads them, so a CLI or
TUI run sends what the window was set to. An all-zero config (the CLI)
leaves what the GUI saved alone.
- AgentCore::genParams() / setGenParams(), and useProvider() applies both the
remembered parameters of the provider it lands on and a base URL that was
edited in the dialog.
The dialog
- Rewritten around one scrolling body with a fixed footer: a long model list
used to push the key field and the buttons out of the dialog. The model
chips are filtered (a filter field appears past twelve) and capped at 40
with "... +N more", which is what a router with hundreds of models needs.
- "Advanced options" folds out the provider entry (name, base URL, key
variable, default model, models; Save provider / Remove from models.json)
and the model parameters (temperature, top-p, max tokens; empty = not
sent). The fields follow the picked provider and are what gets saved, so a
hand-typed edit is never lost to a stale property.
Verified: --uitest 67/67 (15 new checks: parameters round-trip through the
engine, a provider added through the form and removed again, a bad name
refused, a preset not removable), --selftest, ctest cst-app-selftest, and
make check with the new body test.
Version 0.8.15.
mode = coding
The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.
Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
hicolor), tinted with QPainter's SourceIn and rasterised at the requested
size and the screen's device pixel ratio. Qt only builds its theme search
paths from XDG once a theme name is set, so the provider fills them itself
(GenericDataLocation/icons, ~/.icons and, on NixOS,
/run/current-system/sw/share/icons, for a shell started outside the desktop
session). An unknown name yields a null image, which QML reports as
Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
still names the old character as `glyph:`, so a host with no icon theme
degrades to exactly what the window looked like before instead of going
blank. installThemeIcons() is called by main() and by --uitest, so the test
loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
(mail-attachment), copy (edit-copy), session delete (edit-delete), new
session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
(go-down), the plan checklist (task-complete/box). A tool card also shows
what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
for read, folder for list/glob, magnifier for grep, braces for a shell,
globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
size), card and row icons 14-16 px.
Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
hints, provider URLs, session metadata and placeholders, so it is text, not
decoration.
- A selected provider row and the open session row write on the accent fill;
they now use accentText / accentTextDim / okOnAccent instead of faint grey
and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.
Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
implicitHeight + 2*pad). With the top margin alone the last row -- the clip
and Send -- hung over the lower border, which is what put the clip on the
frame instead of inside it.
app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).
Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).
Version 0.8.14.
mode = coding
Two things the window got wrong once a real turn ran through it, plus the
switch that was invisible on a dark theme.
Tool cards (app/src/core/AgentCore.*, app/qml/Main.qml, ToolCard.qml):
- A turn that called the same tool twice left the first card on "running"
forever and gave its result to the second card: both were paired by name.
cst_agent_hooks_t hands the call (ctl_tool_call_t) to every callback, so the
id travels with the signals now (toolEvent(phase, id, name, detail),
toolResult(id, name, exitCode, output)) and findTool() matches on it, in the
same way AgentCore::history() fills it from tool_call_id / tool_calls_json
for a replayed session. Only a call without an id -- an old text-protocol
session -- still falls back to the name.
- Nothing may keep spinning after a turn: closeAllRows() runs when the turn
ends and settles every open row; a call that never came back shows "no
result" (state "unfinished") and no longer claims an exit code.
Streaming text (app/qml/Main.qml, ChatView.qml):
- The answer was plain text while it streamed and turned into markdown only
when the turn ended. A 120 ms timer now re-renders the open answer with the
engine's own renderer (AgentCore::renderMarkdown -> cst_web_md_html), so a
list, a table or a code fence fills in formatted as it arrives. A row keeps
its own "html" field; a replayed session renders once, where its rows are
built.
QML details:
- the Plan mode Switch got a drawn indicator: the Basic style's pill is a dark
pill with a dark knob on this theme, which made the control look disabled.
- Every engine signal handler is a plain window function now
(engineToolEvent, engineToolResult, engineTurnFinished, engineAssistant,
...), which is also what lets --uitest drive the exact path; rowJson(i)
returns a transcript row as JSON for it (an object would not survive
QMetaObject::invokeMethod).
Tests: 6 more checks -- a call opens a card, two calls of one tool are two
cards, the result lands on the card of its own call, the other card is
untouched, the turn end settles an open call, a delta opens a streaming answer
and markdown is rendered while it still streams. 52/52 pass, --selftest,
ctest cst-app-selftest.
Version 0.8.13.
A prompt can carry files now, and they come in the three ways a desktop user
expects: the 📎 button in the composer (QtQuick.Dialogs' FileDialog), files
dropped anywhere on the window (a DropArea over it, with an accent border and
"drop to attach" while something is dragged), and an image on the clipboard
(Ctrl+V). Text keeps the editor's own paste: the composer asks
AgentCore::clipboardHasImage() first and only takes the keystroke when there is
an image.
AgentCore (app/src/core/AgentCore.*):
- pending is a property now (QStringList) fed by cst_agent_pending_describe();
attachFile() and attachClipboard() wrap cst_agent_attach_file() /
cst_agent_attach_bytes(), clearPending() wraps cst_agent_pending_clear(), and
seesImages() reports whether the model is shown images at all.
- the engine's description says what the model gets ("png 12.0 KB"), not where
it came from, so the window remembers the names it attached and puts them in
front: "note.txt — text 21 B".
- pendingChanged() fires on attach, on clear and when a turn ends (the prompt
consumed the list), which is what makes the chips move.
Engine (src/attach.c):
- cst_attach_describe() printed every sub-megabyte size as "%.0f KB": a 21-byte
text file came out as "text 0 KB". It is "B", "KB" with one decimal or "MB"
now, and test_attach still passes.
QML: the composer shows one chip per pending file with "clear all"; the hint
line mentions 📎/Ctrl+V and says when the model is text-only (images then
travel as notes); Main.qml owns the DropArea and the drag feedback.
Verified: 44/44 UI checks (8 new: attach ok, one pending, the name is shown, a
missing file is refused, one chip in the composer, clear all empties it, the
clipboard image is seen and attached), --selftest, make check rc=0 (test_attach
included), and screenshots of the chip row.
Version 0.8.12.
The Basic style paints its own controls: the two text fields came out as white
boxes on the dark panel and the buttons looked nothing like the rest of the
window. Both fields now carry the surface, border and focus colors the chat
cards use (same shape as AskDialog's field), and the buttons follow the
composer: Close is a quiet bordered button, Use this is the accent one.
No behavior change; 36/36 UI checks still pass and the dialog was looked at
again through CST_APP_SHOT.
Version 0.8.11.
The window could be started, but it could not be pointed anywhere: a provider,
a model and a key only came in on the command line. The gear in the header now
opens a dialog that changes all three on the engine's live config.
AgentCore (app/src/core/AgentCore.*):
- providers(): the engine's preset table (cst_provider_count/at), with base
URL, the remembered or preset model, whether a key exists and where it comes
from (cst_creds_resolve: "saved", or the environment variable), the preset's
key variable and the source ("built-in" / "user"). A key is read into a local
buffer only to answer "is there one"; it never leaves the C++ side.
- modelChoices(provider): the provider's own list (cst_provider_models), the
fetched cache (cst_ps_cached -- which writes through its out-parameters, so
it takes a real int, not NULL: passing one cost a segfault) and the
remembered pick. No network. modelInfo() is cst_model_info + its "200K ·
text only" label; usage() is cst_agent_usage for the session and the turn.
- useProvider(provider, model, key) -> "" or a reason: a different provider
goes through cst_ps_switch_provider() (the key is verified against /models
before it is saved; offline it is kept unverified and the header says so),
a key for the current one through cst_ps_set_key(), and the model is
remembered in settings.json. syncConfig() takes the engine's config back
over the app's copy, so provider/model/approval read correctly afterwards.
The engine's session is never touched by a switch.
- open() no longer starts with an empty provider: it takes the one the engine
used last (settings.json), else the built-in default, plus that provider's
base URL and model. Without this the first turn could not be sent at all and
no entry in the dialog was "in use".
QML (app/qml/SettingsDialog.qml, Main.qml):
- a modal dialog with the themed surface the other dialogs use (the Basic
style's light panel washes out the dark theme), the provider list with its
key state, a model field with the info label and clickable candidates, a
password field for a new key, the session's token counters, and a note that
reports what the last "Use this" answered.
Tests: 8 more checks -- the presets are readable, an unknown provider is
refused, the counters are readable, the gear opens the panel, the rows exist, a
model typed into the field reaches the engine, the note says so, Close puts the
dialog away. 36 checks pass. --uitest also points XDG_CONFIG_HOME at its
scratch directory now, so a run cannot rewrite the user's settings.json or
credentials, and CST_APP_SHOT writes shot.png at the end plus
shot-settings.png while the dialog is open (that is how the dialog above was
reviewed on a machine without a screen).
Verified: 36/36 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of the dialog.
Version 0.8.10.
A conversation is now something the window can pick. The sidebar lists the
workspace's stored sessions and switching replays one into the view.
Sessions (app/src/core/AgentCore.*, app/qml/SessionRow.qml, Sidebar.qml):
- AgentCore::open() resolves the workspace's current session through
cst_agent_store_resolve_default() before the engine starts. Without it the
engine opened session.jsonl in the process's working directory, next to a
session list that stayed empty.
- sessionId property; sessions() (id, title, firstUser, messages, mtime in ms,
current); newSession(), switchSession(), deleteSession() (moves the engine
off a session it has open first), renameSession(); sessionChanged and
sessionListChanged signals. No mutation runs during a turn.
- history() turns the open session back into the rows the live signals build
(kind/role/text/name/state/detail/output/exitCode/complete): system prompt
skipped, notes and attachments shown, assistant reasoning as a think row,
tool_calls_json as one card per call, and each result paired with its call
the way findTool() pairs the live stream. A call with no result keeps state
"unfinished" ("no result"), so a replay never shows a spinner that cannot
stop. Main.qml replays on sessionChanged.
- SessionRow.qml is a file of its own: as an inline component it would not see
the Repeater's modelData. Session controls are disabled while a turn runs.
Tests (app/src/ui/UiTest.cpp, app/src/main.cpp):
- the fixture session is written through the store's own API (a tool call with
its result), then the checks click its row, assert the five replayed rows and
the paired result, start two new sessions (the view empties), delete one by
its button (the open one stays), and switch back. 28 checks in total.
- item() falls back to the visual tree: delegates a model owns are not always
in the window's QObject tree, which is why the rows were invisible to
findChild even though they were on screen.
- --uitest drives a scratch workspace unless --workspace is given; it creates
and deletes sessions. CST_APP_SHOT=file saves the window as a PNG when the
run ends and CST_APP_DUMP=1 prints the session rows seen; both are how the
layout gets looked at on a machine without a screen.
QML: ThinkBlock's click target is a TapHandler on its row (a MouseArea anchored
inside a layout is undefined behavior and Qt said so).
Verified: 28/28 UI checks, --selftest, ctest cst-app-selftest, make check rc=0,
and a screenshot of a replayed session (cards, tool card with its result, the
think block, the sidebar list).
Version 0.8.9.
The window is no longer one file pretending to be a chat box. The transcript,
the run panel, the composer and the two consent dialogs are their own QML
components over one Theme singleton, and the shell around them adapts to the
screen: docked and scrollable on a desktop, a slide-out drawer on a phone.
QML (app/qml/, new except Main.qml):
- Theme.qml: single QML singleton, dark Breeze-flavoured tokens (surface,
border, accent, text/textDim/textFaint, radius, pad, gap, sizes, monospace);
diffLineColor() for unified-diff rows, compactBreakpoint 880, touchTarget
34, segmentHeight 30, headerHeight 42.
- ChatView.qml: a row-type dispatcher over the transcript model (message,
thought, tool, note), follow-the-tail scrolling with a "jump to latest"
button once the user scrolls up, and an empty state that says where the
agent runs and what it can do.
- MessageCard.qml, ThinkBlock.qml, NoteLine.qml, ToolCard.qml (status pill,
arguments, collapsible output, 500-line cut, diff colouring, spinner while
the call runs), TodoPanel.qml.
- Sidebar.qml: workspace/provider/status, mode and approval segmented rows,
plan switch, the engine's todo list, version footer; scrolls, so a short
window still reaches every control.
- Composer.qml: auto-growing field, Enter sends / Shift+Enter newlines,
red Stop while a turn runs, autocorrect hints off, finger-sized targets.
- ConsentDialog.qml (Deny / Once / Always -> CST_PERM_*) and AskDialog.qml
(the model's options become buttons, free text stays possible).
- Main.qml: shell with header, draggable sidebar splitter (hover feedback,
200-560 px, double-click resets), transcript, composer, the two dialogs,
Esc to cancel and Ctrl+L to clear. Under 880 px the sidebar becomes a
drawer with a scrim instead of squeezing the chat away.
AgentCore / main.cpp:
- provider, model, approval and planMode are properties now; approval and
plan mode write back into the engine's config, and renderMarkdown() calls
cst_web_md_html(), so a table or a code fence looks like the WebUI's.
- the todo hook writes m_todo on the GUI thread (it was written from the
worker thread before: a data race on a QString member).
- --size WxH for the window (the layout tests drive it), and the Basic style
is chosen by default: the Breeze Quick style registers its types with
relative URLs, which floods the log and breaks the composer.
UI tests (app/src/ui/):
- --uitest drives the real window with synthesized input and exits with the
number of failures: 15 checks over docking/drawer transitions, mouse clicks
and touch taps reaching the engine's mode, the plan switch, the composer
arming the send button, and a 460x900 -> 1200x820 resize round trip.
- every control the tests touch carries an objectName; the sidebar's
implicitHeight binding loop is gone.
Tests: cst-app-selftest and --uitest pass in the standalone app build,
ctest cst-app-selftest passes in the repo build (38/38 on the host).
Version 0.8.8.
The engine can be embedded now instead of only driven from a terminal: the
same objects link into a Qt front end (app/) that owns a cst_agent_t inside
its own process -- no port, no token, no second process -- and that front end
now runs on the desktop and, engine-side, on the phone.
Capability metadata (include/cst_tools.h, src/tools/registry.c):
- cst_tool_def_t gains perm_name, platforms and available (a probe that
returns a reason), so a permission is addressable by name and a tool can
say why it cannot run here.
- cst_platform_name(), cst_tool_platform_ok(), cst_tool_available(),
cst_tool_meta_reload(), cst_tool_perm_name() and CST_PLAT_DESKTOP[].
- cst_tool_register() may take over a built-in name that is not runnable on
this platform, so a front end replaces it with its own implementation.
- the registry entries carry their class (delete_file -> "delete"), the
desktop clipboard tools carry a clipboard probe; find/active/run gate on it.
- /api/settings.tools[] exposes perm_name, platforms, available, why and the
declared parameters.
Engine as a library (Makefile, CMakeLists.txt):
- libcstcore.a from the CORE_OBJ set, and a CMake cstcore target shared by
the CLI and by app/ (-DCST_BUILD_APP=ON): the engine is compiled once.
- Android branch for the vendored sashidown: a per-ABI archive built with the
NDK's aarch64-linux-android24-clang wrapper, because CMake's compiler is
plain clang and keeps --target/--sysroot in its own variables.
Qt front end (app/, new):
- AgentCore wraps one cst_agent_t: turns run on a worker thread, every
cst_agent_hooks_t callback becomes a Qt signal, and the two hooks that need
an answer (on_permission, on_ask_user) block the worker until the GUI
replies; mode, approval and plan mode go through the engine's config.
- ToolProvider hands the front end's tools to the engine (owner "app:*"); the
Linux one is notification_send with a probe for notify-send/D-Bus.
- Main.qml: transcript, tool blocks, consent dialog (Deny/Once/Always ->
CST_PERM_*), ask_user, mode switch, Esc to cancel; --selftest stays
headless and runs the embedding checks without QML.
Android (scripts/, docs/android.md, new):
- scripts/build_third_party_android.sh builds static mbedTLS + libcurl for an
ABI (CA path /system/etc/security/cacerts); build_android.sh builds
cst-agent, libcstcore.a and the test binaries for the phone, can push them
and run the whole suite there.
- sandbox_exec.c keeps the plain exec path on Android: the seccomp supervisor
compiled in and the probe reported "seccomp", but writes outside the
workspace were not denied on the device -- claiming a sandbox that does not
hold is worse than reporting none.
- tests/test_diff.c compares layouts only against real GNU diffutils: toybox's
diff prints the same hunk header with its own hunk/context rules.
Tests: test_tool_meta (metadata, platform gate, probe reasons, takeover of a
built-in name) and test_embed (in-process embedding contract) are new; the
existing registry/llm_body assertions now follow availability. 38 test
binaries pass on the host, 36/36 on arm64-v8a Android 16, cst-app-selftest
passes in both the standalone app/ build and the repo build.
Version 0.8.7.
Working mode: --mode coding|general (CST_MODE, settings.json, /mode,
WebUI run settings). general swaps the default framing for a general
assistant one -- workflow, safety notes, tool wording (run_shell, the
subagents) -- and /status shows it; minimal (the paper profile) stays
byte-frozen. The prompt state carries the mode so a switch leaves a
system note in history.
Clipboard: clipboard_read / clipboard_write tools (16 MiB cap read,
default 64 KiB window; writing is a WRITE permission with a preview) on
pbcopy / wl-copy / xclip / pbpaste / wl-paste / xclip and the Win32
clipboard. In the TUI, /attach paste falls back to clipboard text when
there is no image on it.
PDF: a dependency-free text extractor (src/pdf_text.c): inflate,
Flate/ASCIIHex/ASCII85, PNG/TIFF predictors, layout-independent object
scan, object streams, page tree walk with inherited resources, content
lexer (Tj/TJ/'/" and the Td/TD/T*/Tm spacing heuristics), per-font
ToUnicode CMaps and inline images skipped. Encrypted files and pages
with no text layer are refused with the reason.
Attachments: /attach (a path or files dropped on the terminal) now take
images, PDFs and text. A PDF is stored as its text layer with the mime
still saying application/pdf and its page count; anything else has to be
UTF-8 text. Documents travel inline as text parts, so CST_NO_IMAGES and
text-only models no longer drop them, while images keep their old path.
Tests: test_pdf_text (plain, bare and zlib-framed Flate, inline image,
CID + ToUnicode, object stream, error paths), documents in test_attach,
inlined documents in test_llm_body, mode in test_config.