python-agent-harness 1.5.5.9__tar.gz → 1.5.6.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.
Files changed (94) hide show
  1. {python_agent_harness-1.5.5.9/python_agent_harness.egg-info → python_agent_harness-1.5.6.0}/PKG-INFO +46 -3
  2. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/README.md +44 -1
  3. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/pyproject.toml +2 -6
  4. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/__init__.py +1 -1
  5. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/agent.py +88 -4
  6. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/entry/cli.py +93 -0
  7. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/entry/controller.py +22 -2
  8. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/entry/headless.py +198 -40
  9. python_agent_harness-1.5.6.0/python_agent_harness/entry/server.py +534 -0
  10. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/llm/client.py +2 -12
  11. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/agent.md +1 -2
  12. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/subagent.md +1 -2
  13. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/session/config.py +40 -14
  14. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/session/session.py +7 -1
  15. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/__init__.py +4 -16
  16. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/base.py +1 -1
  17. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/bash.py +1 -3
  18. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/diffapply.py +8 -3
  19. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/edit.py +3 -2
  20. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/filesystem.py +97 -6
  21. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/insert.py +3 -2
  22. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/lsp.py +0 -2
  23. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/write.py +8 -6
  24. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tui/commands.py +1 -1
  25. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tui/input.py +11 -16
  26. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0/python_agent_harness.egg-info}/PKG-INFO +46 -3
  27. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness.egg-info/SOURCES.txt +1 -4
  28. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness.egg-info/requires.txt +1 -0
  29. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/tests/test_invariants.py +1 -1
  30. python_agent_harness-1.5.5.9/python_agent_harness/tools/bash_win.py +0 -232
  31. python_agent_harness-1.5.5.9/python_agent_harness/tools/edit_win.py +0 -56
  32. python_agent_harness-1.5.5.9/python_agent_harness/tools/glob_win.py +0 -104
  33. python_agent_harness-1.5.5.9/python_agent_harness/tools/grep_win.py +0 -135
  34. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/LICENSE +0 -0
  35. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/__main__.py +0 -0
  36. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/__init__.py +0 -0
  37. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/context_manager.py +0 -0
  38. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/models.py +0 -0
  39. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/planmode.py +0 -0
  40. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/subagent.py +0 -0
  41. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/token_estimator.py +0 -0
  42. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/core/tool_runner.py +0 -0
  43. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/entry/__init__.py +0 -0
  44. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/entry/view.py +0 -0
  45. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/io/__init__.py +0 -0
  46. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/io/attachments.py +0 -0
  47. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/io/diffrender.py +0 -0
  48. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/io/persistence.py +0 -0
  49. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/io/text_filter.py +0 -0
  50. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/llm/__init__.py +0 -0
  51. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/lsp/__init__.py +0 -0
  52. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/lsp/client.py +0 -0
  53. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/lsp/config.py +0 -0
  54. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/lsp/manager.py +0 -0
  55. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/mcp/__init__.py +0 -0
  56. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/mcp/client.py +0 -0
  57. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/mcp/config.py +0 -0
  58. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/mcp/manager.py +0 -0
  59. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/__init__.py +0 -0
  60. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/agents/reviewer.md +0 -0
  61. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/build-switch.md +0 -0
  62. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/commands/explain.md +0 -0
  63. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/compact.md +0 -0
  64. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/core.py +0 -0
  65. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/initialize.md +0 -0
  66. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/plan-mode.md +0 -0
  67. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/plan.md +0 -0
  68. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/review.md +0 -0
  69. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/summary.md +0 -0
  70. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/task-completion-rules.md +0 -0
  71. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/prompts/title.md +0 -0
  72. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/session/__init__.py +0 -0
  73. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/session/commands.py +0 -0
  74. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/agent_tool.py +0 -0
  75. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/edit_mac.py +0 -0
  76. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/glob.py +0 -0
  77. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/glob_mac.py +0 -0
  78. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/grep.py +0 -0
  79. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/grep_mac.py +0 -0
  80. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/mcp.py +0 -0
  81. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/mkdir.py +0 -0
  82. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/planexit.py +0 -0
  83. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/question.py +0 -0
  84. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/read.py +0 -0
  85. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/skill.py +0 -0
  86. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tools/todo.py +0 -0
  87. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tui/__init__.py +0 -0
  88. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tui/core.py +0 -0
  89. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness/tui/render.py +0 -0
  90. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness.egg-info/dependency_links.txt +0 -0
  91. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness.egg-info/entry_points.txt +0 -0
  92. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/python_agent_harness.egg-info/top_level.txt +0 -0
  93. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/setup.cfg +0 -0
  94. {python_agent_harness-1.5.5.9 → python_agent_harness-1.5.6.0}/tests/test_scenarios.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-agent-harness
3
- Version: 1.5.5.9
3
+ Version: 1.5.6.0
4
4
  Summary: A lightweight, hackable mini-OpenCode written in Python.
5
5
  Author-email: Huming Chen <chenhuming@gmail.com>
6
6
  License-Expression: MIT
@@ -9,7 +9,6 @@ Classifier: Development Status :: 5 - Production/Stable
9
9
  Classifier: Environment :: Console
10
10
  Classifier: Intended Audience :: Developers
11
11
  Classifier: Operating System :: MacOS
12
- Classifier: Operating System :: Microsoft :: Windows
13
12
  Classifier: Operating System :: POSIX :: Linux
14
13
  Classifier: Programming Language :: Python :: 3.11
15
14
  Classifier: Programming Language :: Python :: 3.12
@@ -20,6 +19,7 @@ Description-Content-Type: text/markdown
20
19
  License-File: LICENSE
21
20
  Requires-Dist: rich>=13.0
22
21
  Requires-Dist: httpx>=0.27
22
+ Requires-Dist: certifi>=2024.0
23
23
  Requires-Dist: prompt_toolkit>=3.0
24
24
  Provides-Extra: mcp
25
25
  Requires-Dist: mcp<3,>=2.0; extra == "mcp"
@@ -113,6 +113,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
113
113
  - **MCP support** — optional MCP integration through the `[mcp]` extra. MCP tools become ordinary agent tools such as `mcp__<server>__<tool>`. Supports `stdio`, `streamable-http`, and `sse` transports.
114
114
  - **Slash commands** — built-in `/init`, `/review`, `/explain`, and other commands, plus custom commands loaded from `prompts/commands/*.md`.
115
115
  - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent prompt files live in `prompts/agents/*.md`. Use `default_agent` in the config file to start sessions with a specific agent.
116
+ - **Embeddable runtime boundaries** — drive the agent from programs: `headless --json` for one-shot CI/scripting (write-only JSONL stream), `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
116
117
 
117
118
  ## Inspired by opencode
118
119
 
@@ -281,6 +282,48 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
281
282
  - Exit code is 0 on success, 1 when the prompt was empty (only failed
282
283
  `@file` references), the run raised an agent error, or the restore failed.
283
284
 
285
+ ### Serve mode (resident JSONL server)
286
+
287
+ ```sh
288
+ python-agent-harness serve [--project DIR] [--answer-timeout SECONDS]
289
+ ```
290
+
291
+ A persistent, bidirectional runtime boundary for hosting applications
292
+ (a web backend, an IDE, a CI driver). Unlike `headless --json` (one
293
+ prompt per process, write-only stream), `serve` keeps the
294
+ `Controller`/`Session` resident and speaks a request/response protocol
295
+ over stdin/stdout — the same process boundary (containerizable), but
296
+ the host can:
297
+
298
+ - submit multiple prompts over the process's lifetime — no per-turn
299
+ interpreter spawn, and conversation history is retained between them
300
+ (multi-turn memory);
301
+ - answer the agent's mid-run questions (the `Question` tool and
302
+ plan-exit confirmation) via an `answer` op;
303
+ - cancel a run as a protocol message (no signal semantics).
304
+
305
+ Protocol (one JSON object per line):
306
+
307
+ ```
308
+ host → agent: {"op": "submit", "prompt": ..., "run_id": ...}
309
+ {"op": "answer", "run_id": ..., "answers": [...]}
310
+ {"op": "cancel", "run_id": ...}
311
+ {"op": "ping"} | {"op": "shutdown"}
312
+ agent → host: {"type": "ready"} first line
313
+ {"seq": N, "type": "start"|"delta"|"notify"|"log", "run_id": ...}
314
+ {"seq": N, "type": "result", "run_id": ..., "answer": ...,
315
+ "errors": [...], "usage": {...}, "cancelled": bool}
316
+ {"type": "error", "error": ...} protocol failures
317
+ ```
318
+
319
+ A mid-run question arrives as a `notify` with `kind: "ask"` (data has
320
+ `kind: "ask"|"confirm"`); reply with `answer`. One run at a time; a
321
+ `submit` while one is active is rejected with an `error` line.
322
+ `--answer-timeout SECONDS` bounds how long a pending question waits
323
+ for the host's answer (default: forever). The result line's shape is
324
+ identical to `headless --json`'s, so a driver can speak both
325
+ protocols with one parser.
326
+
284
327
  ### Slash commands
285
328
 
286
329
  | Command | Description |
@@ -335,7 +378,7 @@ You are a personal assistant. You do NOT modify files or run shell commands.
335
378
 
336
379
  ## Development
337
380
 
338
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
381
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and MacOS.
339
382
 
340
383
  ```sh
341
384
  make test # unit tests
@@ -79,6 +79,7 @@ Edit `~/.config/python-agent-harness/config.json` and set your `base_url`, `api_
79
79
  - **MCP support** — optional MCP integration through the `[mcp]` extra. MCP tools become ordinary agent tools such as `mcp__<server>__<tool>`. Supports `stdio`, `streamable-http`, and `sse` transports.
80
80
  - **Slash commands** — built-in `/init`, `/review`, `/explain`, and other commands, plus custom commands loaded from `prompts/commands/*.md`.
81
81
  - **Custom agents** — switch the main agent's system prompt at runtime with `/agent`. Agent prompt files live in `prompts/agents/*.md`. Use `default_agent` in the config file to start sessions with a specific agent.
82
+ - **Embeddable runtime boundaries** — drive the agent from programs: `headless --json` for one-shot CI/scripting (write-only JSONL stream), `serve` for hosting apps (resident process, bidirectional protocol: multi-turn memory, mid-run Q&A, protocol-level cancel).
82
83
 
83
84
  ## Inspired by opencode
84
85
 
@@ -247,6 +248,48 @@ Interactive prompts are auto-answered (`confirm` → yes, `ask` →
247
248
  - Exit code is 0 on success, 1 when the prompt was empty (only failed
248
249
  `@file` references), the run raised an agent error, or the restore failed.
249
250
 
251
+ ### Serve mode (resident JSONL server)
252
+
253
+ ```sh
254
+ python-agent-harness serve [--project DIR] [--answer-timeout SECONDS]
255
+ ```
256
+
257
+ A persistent, bidirectional runtime boundary for hosting applications
258
+ (a web backend, an IDE, a CI driver). Unlike `headless --json` (one
259
+ prompt per process, write-only stream), `serve` keeps the
260
+ `Controller`/`Session` resident and speaks a request/response protocol
261
+ over stdin/stdout — the same process boundary (containerizable), but
262
+ the host can:
263
+
264
+ - submit multiple prompts over the process's lifetime — no per-turn
265
+ interpreter spawn, and conversation history is retained between them
266
+ (multi-turn memory);
267
+ - answer the agent's mid-run questions (the `Question` tool and
268
+ plan-exit confirmation) via an `answer` op;
269
+ - cancel a run as a protocol message (no signal semantics).
270
+
271
+ Protocol (one JSON object per line):
272
+
273
+ ```
274
+ host → agent: {"op": "submit", "prompt": ..., "run_id": ...}
275
+ {"op": "answer", "run_id": ..., "answers": [...]}
276
+ {"op": "cancel", "run_id": ...}
277
+ {"op": "ping"} | {"op": "shutdown"}
278
+ agent → host: {"type": "ready"} first line
279
+ {"seq": N, "type": "start"|"delta"|"notify"|"log", "run_id": ...}
280
+ {"seq": N, "type": "result", "run_id": ..., "answer": ...,
281
+ "errors": [...], "usage": {...}, "cancelled": bool}
282
+ {"type": "error", "error": ...} protocol failures
283
+ ```
284
+
285
+ A mid-run question arrives as a `notify` with `kind: "ask"` (data has
286
+ `kind: "ask"|"confirm"`); reply with `answer`. One run at a time; a
287
+ `submit` while one is active is rejected with an `error` line.
288
+ `--answer-timeout SECONDS` bounds how long a pending question waits
289
+ for the host's answer (default: forever). The result line's shape is
290
+ identical to `headless --json`'s, so a driver can speak both
291
+ protocols with one parser.
292
+
250
293
  ### Slash commands
251
294
 
252
295
  | Command | Description |
@@ -301,7 +344,7 @@ You are a personal assistant. You do NOT modify files or run shell commands.
301
344
 
302
345
  ## Development
303
346
 
304
- Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux, MacOS and Windows.
347
+ Requires Python ≥ 3.11. CI runs against Python 3.11, 3.12, and 3.13 on Linux and MacOS.
305
348
 
306
349
  ```sh
307
350
  make test # unit tests
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-agent-harness"
7
- version = "1.5.5.9"
7
+ version = "1.5.6.0"
8
8
  description = "A lightweight, hackable mini-OpenCode written in Python."
9
9
  authors = [
10
10
  {name = "Huming Chen", email = "chenhuming@gmail.com"}
@@ -17,7 +17,6 @@ classifiers = [
17
17
  "Environment :: Console",
18
18
  "Intended Audience :: Developers",
19
19
  "Operating System :: MacOS",
20
- "Operating System :: Microsoft :: Windows",
21
20
  "Operating System :: POSIX :: Linux",
22
21
  "Programming Language :: Python :: 3.11",
23
22
  "Programming Language :: Python :: 3.12",
@@ -27,6 +26,7 @@ classifiers = [
27
26
  dependencies = [
28
27
  "rich>=13.0",
29
28
  "httpx>=0.27",
29
+ "certifi>=2024.0",
30
30
  "prompt_toolkit>=3.0",
31
31
  ]
32
32
 
@@ -96,8 +96,4 @@ omit = [
96
96
 
97
97
  [tool.coverage.report]
98
98
  show_missing = true
99
- # Linux/macOS gate: 95%. Windows CI enforces 94% with tools/bash.py
100
- # omitted instead (see .github/workflows/ci.yml): that module is the
101
- # posix PTY/spawn backend whose body cannot execute on Windows, where
102
- # tools/bash_win.py runs in its place.
103
99
  fail_under = 95
@@ -8,7 +8,7 @@ from .mcp.config import MCPConfig, MCPServerConfig
8
8
  from .mcp.manager import MCPManager
9
9
  from .session.session import Session
10
10
 
11
- __version__ = "1.5.5.9"
11
+ __version__ = "1.5.6.0"
12
12
 
13
13
  __all__ = [
14
14
  "Client",
@@ -35,6 +35,8 @@ to them via ``_tool_runner`` / ``_context_manager``.
35
35
 
36
36
  from __future__ import annotations
37
37
 
38
+ import threading
39
+ import time
38
40
  from typing import Any, Protocol
39
41
 
40
42
  from ..llm.client import LLMClient
@@ -50,6 +52,13 @@ from .tool_runner import (
50
52
  )
51
53
 
52
54
 
55
+ def _deadline_message(timeout: float | None) -> str:
56
+ """Result text for a wall-clock abort."""
57
+ if timeout:
58
+ return f"run exceeded the {timeout:g}s wall-clock limit"
59
+ return "run exceeded its wall-clock limit"
60
+
61
+
53
62
  class _SupervisorSession(Protocol):
54
63
  """The slice of an agent session the Supervisor reads."""
55
64
 
@@ -76,6 +85,8 @@ class AgentLoop:
76
85
  system: str | None = None,
77
86
  max_rounds: int = 60,
78
87
  client: LLMClient | None = None,
88
+ budget_top_level: bool = False,
89
+ timeout: float | None = None,
79
90
  ) -> None:
80
91
  self.session = session
81
92
  self.messages: list[Message] = messages if messages is not None else []
@@ -96,6 +107,13 @@ class AgentLoop:
96
107
  # max_rounds only bounds sub-agent loops: the main agent runs until
97
108
  # the model gives a terminal response or the user aborts it (Ctrl-C)
98
109
  self.max_rounds = max_rounds if not top_level else None
110
+ if top_level and budget_top_level:
111
+ # unattended (headless/CI) top-level runs may opt in to a
112
+ # round budget: an interactive TUI run still runs until the
113
+ # model finishes or the user aborts
114
+ self.max_rounds = max_rounds
115
+ # Optional wall-clock budget for unattended runs (None = off)
116
+ self.timeout = timeout
99
117
  self.pending: list[ToolCall] = []
100
118
  self.error: str | None = None
101
119
  self.harness_injected: bool = False
@@ -114,6 +132,9 @@ class AgentLoop:
114
132
  self.info: dict[str, Any] = {}
115
133
  self.history: list[str] = []
116
134
  self.rounds = 0
135
+ # Thread-local wall-clock deadline bookkeeping: sub-agent loops
136
+ # run in their own threads, so a shared attribute would race
137
+ self._local = threading.local()
117
138
  self.terminal_text: str | None = None
118
139
  self.result: str | None = None
119
140
  # Cancellation identity for this run: cancel() bumps the session
@@ -132,7 +153,6 @@ class AgentLoop:
132
153
 
133
154
  def _is_cancelled(self) -> bool:
134
155
  """Whether THIS run must stop (cancelled or superseded).
135
-
136
156
  The plain event is not enough: `_start_agent` clears it before
137
157
  every run, so a worker from a cancelled run that finishes late
138
158
  (e.g. after a long tool call) would otherwise see it cleared and
@@ -156,6 +176,46 @@ class AgentLoop:
156
176
  """
157
177
  return self.session.run_generation != self._run_gen
158
178
 
179
+ @property
180
+ def _deadline(self) -> float | None:
181
+ """This thread's wall-clock deadline (None = unbounded).
182
+
183
+ Sub-agent loops run in their own threads; the thread-local keeps
184
+ the parent's deadline from leaking into (or being cleared by)
185
+ their bookkeeping, while sibling sub-agents under one session
186
+ each read their own entry.
187
+ """
188
+ return getattr(self._local, "deadline", None)
189
+
190
+ def _deadline_exceeded(self) -> bool:
191
+ """Whether this thread's wall-clock budget has run out."""
192
+ deadline = self._deadline
193
+ return deadline is not None and time.monotonic() >= deadline
194
+
195
+ def _check_budget_at_wait(self) -> bool:
196
+ """WAIT-entry budget enforcement; True when a budget tripped.
197
+
198
+ Sub-agent exhaustion keeps the historical soft path (``info[
199
+ "budget"]`` -> DONE with a best-effort final answer). An
200
+ opted-in top-level budget (headless/CI) is a hard stop: it
201
+ routes ERRS with an explicit error so the driving process gets
202
+ a failed exit code instead of silently truncated work.
203
+ """
204
+ if self.max_rounds is not None and self.rounds >= self.max_rounds:
205
+ if self.top_level:
206
+ self.error = "Error: round budget exhausted before the run finished"
207
+ self.info["error"] = self.error
208
+ self.session.notify("error", self.error)
209
+ else:
210
+ self.info["budget"] = True
211
+ return True
212
+ if self._deadline_exceeded():
213
+ self.error = f"Error: {_deadline_message(self.timeout)}"
214
+ self.info["error"] = self.error
215
+ self.session.notify("error", self.error)
216
+ return True
217
+ return False
218
+
159
219
  # ------------------------------------------------------------------
160
220
  # finite state machine
161
221
  #
@@ -346,6 +406,11 @@ class AgentLoop:
346
406
  handler.
347
407
  """
348
408
  session = self.session
409
+ if self.timeout is not None:
410
+ # Wall-clock budget for THIS run's thread. Thread-local on
411
+ # purpose: sub-agent loops run in sibling threads and must
412
+ # not inherit (or clear) the parent's deadline.
413
+ self._local.deadline = time.monotonic() + self.timeout
349
414
  try:
350
415
  # The machine starts directly in WAIT — an asynchronous
351
416
  # INIT state is not needed for the synchronous driver.
@@ -420,8 +485,7 @@ class AgentLoop:
420
485
  """
421
486
  session = self.session
422
487
  self.info.clear()
423
- if self.max_rounds is not None and self.rounds >= self.max_rounds:
424
- self.info["budget"] = True
488
+ if self._check_budget_at_wait():
425
489
  return
426
490
  self.rounds += 1
427
491
  if self._is_cancelled():
@@ -506,6 +570,18 @@ class AgentLoop:
506
570
  if self._is_cancelled():
507
571
  return # response arrived after cancel: drop it
508
572
 
573
+ # Usage accounting: every loop under this session (main and
574
+ # sub-agents alike — sub-agent tokens spend the same API key)
575
+ # adds its per-round usage; a run's driver reads the totals
576
+ # when the run finishes. Sessions without the attribute (test
577
+ # doubles) skip silently.
578
+ totals = getattr(session, "usage_totals", None)
579
+ if isinstance(totals, dict):
580
+ with totals.setdefault("_lock", threading.Lock()):
581
+ totals["input"] = totals.get("input", 0) + usage.input_tokens
582
+ totals["output"] = totals.get("output", 0) + usage.output_tokens
583
+ totals["rounds"] = totals.get("rounds", 0) + 1
584
+
509
585
  # persist the assistant response in the conversation history
510
586
  # (text and/or tool calls) so later turns and the UI see it;
511
587
  # always append to maintain role alternation — a missing
@@ -636,11 +712,17 @@ def run_agent_loop(
636
712
  system: str | None = None,
637
713
  max_rounds: int = 60,
638
714
  client: LLMClient | None = None,
715
+ budget_top_level: bool = False,
716
+ timeout: float | None = None,
639
717
  ) -> str | None:
640
718
  """Convenience wrapper running a full agent run (FSM).
641
719
 
642
720
  ``client`` (when given) overrides the session's client for this
643
- run — the per-invocation sub-agent clone."""
721
+ run — the per-invocation sub-agent clone. ``budget_top_level``
722
+ opts an unattended top-level run into the ``max_rounds`` budget;
723
+ ``timeout`` adds a wall-clock limit (both default off, matching
724
+ the interactive TUI behavior).
725
+ """
644
726
  loop = AgentLoop(
645
727
  session,
646
728
  messages=messages,
@@ -648,6 +730,8 @@ def run_agent_loop(
648
730
  system=system,
649
731
  max_rounds=max_rounds,
650
732
  client=client,
733
+ budget_top_level=budget_top_level,
734
+ timeout=timeout,
651
735
  )
652
736
  result = loop.run()
653
737
  # Notify run_done only for a successfully completed top-level run:
@@ -4,6 +4,8 @@ Commands:
4
4
  run interactive TUI agent session (default)
5
5
  headless [prompt] non-interactive: submit one prompt, print the result
6
6
  (--json emits the run as JSON lines instead)
7
+ serve resident JSONL server over stdin/stdout: submit
8
+ runs repeatedly, answer mid-run questions, cancel
7
9
  config [--init] show effective LLM config / write a template file
8
10
 
9
11
  Custom commands (prompts/commands/*.md) — like init, review,
@@ -207,11 +209,45 @@ def cmd_headless(args: argparse.Namespace) -> int:
207
209
  if prompt is None:
208
210
  prompt = sys.stdin.read()
209
211
  runner = run_headless_jsonl if getattr(args, "json", False) else run_headless
212
+ # Unattended budgets: explicit flags win over the config file's
213
+ # headless section (0/negative disables the budget entirely).
214
+ cfg_max_rounds, cfg_timeout = config.load_headless_limits(args.config)
215
+ max_rounds = getattr(args, "max_rounds", None)
216
+ if max_rounds is None:
217
+ max_rounds = cfg_max_rounds
218
+ elif max_rounds <= 0:
219
+ max_rounds = None
220
+ timeout = getattr(args, "timeout", None)
221
+ if timeout is None:
222
+ timeout = cfg_timeout
223
+ elif timeout <= 0:
224
+ timeout = None
210
225
  return runner(
211
226
  session,
212
227
  prompt,
213
228
  restore=getattr(args, "restore", None),
214
229
  model=getattr(args, "model", None),
230
+ run_id=getattr(args, "run_id", None),
231
+ max_rounds=max_rounds,
232
+ timeout=timeout,
233
+ )
234
+ finally:
235
+ session.close()
236
+
237
+
238
+ def cmd_serve(args: argparse.Namespace) -> int:
239
+ project_dir = getattr(args, "project", None) or os.getcwd()
240
+ session = make_session_with_mcp(
241
+ project_dir,
242
+ config_path=args.config,
243
+ stream=False if getattr(args, "no_stream", False) else None,
244
+ )
245
+ try:
246
+ from .server import run_serve
247
+
248
+ return run_serve(
249
+ session,
250
+ answer_timeout=float(getattr(args, "answer_timeout", 0.0) or 0.0),
215
251
  )
216
252
  finally:
217
253
  session.close()
@@ -299,6 +335,15 @@ def cmd_config(args: argparse.Namespace) -> int:
299
335
  # Show default agent
300
336
  default_agent = config.load_default_agent(args.path)
301
337
  print(f"default_agent: {default_agent or '(default: agent.md)'}")
338
+ max_rounds, timeout = config.load_headless_limits(args.path)
339
+ if max_rounds is None and timeout is None:
340
+ print(
341
+ "headless: (unlimited — set headless.max_rounds/timeout or use --max-rounds/--timeout)"
342
+ )
343
+ else:
344
+ print(
345
+ f"headless: max_rounds={max_rounds or '(unlimited)'}, timeout={timeout or '(unlimited)'}s"
346
+ )
302
347
  return 0
303
348
 
304
349
 
@@ -372,11 +417,57 @@ def build_parser() -> argparse.ArgumentParser:
372
417
  help="emit the run as JSON lines on stdout (start/delta/notify/log/"
373
418
  "result events) for programmatic driving; diagnostics stay on stderr",
374
419
  )
420
+ p_headless.add_argument(
421
+ "--run-id",
422
+ metavar="ID",
423
+ default=None,
424
+ help="correlation id echoed on every --json line (e.g. the controller's exec id)",
425
+ )
426
+ p_headless.add_argument(
427
+ "--max-rounds",
428
+ metavar="N",
429
+ type=int,
430
+ default=None,
431
+ help="cap the run at N LLM rounds (unattended budget; default: config "
432
+ "headless.max_rounds, else unlimited)",
433
+ )
434
+ p_headless.add_argument(
435
+ "--timeout",
436
+ metavar="SECONDS",
437
+ type=float,
438
+ default=None,
439
+ help="wall-clock limit for the run in seconds (unattended budget; "
440
+ "default: config headless.timeout, else unlimited)",
441
+ )
375
442
 
376
443
  p_config = sub.add_parser("config", help="show effective LLM config or write a template file")
377
444
  p_config.add_argument("--init", action="store_true", help="write a config template")
378
445
  p_config.add_argument("--path", metavar="PATH", help="config file path")
379
446
  p_config.set_defaults(func=cmd_config)
447
+
448
+ p_serve = sub.add_parser(
449
+ "serve",
450
+ help="resident JSONL server: multiple runs per process, mid-run Q&A",
451
+ )
452
+ _add_config_arg(p_serve, suppress=True)
453
+ p_serve.add_argument(
454
+ "--no-stream",
455
+ action="store_true",
456
+ help="disable streaming (one-shot responses; overrides config file)",
457
+ )
458
+ p_serve.add_argument(
459
+ "--project",
460
+ metavar="DIR",
461
+ help="project directory (default: cwd)",
462
+ )
463
+ p_serve.add_argument(
464
+ "--answer-timeout",
465
+ metavar="SECONDS",
466
+ type=float,
467
+ default=0.0,
468
+ help="give up waiting for a host answer after N seconds (0 = wait "
469
+ 'forever, the default; the host can answer via {"op": "answer"})',
470
+ )
380
471
  return parser
381
472
 
382
473
 
@@ -387,6 +478,8 @@ def main(argv: list[str] | None = None) -> int:
387
478
  return cmd_run(args)
388
479
  if args.command == "headless":
389
480
  return cmd_headless(args)
481
+ if args.command == "serve":
482
+ return cmd_serve(args)
390
483
  if args.command == "config":
391
484
  return cmd_config(args)
392
485
  parser.print_help()
@@ -219,6 +219,8 @@ class Controller:
219
219
  text: str | Message,
220
220
  system: str | None = None,
221
221
  restore: Any = None,
222
+ max_rounds: int | None = None,
223
+ timeout: float | None = None,
222
224
  ) -> RunHandle | None:
223
225
  """Start an agent run in a worker thread; return its handle.
224
226
 
@@ -228,7 +230,10 @@ class Controller:
228
230
  directly). SYSTEM overrides the session's system prompt for
229
231
  this run only. RESTORE (if given) runs when the run finishes
230
232
  — used by slash commands to put back state they borrowed
231
- (e.g. project_dir).
233
+ (e.g. project_dir). MAX_ROUNDS (when given) opts this
234
+ unattended run into a round budget; TIMEOUT sets a wall-clock
235
+ limit — both default to the interactive behavior (unbounded),
236
+ and both apply to the top-level loop only.
232
237
 
233
238
  Returns None when the text was only failed ``@file``
234
239
  references with nothing else to send (the view surfaces the
@@ -249,9 +254,19 @@ class Controller:
249
254
  self.session.run_generation += 1
250
255
  self.session.cancel_event.clear()
251
256
  seq = self.session.run_generation
257
+ # Fresh usage accounting for this run (the previous run's totals
258
+ # were already emitted on its result line): a fresh dict, not a
259
+ # clear(), so a late sub-agent still writing into the old dict
260
+ # cannot pollute this run's numbers.
261
+ self.session.usage_totals = {
262
+ "input": 0,
263
+ "output": 0,
264
+ "rounds": 0,
265
+ "_lock": threading.Lock(),
266
+ }
252
267
  worker = threading.Thread(
253
268
  target=self._run_worker,
254
- args=(user_msg, seq, system, restore),
269
+ args=(user_msg, seq, system, restore, max_rounds, timeout),
255
270
  daemon=True,
256
271
  )
257
272
  worker.start()
@@ -324,6 +339,8 @@ class Controller:
324
339
  seq: int,
325
340
  system: str | None = None,
326
341
  restore: Any = None,
342
+ max_rounds: int | None = None,
343
+ timeout: float | None = None,
327
344
  ) -> None:
328
345
  """Worker-thread body of a submitted run: drive the agent loop.
329
346
 
@@ -342,6 +359,9 @@ class Controller:
342
359
  messages=list(self.conversation_history),
343
360
  top_level=True,
344
361
  system=system or self.system_prompt,
362
+ budget_top_level=max_rounds is not None,
363
+ max_rounds=max_rounds if max_rounds is not None else 60,
364
+ timeout=timeout,
345
365
  )
346
366
  if seq == self.session.run_generation and self.session.last_messages:
347
367
  self.conversation_history = list(self.session.last_messages)