Skip to content

agenttalk 0.99.0

Choose a tag to compare

@zoolok17 zoolok17 released this 08 Oct 14:29
· 630 commits to master since this release
5c5503c

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_reason in
    dev-gate.json, and the comment in .github/workflows/tests.yml).

    Technical details: dev-gate.json (pytest args add --durations=0 --durations-min=0;
    new windows_timeout_seconds 9000 and windows_timeout_reason); src/agenttalk/dev_gate.py:

    • the manifest contract now pins the four pytest arguments;
    • _validate_windows_pytest_limit accepts the Windows value only together with its reason,
      and never below timeout_seconds;
    • _pytest_timeout_seconds chooses it on Windows (a CI leg is refused unless it runs on its
      declared OS);
    • run_command takes a timeout_note, and pytest runs pass
      PYTEST_DURATIONS_UNAVAILABLE.

    The Windows job ceiling is in .github/workflows/tests.yml, and docs/DEV-GATE.md has a
    new "Per-test durations and time limits" section. Tests in tests/test_dev_gate.py and
    tests/test_dev_gate_workflows.py include 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 capacity and 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
    or agenttalk 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 manual agenttalk 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 ~/.claude and
    CLAUDE_CONFIG_DIR say 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-path you 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-path or
    let the seat's wrapper refresh it. A seat whose supervisor entry gives it its own
    CLAUDE_CONFIG_DIR is refreshed the same way.

    For Codex seats: before this, a seat without a Codex home of its own reported nothing, with
    the reason codex_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 shows capacity=current, capacity=stale(...) or capacity=unknown(...) for each
    seat), the web console and agenttalk 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 capacity lists 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 manual agenttalk checkpoint save still 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) and for_publication;
      • read_local takes stream=, now=, claude_home=, provider=, session_id= and
        any_session_context=, and rejects a stream reading whose binding differs from the
        current account;
      • _same_path and account_key resolve links first (os.path.realpath); account_key
        always hashes the folder read, ~/.claude and ~/.codex included;
      • usable_epoch accepts 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_view drops a saved refusal type that is not a window
        name; detect_source is 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>.jsonl anywhere in the tree, or
        by its session_meta id (non-object records skipped), with CODEX_SHARED_SCAN_LIMIT 4096
        for the shared home;
      • new snapshot fields primary_status, primary_window_basis and primary_observed_at
        (the same three for secondary), last_status, last_status_at, last_status_window,
        scope, account and
        schema_version = 2.
    • src/agenttalk/wrapper/session.py: SessionState.claude_rate_limit (with its binding),
      folded in observe_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 with any_session_context=True; collect_context keeps only what
      for_publication keeps.
    • src/agenttalk/wrapper/loop.py: _park_usage_limit publishes 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_entry reads through current_view.
    • src/agenttalk/cli.py:
      • the wrapper binds the child's readings at start and passes its config folder, provider,
        session id and thread to capacity_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 when AGENTTALK_SELF is
        the seat, otherwise its saved bound reading or an explicit --statusline-path, or "unknown";
      • capacity show groups by account and lists each seat's own row;
      • the capacity row in status and status --json, and attention, read through current_view.
    • Tests: test_301_* in tests/test_capacity.py, tests/test_cli.py,
      tests/test_checkpoint.py and tests/test_wrapper_loop.py, built on the recorded real rate-limit events in
      tests/golden_stop_retries_scenarios.py and the real capture in tests/test_wrapper_claude.py.
      The fixture of an older attention test (tests/test_attention_cli.py) is now fresh.
  • agenttalk janitor --apply no 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 as removed-after-acl or
    removed-after-robocopy when 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 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 example Access 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.

    • absent and removed now need a confirmed not-found. A folder whose details cannot
      be read is FAILED, not absent.

    • 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, refused and FAILED.

    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 FAILED folder, look at what it
    holds, then remove it yourself.

    Technical details: src/agenttalk/janitor.py:

    • remove_stubborn and its takeown, icacls and robocopy steps are removed;
    • new remove_plainly, _exists (only FileNotFoundError or NotADirectoryError means
      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
      with Candidate.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.md and the README are updated.

Security

  • The dashboard now refuses requests that are not addressed to it as a local program. The dashboard
    (agenttalk dashboard and agenttalk 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 example 127.0.0.1, localhost or [::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 /api route, to every kind of request, and to routes switched on by an option. The
    checks on actions (a matching Origin and 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 in parse_request in src/agenttalk/web.py (it reuses _host_allowed), tests
    in tests/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.