Repository navigation
agenttalk 0.99.0
In short: this release makes the usage readings real. agenttalk capacity, each seat's
capacity file and the dashboard now show each account's actual 5-hour and weekly usage, say
plainly when a reading is old, and never show one account's usage under another seat's name.
The dashboard now refuses requests that are not addressed to it as a local program, so a web
page you have open can no longer read it through a trick called DNS rebinding.
agenttalk janitor --apply no longer forces a delete. The dev gate records how long each test
took, and Windows test runs get more time for a month. The gateway and its ledger are unchanged
in this release, so upgrading needs no extra step.
Changed
-
Every test run in the dev gate now records how long each test took, and Windows test
runs get more time for a month (#378). On Windows, the gate's full test runs had reached
their two-hour limit: on 2026-10-06 one was stopped at 90% with no failure, and its twin
passed 15 seconds under the limit. No per-test timings existed to decide what to change.What you will notice: before this, a test run's log listed only skipped tests and the
result. Now it also lists the time of every test's setup, call and teardown, slowest first,
before the result line. That adds about 3.5 MiB to each log, well under its 16 MiB limit. A run
stopped by its time limit says its per-test durations are unavailable, so it is never read
as zero. Windows test runs may now take 9,000 seconds instead of 7,200, and the Windows CI
job 330 minutes instead of 270. Linux and macOS stay at 7,200 seconds and 90 minutes, and
no individual test's time limit changed.What you need to do: nothing. The Windows margin is temporary: it expires on 2026-11-06,
by when the new timings should show how to split or speed up the Windows runs. The reason
and the date are recorded next to the values (windows_timeout_reasonin
dev-gate.json, and the comment in.github/workflows/tests.yml).Technical details:
dev-gate.json(pytestargsadd--durations=0 --durations-min=0;
newwindows_timeout_seconds9000 andwindows_timeout_reason);src/agenttalk/dev_gate.py:- the manifest contract now pins the four pytest arguments;
_validate_windows_pytest_limitaccepts the Windows value only together with its reason,
and never belowtimeout_seconds;_pytest_timeout_secondschooses it on Windows (a CI leg is refused unless it runs on its
declared OS);run_commandtakes atimeout_note, and pytest runs pass
PYTEST_DURATIONS_UNAVAILABLE.
The Windows job ceiling is in
.github/workflows/tests.yml, anddocs/DEV-GATE.mdhas a
new "Per-test durations and time limits" section. Tests intests/test_dev_gate.pyand
tests/test_dev_gate_workflows.pyinclude a real pytest run (the final result line stays in
the 2,000-character diagnostic) and a real killed run.
Fixed
-
Usage readings are real again, say when they are old, and are shown per account (#301).
agenttalk capacityand the per-seat capacity files say how full each seat's 5-hour and
weekly usage windows are. On the desktop fleet none of those numbers was real: every Claude
seat showed the same reading from 3 June, every Codex seat had none, and the files looked
fresh because they were rewritten every minute. A lead deciding whom to give work to was
shown old or missing numbers that looked current.What you will notice, for Claude seats: before this, a seat's reading came only from a
status-line file that had not been written since June, and it was shown as if it were
current. Now a wrapped Claude seat records its own rate-limit messages from Claude. They say
whether a request was allowed and, when they include them, how full each window is. A
message without a percentage is recorded without one, and a message that names no window
(an ordinary turn's "allowed") is kept as the latest verdict; agenttalk never makes up
either. A malformed figure in such a message is ignored and never stops the seat's turn,
and a reset time outside any real date is dropped, so it can no longer break the web console
oragenttalk status.
The status-line file is only a fallback, read from the same Claude config folder the seat's
Claude uses. A saved reading remembers the account it was taken under and is dropped, never
relabelled, when the seat runs under another folder or provider, so a gateway seat is never
shown the operator's Claude numbers. With no source at all, it says "Claude capacity source
not configured" instead of a number. When Claude refuses a turn because a usage limit is
reached, the seat publishes that reading right away, as it starts waiting, not only at its
next quiet minute. A manualagenttalk capacity refresh --for <seat>run by the seat itself
reads its own folder and provider, found the same way its wrapper finds them. Run for another
seat, it never reads a folder of the shell that runs it, because that shell's~/.claudeand
CLAUDE_CONFIG_DIRsay nothing about the other seat's. It publishes that seat's own saved
reading again, under the account its wrapper bound, or reads the--statusline-pathyou give.
A folder is read only when it matches the account of the seat's saved reading. Otherwise it
publishes "unknown" (claude_seat_source_unknown), with a hint to pass--statusline-pathor
let the seat's wrapper refresh it. A seat whose supervisor entry gives it its own
CLAUDE_CONFIG_DIRis refreshed the same way.For Codex seats: before this, a seat without a Codex home of its own reported nothing, with
the reasoncodex_home_missing. Now it reads the shared Codex home, but only its own session
there, found by its thread id; a manual refresh uses only the thread the seat's wrapper saved.
It never shows another session's numbers; without a thread it says so
(codex_no_thread_yet). A Codex home, or its sessions folder, that is only a link to the
shared one counts as the shared one. One limit remains: separate Codex homes seeded from one
login show as separate accounts, although they share one budget.For everyone: each figure counts for 10 minutes from when it was seen. After that every
reader hides it, even when nobody rewrote the file:agenttalk capacity,agenttalk status
(which now showscapacity=current,capacity=stale(...)orcapacity=unknown(...)for each
seat), the web console andagenttalk attention. The next time the seat publishes a reading,
the expired figures are also removed from the file. A refusal flag expires with the window
it came from, and the latest verdict keeps the status and the window it named, so a refused
window is known even when its percentage reads low. Each reading
names the account it belongs to: the provider, the OS user and the folder the reading comes
from, the default folder too, so two user homes are never one account.agenttalk capacitylists the seats
that share an account under that account, each with its own reading and its age, newest
first; readings are never merged across seats. A seat's conversation fill is shown only when
it is known to be that seat's own. A manualagenttalk checkpoint savestill records the
status-line file's fill, as before this change, because a manual save cannot tell which
conversation it runs in; a save from Claude's own hook keeps it only when it names that
conversation. A fill older than 10 minutes is not recorded at all. A reading also says whether a window's length was reported
by the provider or filled in by agenttalk, and the file carries a version number. The
README's new section "Reading the capacity files" lists the fields other programs may rely on.What you need to do: nothing. The readings stay advice only: nothing waits for them or is
held back by them. To judge an account, read its newest seat's row. A wrapped Claude seat
usually shows no conversation fill: its own messages do not carry it, and the status-line
file's fill counts only when it names the seat's own session.Technical details:
src/agenttalk/capacity.py:claude_stream_reading,read_claude_stream,account_key,claude_account,
current_view(the read-time, per-part check every consumer uses) andfor_publication;read_localtakesstream=,now=,claude_home=,provider=,session_id=and
any_session_context=, and rejects a stream reading whosebindingdiffers from the
current account;_same_pathandaccount_keyresolve links first (os.path.realpath);account_key
always hashes the folder read,~/.claudeand~/.codexincluded;usable_epochaccepts a reset time only from 0 to the last second of year 9999, at
ingestion (stream, status line, Codex rollout) and in the readers (web._capacity_window,
cli._reset_in_minutes);current_viewdrops a saved refusal type that is not a window
name;detect_sourceis shared with the manual refresh;- numbers are parsed finite-only; the status-line read takes its time and content from the
same version of the file; - a thread's rollout is found by the name suffix
-<thread>.jsonlanywhere in the tree, or
by itssession_metaid (non-object records skipped), withCODEX_SHARED_SCAN_LIMIT4096
for the shared home; - new snapshot fields
primary_status,primary_window_basisandprimary_observed_at
(the same three forsecondary),last_status,last_status_at,last_status_window,
scope,accountand
schema_version= 2.
src/agenttalk/wrapper/session.py:SessionState.claude_rate_limit(with itsbinding),
folded inobserve_event; a failure there leaves no reading but keeps the binding; the
adapter's handling of a refused request is unchanged.src/agenttalk/wrapper/run.py:child_claude_config_dir, shared with_child_env.src/agenttalk/checkpoint.py: passes only a hook's own session id to the reading; a manual
save reads the fill withany_session_context=True;collect_contextkeeps only what
for_publicationkeeps.src/agenttalk/wrapper/loop.py:_park_usage_limitpublishes the capacity reading at once
after the failed turn (_maybe_refresh_capacity(..., force=True)), and on its interval on
later parked polls.src/agenttalk/web.py:_capacity_entryreads throughcurrent_view.src/agenttalk/cli.py:- the wrapper binds the child's readings at start and passes its config folder, provider,
session id and thread tocapacity_refresh; - manual refresh uses the seat's saved thread, session and reading, never the caller's thread;
for Claude (_manual_claude_snapshot), the seat's own folder only whenAGENTTALK_SELFis
the seat, otherwise its saved bound reading or an explicit--statusline-path, or "unknown"; capacity showgroups byaccountand lists each seat's own row;- the
capacityrow instatusandstatus --json, and attention, read throughcurrent_view.
- the wrapper binds the child's readings at start and passes its config folder, provider,
- Tests:
test_301_*intests/test_capacity.py,tests/test_cli.py,
tests/test_checkpoint.pyandtests/test_wrapper_loop.py, built on the recorded real rate-limit events in
tests/golden_stop_retries_scenarios.pyand the real capture intests/test_wrapper_claude.py.
The fixture of an older attention test (tests/test_attention_cli.py) is now fresh.
-
agenttalk janitor --applyno longer forces a delete, so it can no longer delete files
outside an old folder (#342). When the ordinary delete of an old temporary folder failed,
janitor tried harder on Windows: it took ownership of everything in the folder, granted
itself full rights, and mirrored an empty folder over it. All three steps walk into a folder
link (a junction or a symbolic link to a folder) inside the folder, and the mirror deleted
whatever the link pointed to: other projects, tool installs, anything. It needed a link and
a failed delete, so it was rare, but the loss was real and silent.What you will notice: before this, a folder the ordinary delete could not remove went
through that stronger fallback and was reported asremoved-after-aclor
removed-after-robocopywhen it worked, or listed by its full path with a hint to re-run
elevated when it did not. Now janitor only uses the ordinary delete:-
Before each delete, without following links, it checks three things:
- The scanned folder and every folder below it, down to the old folder's parent, must
still be plain folders. A folder link (junction, symbolic link or other reparse point)
there keeps the old folder. - An old folder that was not a link when found and is one now is kept and reported.
- An old folder that was already a link when found is removed as the link itself, and
what it points to is left alone. If it is no longer a link, it is kept.
The check compares link or not-link status with what the scan recorded; it does not
prove it is the same folder. A check that fails keeps the folder. - The scanned folder and every folder below it, down to the old folder's parent, must
-
The ordinary delete removes a folder link found inside the old folder as the link
itself, without entering it. -
A folder it cannot remove is kept and listed under
FAILED. It is named relative to
the scanned folder it was found in (for example[scratch] dev-9\old-task), with the
reason the system gave (for exampleAccess is denied (at sub\locked.txt)). -
The remaining files are left as they are after the failed attempt. Files removed
before the failure stay removed. Read-only flags may have been cleared, because the
ordinary delete clears them. There is no ownership or permission takeover. -
absentandremovednow need a confirmed not-found. A folder whose details cannot
be read isFAILED, notabsent. -
Locations janitor could not list are also named relative to their scanned folder, and
their error text no longer repeats the file name. -
The summary counts are now only
removed,absent,refusedandFAILED.
The limit: these checks cannot see a folder swapped for a link by another process during
the delete itself. agenttalk is a trusted, single-user local tool, and janitor runs while
no other process rewrites that tree, so such a swap is out of scope.What you need to do: nothing for ordinary folders. For each
FAILEDfolder, look at what it
holds, then remove it yourself.Technical details:
src/agenttalk/janitor.py:remove_stubbornand its takeown, icacls and robocopy steps are removed;- new
remove_plainly,_exists(onlyFileNotFoundErrororNotADirectoryErrormeans
not there),_path_change_reason(an lstat on the scanned root and each folder below
it down to the candidate's parent, plus the candidate's link-or-not status compared
withCandidate.link, recorded at discovery) and
_scanned_root_relative.
Tests in
tests/test_janitor.py:- they force the ordinary delete to fail on an old folder holding a nested junction or
directory symlink, and check byte for byte that nothing beyond the link changes; - they swap the scanned root, a parent folder or the candidate for a junction after
discovery, and check that nothing is deleted; - they deny the metadata reads before and after the delete;
- the residual swap inside the delete is kept as an expected failure (xfail) that names
the limit.
docs/ops/scratch-hygiene.mdand the README are updated. -
Security
- The dashboard now refuses requests that are not addressed to it as a local program. The dashboard
(agenttalk dashboardandagenttalk serve) only ever listened on your own computer, but it answered
every request whatever name the request said it was for. A web page you had open could use a trick called
DNS rebinding to make your browser ask the dashboard for its data under a made-up website name, and then
read the answer. That included the status page and, with--enable-budget, this machine's spending
figures. Now every request must name a loopback address (for example127.0.0.1,localhostor[::1])
together with the dashboard's own port (an omitted port means 80); anything else gets a short "forbidden"
answer (403), the same for every page, so a web page cannot even tell which pages exist. A request with no
name at all, with two names, or with a name that is not exactly a host and an optional port gets a 400, and
so does a malformed full-URL request target; a full-URL target is checked the same way for every kind of
request. This applies to every page, file and/apiroute, to every kind of request, and to routes switched on by an option. The
checks on actions (a matchingOriginand the other headers) are unchanged. What you will notice: nothing
in normal use. If you reach the dashboard through an SSH tunnel, use the same port number on both ends (for
example-L 8765:127.0.0.1:8765), because the browser will now be checked against the dashboard's own port.
Technical details: one check inparse_requestinsrc/agenttalk/web.py(it reuses_host_allowed), tests
intests/test_web_host_gate.py; issue #379.
Also in this release: a Windows test trial (not a release gate)
The test workflow gained a separate, non-voting Windows trial: eight extra jobs that run the gate's tests from the source tree and from the built package as separate jobs, plus a summary job (the windows-mode-trial matrix in .github/workflows/tests.yml and the agenttalk.dev_gate_trial runner). It measures whether splitting the slow Windows runs helps; it never blocks a release, and it adds CI workload while it runs. A change awaiting merge (#405) limits it to pushes to master.