ai-hats-client 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,54 @@
1
+ _version.py
2
+ __pycache__/
3
+ *.py[cod]
4
+ *$py.class
5
+ *.egg-info/
6
+ dist/
7
+ build/
8
+ .eggs/
9
+ *.egg
10
+ .venv/
11
+ venv/
12
+ .env
13
+ *.log
14
+ .mypy_cache/
15
+ .ruff_cache/
16
+ .pytest_cache/
17
+ .coverage
18
+ .coverage.*
19
+ coverage.xml
20
+ htmlcov/
21
+
22
+ # macOS finder metadata
23
+ .DS_Store
24
+
25
+ # uv.lock — local dev tool artifact, ai-hats is a library installed via pip
26
+ uv.lock
27
+
28
+ # Real-session fixtures — recorded from a developer's local Claude Code session,
29
+ # contain absolute home paths, real sessionId/requestId, subscription metadata,
30
+ # and unredacted user prompts. Tests that consume them auto-skip when absent.
31
+ # Generate locally for debugging; never commit. See HATS-343.
32
+ tests/fixtures/real_conversation.jsonl
33
+ tests/fixtures/real_trace.log
34
+
35
+ # ai-hats runtime (generated per-project)
36
+ .agent/
37
+ .githooks/
38
+ .gitlog/
39
+ ai-hats.yaml
40
+ profile.json # legacy, migrated to ai-hats.yaml
41
+ profile.json.bak
42
+ GEMINI.md
43
+ CLAUDE.md
44
+ .claude/skills/
45
+ .claude/scheduled_tasks.lock
46
+ .claude/settings.local.json
47
+ .claude/settings.json
48
+ .envrc
49
+ .gemini/
50
+ .cline/skills/
51
+ .cline/plugins/
52
+ .agy/
53
+
54
+ .agent/ai-hats/
@@ -0,0 +1,45 @@
1
+ # Changelog
2
+
3
+ All notable changes to `ai-hats-client` are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project adheres
5
+ to [Semantic Versioning](https://semver.org/).
6
+
7
+ ## [0.2.0]
8
+
9
+ ### Added
10
+
11
+ - The stub claude (`ai_hats_client.testing`) follows claude 2.1.283 further:
12
+ - every prompt line gets `command_lifecycle` on the wire (`queued`, `started`,
13
+ `completed`); a prompt folded into a running turn is echoed before it is
14
+ started;
15
+ - slash commands: `/model`, `/context`, `/usage` and `/rename` are answered by
16
+ a `<synthetic>` response before their echo, and `/tui`, `/login` and the
17
+ other refused ones get "isn't available in this environment" and no echo;
18
+ - `/clear` writes `conversation_reset`, then goes on under a new session id
19
+ and in a new transcript, and forgets the previous prompt.
20
+
21
+ ## [0.1.0]
22
+
23
+ ### Added
24
+
25
+ - `HeadlessSession`: start `ai-hats headless`, read its `headless/v1` header, send
26
+ a `prompt`, wait for the `turn_ended` that carries the prompt's id, and close or
27
+ abort the session. Every wait is bounded.
28
+ - `answer(call_id, decision, message=, answers=)` decides a question the
29
+ session put; `interrupt()` stops the running turn and keeps the session.
30
+ - A turn wait takes `on_question`. A question stays offered until it is answered,
31
+ its handler returns, or its call gets a result. With no handler the wait raises
32
+ `QuestionPending` instead of waiting out its bound. `tool_call(call_id)` returns
33
+ the call a question is about.
34
+ - A command the holder would refuse raises `ValueError` before it is sent; one the
35
+ holder does not run (per `header.commands`), or any command after the session
36
+ ended, raises `HeadlessError`.
37
+ - Leaving a `with` block terminates a holder that does not end within
38
+ `close_timeout`, then raises the timeout.
39
+ - Typed: the wheel carries `py.typed`.
40
+ - `ai_hats_client.testing`: a stand-in `claude` binary that speaks the
41
+ stream-json wire, so tests of a client run with no model and no login. It asks
42
+ questions (`@ask`, `@askq`, `@plan`) and runs long turns an interrupt can cut
43
+ (`@slow`, `@slowtool`). It drifts off the wire (`@drift`), hits the quota
44
+ (`@quota <status>`, reset at `QUOTA_RESETS_AT`), dies by a signal (`@kill`,
45
+ `@ignore-term`), and reports itself logged out under `LOGGED_OUT_ENV`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 muratovv
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,116 @@
1
+ Metadata-Version: 2.5
2
+ Name: ai-hats-client
3
+ Version: 0.2.0
4
+ Summary: Drive an ai-hats headless session over its stdin and stdout: turns, answers to its questions, interrupts.
5
+ Project-URL: Homepage, https://github.com/muratovv/ai-hats
6
+ Project-URL: Repository, https://github.com/muratovv/ai-hats
7
+ Project-URL: Changelog, https://github.com/muratovv/ai-hats/blob/master/packages/ai-hats-client/CHANGELOG.md
8
+ Author-email: muratovv <f@muratovv.me>
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-hats,claude,client,headless,session
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: POSIX
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
18
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
19
+ Requires-Python: >=3.13
20
+ Description-Content-Type: text/markdown
21
+
22
+ # ai-hats-client
23
+
24
+ Drive an `ai-hats headless` session from a program: send turns, wait for each
25
+ one to end, and read everything the session did.
26
+
27
+ `ai-hats headless` runs a role's interactive session with no terminal. Its stdin
28
+ takes commands, one JSON object per line (`commands/v1`). Its stdout opens with a
29
+ header line (`headless/v1`) and then copies the session's event log byte for
30
+ byte (`events/v1`). Its exit code says how the session ended. This package speaks
31
+ that contract and nothing else. It uses only the standard library and imports
32
+ nothing from ai-hats.
33
+
34
+ ```python
35
+ from ai_hats_client import HeadlessSession
36
+
37
+ with HeadlessSession.start(["ai-hats", "headless", "-r", "maintainer"]) as s:
38
+ first = s.turn("remember the word KIWI")
39
+ second = s.turn("which word?")
40
+ end = s.close()
41
+ assert "KIWI" in second.text and end.code == 0
42
+ ```
43
+
44
+ - `HeadlessSession.start(argv, cwd=, env=)` launches the holder and reads the
45
+ header. If the holder refuses before the start, it raises `SessionEnded`, which
46
+ carries the exit code.
47
+ - `prompt(text, id=None)` sends a turn and returns its id. `turn_for(id)` waits
48
+ for the `turn_ended` event that lists that id. `turn(text)` does both.
49
+ - `close()` closes stdin, which tells the holder to finish its turns and exit. It
50
+ then reads to the end and returns an `Exit` with the code, every event and the
51
+ holder's stderr. `terminate()` signals the holder instead.
52
+ - Leaving a `with` block closes the session. If the session does not end within
53
+ `start(..., close_timeout=60)`, the holder is terminated and the
54
+ `HeadlessTimeout` is raised, so no session keeps spending turns.
55
+
56
+ Every wait has a bound. Every error carries what was read so far and the tail of
57
+ the holder's stderr.
58
+
59
+ - A command the holder would refuse raises `ValueError` before it is sent: empty
60
+ text, an `id` that is not a lowercase canonical UUID, a `decision` other than
61
+ `allow` or `deny`.
62
+ - A command the holder does not run raises `HeadlessError`, and so does any
63
+ command after the session has ended. The holder lists what it runs in
64
+ `header.commands`.
65
+ - `prompt()`, `answer()` and `interrupt()` may be called from another thread, for
66
+ instance a watchdog that interrupts a turn while the main thread waits on it.
67
+ The waits themselves belong to one thread.
68
+
69
+ ## Questions and interrupts
70
+
71
+ A session asks its stdin owner what it would ask a person at the terminal: a
72
+ gate's `ask`, a consent point, claude's own permission prompt, leaving plan mode,
73
+ the model's own question. Each question arrives as a `person_asked` event with a
74
+ `call_id`, and the call waits for your answer.
75
+
76
+ ```python
77
+ def decide(question):
78
+ s.answer(question["call_id"], "allow") # or "deny", message="why"
79
+
80
+ turn = s.turn("push the branch", on_question=decide)
81
+ ```
82
+
83
+ - A turn wait hands each question to `on_question`. Without a handler it
84
+ raises `QuestionPending` instead of waiting out its bound. Answer
85
+ `pending.question["call_id"]` and wait again: nothing read so far is lost.
86
+ - `answer(call_id, "allow", answers={"<question>": "<answer>"})` answers a
87
+ question the model asked with `AskUserQuestion`. `tool_call(call_id)` returns
88
+ the call the question is about, with the questions and their options in its
89
+ `input`.
90
+ - A question stays offered until you answer it, its handler returns, or its call
91
+ gets a result: a handler that raised sees it again on the next wait.
92
+ - The first answer on a call id wins. The holder refuses the rest and logs a
93
+ `command_rejected` signal for each.
94
+ - `interrupt()` stops the running turn and keeps the session. The turn still
95
+ ends with its `turn_ended`, and a question it had open is closed.
96
+
97
+ The holder never times out a question. If you will not wait, answer `deny`.
98
+
99
+ The client is synchronous. A caller that needs asyncio runs it in a thread.
100
+
101
+ ## Testing without a model
102
+
103
+ `ai_hats_client.testing` has a stand-in `claude` binary that speaks the same wire.
104
+ Tests that use it need no model and no login:
105
+
106
+ ```python
107
+ import os
108
+
109
+ from ai_hats_client.testing import install
110
+
111
+ stub = install(tmp_path) # writes tmp_path/stub-bin/claude
112
+ env = {**os.environ, **stub.env(os.environ["PATH"])}
113
+ ```
114
+
115
+ Each prompt's text picks what the stub does. The directives are listed in the
116
+ module docstring of `ai_hats_client.testing.stub_claude`.
@@ -0,0 +1,95 @@
1
+ # ai-hats-client
2
+
3
+ Drive an `ai-hats headless` session from a program: send turns, wait for each
4
+ one to end, and read everything the session did.
5
+
6
+ `ai-hats headless` runs a role's interactive session with no terminal. Its stdin
7
+ takes commands, one JSON object per line (`commands/v1`). Its stdout opens with a
8
+ header line (`headless/v1`) and then copies the session's event log byte for
9
+ byte (`events/v1`). Its exit code says how the session ended. This package speaks
10
+ that contract and nothing else. It uses only the standard library and imports
11
+ nothing from ai-hats.
12
+
13
+ ```python
14
+ from ai_hats_client import HeadlessSession
15
+
16
+ with HeadlessSession.start(["ai-hats", "headless", "-r", "maintainer"]) as s:
17
+ first = s.turn("remember the word KIWI")
18
+ second = s.turn("which word?")
19
+ end = s.close()
20
+ assert "KIWI" in second.text and end.code == 0
21
+ ```
22
+
23
+ - `HeadlessSession.start(argv, cwd=, env=)` launches the holder and reads the
24
+ header. If the holder refuses before the start, it raises `SessionEnded`, which
25
+ carries the exit code.
26
+ - `prompt(text, id=None)` sends a turn and returns its id. `turn_for(id)` waits
27
+ for the `turn_ended` event that lists that id. `turn(text)` does both.
28
+ - `close()` closes stdin, which tells the holder to finish its turns and exit. It
29
+ then reads to the end and returns an `Exit` with the code, every event and the
30
+ holder's stderr. `terminate()` signals the holder instead.
31
+ - Leaving a `with` block closes the session. If the session does not end within
32
+ `start(..., close_timeout=60)`, the holder is terminated and the
33
+ `HeadlessTimeout` is raised, so no session keeps spending turns.
34
+
35
+ Every wait has a bound. Every error carries what was read so far and the tail of
36
+ the holder's stderr.
37
+
38
+ - A command the holder would refuse raises `ValueError` before it is sent: empty
39
+ text, an `id` that is not a lowercase canonical UUID, a `decision` other than
40
+ `allow` or `deny`.
41
+ - A command the holder does not run raises `HeadlessError`, and so does any
42
+ command after the session has ended. The holder lists what it runs in
43
+ `header.commands`.
44
+ - `prompt()`, `answer()` and `interrupt()` may be called from another thread, for
45
+ instance a watchdog that interrupts a turn while the main thread waits on it.
46
+ The waits themselves belong to one thread.
47
+
48
+ ## Questions and interrupts
49
+
50
+ A session asks its stdin owner what it would ask a person at the terminal: a
51
+ gate's `ask`, a consent point, claude's own permission prompt, leaving plan mode,
52
+ the model's own question. Each question arrives as a `person_asked` event with a
53
+ `call_id`, and the call waits for your answer.
54
+
55
+ ```python
56
+ def decide(question):
57
+ s.answer(question["call_id"], "allow") # or "deny", message="why"
58
+
59
+ turn = s.turn("push the branch", on_question=decide)
60
+ ```
61
+
62
+ - A turn wait hands each question to `on_question`. Without a handler it
63
+ raises `QuestionPending` instead of waiting out its bound. Answer
64
+ `pending.question["call_id"]` and wait again: nothing read so far is lost.
65
+ - `answer(call_id, "allow", answers={"<question>": "<answer>"})` answers a
66
+ question the model asked with `AskUserQuestion`. `tool_call(call_id)` returns
67
+ the call the question is about, with the questions and their options in its
68
+ `input`.
69
+ - A question stays offered until you answer it, its handler returns, or its call
70
+ gets a result: a handler that raised sees it again on the next wait.
71
+ - The first answer on a call id wins. The holder refuses the rest and logs a
72
+ `command_rejected` signal for each.
73
+ - `interrupt()` stops the running turn and keeps the session. The turn still
74
+ ends with its `turn_ended`, and a question it had open is closed.
75
+
76
+ The holder never times out a question. If you will not wait, answer `deny`.
77
+
78
+ The client is synchronous. A caller that needs asyncio runs it in a thread.
79
+
80
+ ## Testing without a model
81
+
82
+ `ai_hats_client.testing` has a stand-in `claude` binary that speaks the same wire.
83
+ Tests that use it need no model and no login:
84
+
85
+ ```python
86
+ import os
87
+
88
+ from ai_hats_client.testing import install
89
+
90
+ stub = install(tmp_path) # writes tmp_path/stub-bin/claude
91
+ env = {**os.environ, **stub.env(os.environ["PATH"])}
92
+ ```
93
+
94
+ Each prompt's text picks what the stub does. The directives are listed in the
95
+ module docstring of `ai_hats_client.testing.stub_claude`.
@@ -0,0 +1,35 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "ai-hats-client"
7
+ version = "0.2.0"
8
+ description = "Drive an ai-hats headless session over its stdin and stdout: turns, answers to its questions, interrupts."
9
+ readme = "README.md"
10
+ requires-python = ">=3.13"
11
+ license = "MIT"
12
+ license-files = ["LICENSE"]
13
+ authors = [
14
+ { name = "muratovv", email = "f@muratovv.me" }, # ai-hats: allow-secret
15
+ ]
16
+ keywords = ["headless", "session", "client", "claude", "ai-hats"]
17
+ classifiers = [
18
+ "Development Status :: 4 - Beta",
19
+ "Intended Audience :: Developers",
20
+ "Operating System :: POSIX",
21
+ "Programming Language :: Python :: 3",
22
+ "Programming Language :: Python :: 3.13",
23
+ "Programming Language :: Python :: 3.14",
24
+ "Topic :: Software Development :: Libraries :: Python Modules",
25
+ ]
26
+ # Standard library only: a client relies on the holder's pipes and exit code, never on ai-hats.
27
+ dependencies = []
28
+
29
+ [project.urls]
30
+ Homepage = "https://github.com/muratovv/ai-hats"
31
+ Repository = "https://github.com/muratovv/ai-hats"
32
+ Changelog = "https://github.com/muratovv/ai-hats/blob/master/packages/ai-hats-client/CHANGELOG.md"
33
+
34
+ [tool.hatch.build.targets.wheel]
35
+ packages = ["src/ai_hats_client"]
@@ -0,0 +1,35 @@
1
+ """Drive an ``ai-hats headless`` session through its pipes, knowing nothing but its contract."""
2
+
3
+ from .session import (
4
+ Event,
5
+ Exit,
6
+ Header,
7
+ HeadlessError,
8
+ HeadlessSession,
9
+ HeadlessTimeout,
10
+ OnQuestion,
11
+ ProtocolError,
12
+ QuestionPending,
13
+ SessionEnded,
14
+ Turn,
15
+ answer_command,
16
+ interrupt_command,
17
+ prompt_command,
18
+ )
19
+
20
+ __all__ = [
21
+ "Event",
22
+ "Exit",
23
+ "Header",
24
+ "HeadlessError",
25
+ "HeadlessSession",
26
+ "HeadlessTimeout",
27
+ "OnQuestion",
28
+ "ProtocolError",
29
+ "QuestionPending",
30
+ "SessionEnded",
31
+ "Turn",
32
+ "answer_command",
33
+ "interrupt_command",
34
+ "prompt_command",
35
+ ]
File without changes