pi-agent-cli-lc 0.2.0__tar.gz → 0.4.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 (52) hide show
  1. pi_agent_cli_lc-0.4.0/.gitignore +34 -0
  2. pi_agent_cli_lc-0.4.0/AGENTS.md +57 -0
  3. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/PKG-INFO +4 -3
  4. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/README.md +1 -0
  5. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/agent.example.toml +12 -0
  6. pi_agent_cli_lc-0.4.0/pi_agent_cli/__main__.py +159 -0
  7. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/agent.py +184 -9
  8. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/__init__.py +56 -0
  9. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/collector.py +130 -0
  10. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/docker_runner.py +474 -0
  11. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/egress_policy.py +129 -0
  12. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/evaluator.py +582 -0
  13. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/golden_seed.py +204 -0
  14. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/models.py +188 -0
  15. pi_agent_cli_lc-0.4.0/pi_agent_cli/benchmarks/task_loader.py +123 -0
  16. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/config.py +59 -0
  17. pi_agent_cli_lc-0.4.0/pi_agent_cli/context_files.py +111 -0
  18. pi_agent_cli_lc-0.4.0/pi_agent_cli/create_harness.py +65 -0
  19. pi_agent_cli_lc-0.4.0/pi_agent_cli/events.py +398 -0
  20. pi_agent_cli_lc-0.4.0/pi_agent_cli/factory.py +166 -0
  21. pi_agent_cli_lc-0.4.0/pi_agent_cli/git_context.py +85 -0
  22. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/headless.py +47 -3
  23. pi_agent_cli_lc-0.4.0/pi_agent_cli/prompt_options.py +71 -0
  24. pi_agent_cli_lc-0.4.0/pi_agent_cli/system_prompt.py +212 -0
  25. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pyproject.toml +3 -3
  26. pi_agent_cli_lc-0.4.0/tests/snapshots/default_coding_tools_prompt.txt +31 -0
  27. pi_agent_cli_lc-0.4.0/tests/test_acp_agent.py +645 -0
  28. pi_agent_cli_lc-0.4.0/tests/test_benchmark_loader.py +25 -0
  29. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/tests/test_config.py +42 -0
  30. pi_agent_cli_lc-0.4.0/tests/test_context_files.py +75 -0
  31. pi_agent_cli_lc-0.4.0/tests/test_evaluator_cache.py +64 -0
  32. pi_agent_cli_lc-0.4.0/tests/test_factory_skills.py +77 -0
  33. pi_agent_cli_lc-0.4.0/tests/test_git_context.py +127 -0
  34. pi_agent_cli_lc-0.4.0/tests/test_golden_checkpoint.py +191 -0
  35. pi_agent_cli_lc-0.4.0/tests/test_headless.py +149 -0
  36. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/tests/test_pelican_real_llm.py +1 -1
  37. pi_agent_cli_lc-0.4.0/tests/test_system_prompt.py +156 -0
  38. pi_agent_cli_lc-0.2.0/.gitignore +0 -16
  39. pi_agent_cli_lc-0.2.0/pi_agent_cli/__main__.py +0 -81
  40. pi_agent_cli_lc-0.2.0/pi_agent_cli/benchmarks/__init__.py +0 -15
  41. pi_agent_cli_lc-0.2.0/pi_agent_cli/events.py +0 -141
  42. pi_agent_cli_lc-0.2.0/pi_agent_cli/factory.py +0 -70
  43. pi_agent_cli_lc-0.2.0/pi_agent_cli/prompt.py +0 -24
  44. pi_agent_cli_lc-0.2.0/tests/test_acp_agent.py +0 -253
  45. pi_agent_cli_lc-0.2.0/tests/test_factory_skills.py +0 -43
  46. pi_agent_cli_lc-0.2.0/tests/test_headless.py +0 -74
  47. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/config.toml.example +0 -0
  48. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/local.env.example +0 -0
  49. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/__init__.py +0 -0
  50. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/benchmarks/pelican.py +0 -0
  51. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/pi_agent_cli/permissions.py +0 -0
  52. {pi_agent_cli_lc-0.2.0 → pi_agent_cli_lc-0.4.0}/tests/test_pelican_benchmark.py +0 -0
@@ -0,0 +1,34 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *.egg-info/
4
+ .eggs/
5
+ dist/
6
+ build/
7
+ !tui/crates/build/
8
+ .pytest_cache/
9
+ .pytest-audit/
10
+ .mypy_cache/
11
+ .ruff_cache/
12
+ .venv/
13
+ .venv-*/
14
+ venv/
15
+ .env
16
+ # Rust TUI workspace (Apache-2.0 fork under tui/)
17
+ tui/target/
18
+ **/*.rs.bk
19
+
20
+ # Benchmark evaluation cache and trial artifacts
21
+ .cache/
22
+ .pi-eval/
23
+
24
+ # Temporary files, coverage and logs
25
+ *.log
26
+ *.tmp
27
+ *.bak
28
+ *.swp
29
+ *.orig
30
+ .coverage
31
+ .coverage.*
32
+ coverage.xml
33
+ htmlcov/
34
+
@@ -0,0 +1,57 @@
1
+ # pi-agent-cli
2
+
3
+ Standard-ACP agent over `AgentHarness`. Two modes:
4
+
5
+ | Mode | Entry | Purpose |
6
+ |------|-------|---------|
7
+ | ACP stdio | `python -m pi_agent_cli` | TUI-spawned agent; `/new` `/resume` `/quit` map to ACP methods |
8
+ | Headless | `python -m pi_agent_cli -p "prompt"` | One-shot turn, prints assistant text, exits |
9
+
10
+ Headless flags: `--system-prompt`, `--append-system-prompt`, `--no-context-files`, `--no-git-context` — see `--help`.
11
+
12
+ ## Config
13
+
14
+ Home: `~/.pi-python/` (override with `PI_HOME`). Two config files with distinct owners:
15
+
16
+ | File | Owner | Scope |
17
+ |------|-------|-------|
18
+ | `agent.toml` | Python agent | model, permission, skills, prompt overrides |
19
+ | `config.toml` | Rust TUI only | grok-shell settings; Python-only keys break TUI parsing |
20
+
21
+ `local.env` in home loads `KEY=VALUE` pairs into env without overwriting (secrets stay out of TOML). See `agent.example.toml` for all keys.
22
+
23
+ ## TUI spawn
24
+
25
+ The Rust TUI (`zypi`) spawns the Python agent via (priority order):
26
+
27
+ 1. `PI_AGENT_COMMAND` env var
28
+ 2. `[agent].command` in `agent.toml`
29
+ 3. `PI_PYTHON` env var (appends `-m pi_agent_cli`)
30
+
31
+ ## System prompt pipeline
32
+
33
+ `build_system_prompt` (`system_prompt.py`) assembles:
34
+
35
+ 1. Base prompt with tool snippets and `prompt_guidelines`
36
+ 2. `append_system_prompt` (config or CLI flag)
37
+ 3. Context files — `context_files.py` walks cwd → repo root collecting `AGENTS.md`, `CLAUDE.md`, `.pi/SYSTEM.md`
38
+ 4. Git status — `<git_status>` from `git_context.py` when cwd is a repo (`[git]` / `--no-git-context`)
39
+ 5. Skills from `[skills].paths`, formatted as `<available_skills>`
40
+
41
+ `build_coding_agent_harness_system_prompt` (`create_harness.py`) wraps this for harness use.
42
+
43
+ ## Modules
44
+
45
+ | Module | Role |
46
+ |--------|------|
47
+ | `agent.py` | `PiAcpAgent` — ACP methods, permission handling, event projection |
48
+ | `config.py` | TOML loading, `pi_home()`, `CliConfig` dataclass |
49
+ | `factory.py` | `create_session_harness` — wires tools, model, stream_fn, skills into harness |
50
+ | `system_prompt.py` | `build_system_prompt`, tool snippet/guideline consumption |
51
+ | `context_files.py` | AGENTS.md / CLAUDE.md / .pi/SYSTEM.md discovery |
52
+ | `git_context.py` | Read-only branch and `git status` snapshot for the system prompt |
53
+ | `headless.py` | `-p` one-shot mode, prompt override application |
54
+ | `events.py` | Internal events → ACP `session_update` projection |
55
+ | `permissions.py` | Tool permission gating (ask / auto / always-approve) |
56
+ | `create_harness.py` | Harness-level system prompt, coding tool wiring |
57
+ | `benchmarks/` | Pelican benchmark suite, evaluator, Docker runner |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: pi-agent-cli-lc
3
- Version: 0.2.0
3
+ Version: 0.4.0
4
4
  Summary: Standard ACP agent over AgentHarness (no x.ai extensions)
5
5
  Project-URL: Homepage, https://github.com/zy1233/pi-python
6
6
  Project-URL: Repository, https://github.com/zy1233/pi-python
@@ -20,8 +20,8 @@ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
20
20
  Classifier: Typing :: Typed
21
21
  Requires-Python: >=3.11
22
22
  Requires-Dist: agent-client-protocol>=0.12.0
23
- Requires-Dist: pi-agent-core-lc==0.2.0
24
- Requires-Dist: pi-agent-harness-lc==0.2.0
23
+ Requires-Dist: pi-agent-core-lc==0.4.0
24
+ Requires-Dist: pi-agent-harness-lc==0.4.0
25
25
  Requires-Dist: pydantic>=2.0
26
26
  Description-Content-Type: text/markdown
27
27
 
@@ -31,6 +31,7 @@ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent o
31
31
 
32
32
  - stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
33
33
  - headless one-shot: `python -m pi_agent_cli -p "..."`
34
+ - Headless prompt overrides (override `agent.toml` `[prompt]`): `--system-prompt`, `--system-prompt-file`, `--append-system-prompt` / `--rules`, `--append-system-prompt-file`, `--no-context-files`
34
35
  - Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
35
36
  - No `x.ai/*` vendor RPCs — core + harness + ACP only
36
37
 
@@ -4,6 +4,7 @@ Standard [Agent Client Protocol (ACP)](https://agentclientprotocol.com/) agent o
4
4
 
5
5
  - stdio entry: `python -m pi_agent_cli` (or console script `pi-agent-cli`)
6
6
  - headless one-shot: `python -m pi_agent_cli -p "..."`
7
+ - Headless prompt overrides (override `agent.toml` `[prompt]`): `--system-prompt`, `--system-prompt-file`, `--append-system-prompt` / `--rules`, `--append-system-prompt-file`, `--no-context-files`
7
8
  - Config: `~/.pi-python/agent.toml` (see `agent.example.toml` in this directory)
8
9
  - No `x.ai/*` vendor RPCs — core + harness + ACP only
9
10
 
@@ -20,3 +20,15 @@ paths = ["~/.pi-python/skills", ".pi/skills"]
20
20
  [agent]
21
21
  # Used by the Rust TUI when PI_AGENT_COMMAND is unset (Windows: prefer a venv python.exe).
22
22
  command = "python -m pi_agent_cli"
23
+
24
+ [prompt]
25
+ # no_context_files = false
26
+ # custom_system_prompt = "You are a helpful assistant."
27
+ # custom_system_prompt_file = "~/.pi-python/agent/SYSTEM.md"
28
+ # append_system_prompt = "Always run tests after edits."
29
+ # append_system_prompt_file = "~/.pi-python/agent/APPEND_SYSTEM.md"
30
+
31
+ [git]
32
+ # enabled = true
33
+ # timeout_seconds = 2
34
+ # max_status_lines = 40
@@ -0,0 +1,159 @@
1
+ """stdio entry: python -m pi_agent_cli
2
+
3
+ Default: ACP agent on stdio.
4
+ `python -m pi_agent_cli -p "..."`: one-shot headless turn (no TUI).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import asyncio
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ from acp import run_agent
16
+
17
+ from pi_agent_cli.agent import PiAcpAgent
18
+ from pi_agent_cli.config import load_local_env
19
+ from pi_agent_cli.headless import HeadlessPromptOverrides, resolve_print_prompt, run_print
20
+
21
+ _PROMPT_CLI_FLAG_NAMES = (
22
+ "system_prompt",
23
+ "system_prompt_file",
24
+ "append_system_prompt",
25
+ "append_system_prompt_file",
26
+ "no_context_files",
27
+ "no_git_context",
28
+ )
29
+
30
+
31
+ async def _amain() -> None:
32
+ await run_agent(PiAcpAgent())
33
+
34
+
35
+ def _build_parser() -> argparse.ArgumentParser:
36
+ parser = argparse.ArgumentParser(
37
+ prog="pi-agent-cli",
38
+ description="Standard ACP agent over AgentHarness (stdio), or one-shot -p print.",
39
+ )
40
+ src = parser.add_mutually_exclusive_group()
41
+ src.add_argument(
42
+ "-p",
43
+ "--print",
44
+ dest="print_prompt",
45
+ metavar="PROMPT",
46
+ help="Run one prompt, print the assistant text, and exit (no TUI, no ACP stdio).",
47
+ )
48
+ src.add_argument(
49
+ "--prompt-json",
50
+ metavar="JSON",
51
+ help="Single-turn prompt as a JSON string or list of content blocks.",
52
+ )
53
+ src.add_argument(
54
+ "--prompt-file",
55
+ metavar="PATH",
56
+ type=Path,
57
+ help="Read the single-turn prompt from a file.",
58
+ )
59
+ parser.add_argument(
60
+ "--cwd",
61
+ metavar="PATH",
62
+ type=Path,
63
+ help="Working directory for the headless session (default: process cwd).",
64
+ )
65
+ system = parser.add_mutually_exclusive_group()
66
+ system.add_argument(
67
+ "--system-prompt",
68
+ "--system-prompt-override",
69
+ dest="system_prompt",
70
+ metavar="TEXT",
71
+ help="Replace the default system prompt (headless only).",
72
+ )
73
+ system.add_argument(
74
+ "--system-prompt-file",
75
+ metavar="PATH",
76
+ type=Path,
77
+ help="Read the system prompt override from a file (headless only).",
78
+ )
79
+ append = parser.add_mutually_exclusive_group()
80
+ append.add_argument(
81
+ "--append-system-prompt",
82
+ "--rules",
83
+ dest="append_system_prompt",
84
+ metavar="TEXT",
85
+ help="Append text to the system prompt (headless only).",
86
+ )
87
+ append.add_argument(
88
+ "--append-system-prompt-file",
89
+ metavar="PATH",
90
+ type=Path,
91
+ help="Read append text from a file (headless only).",
92
+ )
93
+ parser.add_argument(
94
+ "--no-context-files",
95
+ action="store_true",
96
+ help="Skip AGENTS.md / CLAUDE.md discovery (headless only).",
97
+ )
98
+ parser.add_argument(
99
+ "--no-git-context",
100
+ action="store_true",
101
+ help="Omit the <git_status> section from the system prompt (headless only).",
102
+ )
103
+ return parser
104
+
105
+
106
+ def _prompt_overrides_from_args(args: argparse.Namespace) -> HeadlessPromptOverrides:
107
+ return HeadlessPromptOverrides(
108
+ system_prompt=args.system_prompt,
109
+ system_prompt_file=args.system_prompt_file,
110
+ append_system_prompt=args.append_system_prompt,
111
+ append_system_prompt_file=args.append_system_prompt_file,
112
+ no_context_files=True if args.no_context_files else None,
113
+ no_git_context=True if args.no_git_context else None,
114
+ )
115
+
116
+
117
+ def _has_prompt_cli_flags(args: argparse.Namespace) -> bool:
118
+ return any(getattr(args, name) for name in _PROMPT_CLI_FLAG_NAMES)
119
+
120
+
121
+ def main() -> None:
122
+ load_local_env()
123
+ parser = _build_parser()
124
+ args = parser.parse_args()
125
+ headless = any(
126
+ value is not None for value in (args.print_prompt, args.prompt_json, args.prompt_file)
127
+ )
128
+ if not headless and _has_prompt_cli_flags(args):
129
+ print(
130
+ "error: --system-prompt, --append-system-prompt, --no-context-files, "
131
+ "and --no-git-context require headless mode "
132
+ "(-p, --prompt-json, or --prompt-file)",
133
+ file=sys.stderr,
134
+ )
135
+ raise SystemExit(2)
136
+ if headless:
137
+ try:
138
+ prompt = resolve_print_prompt(
139
+ print_prompt=args.print_prompt,
140
+ prompt_json=args.prompt_json,
141
+ prompt_file=args.prompt_file,
142
+ )
143
+ except (OSError, ValueError, json.JSONDecodeError) as exc:
144
+ print(f"error: {exc}", file=sys.stderr)
145
+ raise SystemExit(2) from exc
146
+ raise SystemExit(
147
+ asyncio.run(
148
+ run_print(
149
+ prompt,
150
+ cwd=args.cwd,
151
+ prompt_overrides=_prompt_overrides_from_args(args),
152
+ )
153
+ )
154
+ )
155
+ asyncio.run(_amain())
156
+
157
+
158
+ if __name__ == "__main__":
159
+ main()
@@ -3,14 +3,19 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import logging
7
+ from dataclasses import replace
6
8
  from pathlib import Path
7
- from typing import Any
9
+ from typing import Any, cast
8
10
 
9
11
  from acp import PROTOCOL_VERSION, RequestError
10
12
  from acp.interfaces import Agent, Client
11
13
  from acp.schema import (
12
14
  AgentCapabilities,
13
15
  AudioContentBlock,
16
+ AvailableCommand,
17
+ AvailableCommandInput,
18
+ AvailableCommandsUpdate,
14
19
  ClientCapabilities,
15
20
  CloseSessionResponse,
16
21
  EmbeddedResourceContentBlock,
@@ -25,6 +30,7 @@ from acp.schema import (
25
30
  PromptCapabilities,
26
31
  PromptResponse,
27
32
  ResourceContentBlock,
33
+ ResumeSessionResponse,
28
34
  SessionCapabilities,
29
35
  SessionCloseCapabilities,
30
36
  SessionInfo,
@@ -32,10 +38,11 @@ from acp.schema import (
32
38
  SessionResumeCapabilities,
33
39
  SseMcpServer,
34
40
  TextContentBlock,
41
+ UnstructuredCommandInput,
35
42
  )
36
43
 
37
- from pi_agent_cli.config import CliConfig, load_config, pi_home
38
- from pi_agent_cli.events import project_event
44
+ from pi_agent_cli.config import CliConfig, PermissionMode, load_config, pi_home
45
+ from pi_agent_cli.events import project_event, project_message_replay
39
46
  from pi_agent_cli.factory import create_session_harness, default_stream_fn, load_session_resources
40
47
  from pi_agent_cli.permissions import (
41
48
  PERMISSION_OPTIONS,
@@ -48,6 +55,8 @@ from pi_agent_core.messages import ImageContent
48
55
  from pi_agent_core.types import StreamFn
49
56
  from pi_agent_harness import AgentHarness, JsonlSessionRepo, Session
50
57
 
58
+ logger = logging.getLogger(__name__)
59
+
51
60
  _AGENT_INFO = Implementation(name="pi-agent-cli", title="pi-python ACP agent", version="0.1.0")
52
61
 
53
62
 
@@ -55,6 +64,7 @@ class PiAcpAgent(Agent):
55
64
  """Standard-ACP-only agent. Does not register any vendor extension methods."""
56
65
 
57
66
  _conn: Client | None
67
+ _commands_advertised: set[str]
58
68
 
59
69
  def __init__(
60
70
  self,
@@ -63,6 +73,7 @@ class PiAcpAgent(Agent):
63
73
  home: Path | str | None = None,
64
74
  config: CliConfig | None = None,
65
75
  repo: JsonlSessionRepo | None = None,
76
+ extensions: list[Any] | None = None,
66
77
  ) -> None:
67
78
  self._conn = None
68
79
  self._home = pi_home(home)
@@ -73,6 +84,12 @@ class PiAcpAgent(Agent):
73
84
  self._repo = repo if repo is not None else JsonlSessionRepo(sessions_dir)
74
85
  self._harnesses: dict[str, AgentHarness] = {}
75
86
  self._abort_tasks: set[asyncio.Task[Any]] = set()
87
+ self._session_cwds: dict[str, str] = {}
88
+ self._client_capabilities: ClientCapabilities | None = None
89
+ self._client_info: Implementation | None = None
90
+ self._extensions: list[Any] = extensions or []
91
+ self._commands_advertised: set[str] = set()
92
+ self._background_tasks: set[asyncio.Task[Any]] = set()
76
93
 
77
94
  def on_connect(self, conn: Client) -> None:
78
95
  self._conn = conn
@@ -84,6 +101,8 @@ class PiAcpAgent(Agent):
84
101
  client_info: Implementation | None = None,
85
102
  **kwargs: Any,
86
103
  ) -> InitializeResponse:
104
+ self._client_capabilities = client_capabilities
105
+ self._client_info = client_info
87
106
  return InitializeResponse(
88
107
  protocol_version=min(protocol_version, PROTOCOL_VERSION),
89
108
  agent_capabilities=AgentCapabilities(
@@ -111,7 +130,15 @@ class PiAcpAgent(Agent):
111
130
  session = await self._repo.create({"cwd": cwd})
112
131
  session_id = (await session.get_metadata()).id
113
132
  await self._bind_session(session_id, session, cwd)
114
- return NewSessionResponse(session_id=session_id)
133
+ # Defer available_commands_update: Zed only registers the session
134
+ # AFTER it receives NewSessionResponse, so notifications sent before
135
+ # that are silently dropped. Schedule via create_task + sleep(0) so
136
+ # the response is flushed first. (See zed#60199, zed#53161.)
137
+ self._schedule_deferred_advertise(session_id)
138
+ return NewSessionResponse(
139
+ session_id=session_id,
140
+ field_meta=self._session_response_meta(),
141
+ )
115
142
 
116
143
  async def load_session(
117
144
  self,
@@ -126,7 +153,13 @@ class PiAcpAgent(Agent):
126
153
  raise RequestError.resource_not_found(session_id)
127
154
  session = await self._repo.open(metadata)
128
155
  await self._bind_session(session_id, session, metadata.cwd or cwd)
129
- return LoadSessionResponse()
156
+ if self._conn is not None:
157
+ context = await session.build_context()
158
+ for msg in context.messages:
159
+ for update in project_message_replay(msg):
160
+ await self._conn.session_update(session_id=session_id, update=update)
161
+ self._schedule_deferred_advertise(session_id)
162
+ return LoadSessionResponse(field_meta=self._session_response_meta())
130
163
 
131
164
  async def list_sessions(
132
165
  self, cwd: str | None = None, cursor: str | None = None, **kwargs: Any
@@ -143,8 +176,27 @@ class PiAcpAgent(Agent):
143
176
  ]
144
177
  return ListSessionsResponse(sessions=sessions)
145
178
 
179
+ async def resume_session(
180
+ self,
181
+ session_id: str,
182
+ cwd: str,
183
+ additional_directories: list[str] | None = None,
184
+ mcp_servers: list[HttpMcpServer | SseMcpServer | McpServerStdio] | None = None,
185
+ **kwargs: Any,
186
+ ) -> ResumeSessionResponse:
187
+ metadata = await self._find_metadata(session_id)
188
+ if metadata is None:
189
+ raise RequestError.resource_not_found(session_id)
190
+ session = await self._repo.open(metadata)
191
+ await self._bind_session(session_id, session, metadata.cwd or cwd)
192
+ # ACP session/resume intentionally does not replay history.
193
+ self._schedule_deferred_advertise(session_id)
194
+ return ResumeSessionResponse(field_meta=self._session_response_meta())
195
+
146
196
  async def close_session(self, session_id: str, **kwargs: Any) -> CloseSessionResponse | None:
147
- self._harnesses.pop(session_id, None)
197
+ harness = self._harnesses.pop(session_id, None)
198
+ if harness is not None:
199
+ await harness.close()
148
200
  return CloseSessionResponse()
149
201
 
150
202
  async def prompt(
@@ -160,6 +212,9 @@ class PiAcpAgent(Agent):
160
212
  **kwargs: Any,
161
213
  ) -> PromptResponse:
162
214
  harness = self._require_harness(session_id)
215
+ # Fallback: re-advertise commands if the deferred task was missed.
216
+ if session_id not in self._commands_advertised:
217
+ await self._advertise_commands(session_id)
163
218
  text, images = _prompt_to_text_images(prompt)
164
219
  try:
165
220
  message = await harness.prompt(text, images or None)
@@ -178,11 +233,42 @@ class PiAcpAgent(Agent):
178
233
  task.add_done_callback(self._abort_tasks.discard)
179
234
 
180
235
  async def ext_method(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
236
+ if method == "pi/session/delete":
237
+ session_id_raw = params.get("sessionId", params.get("session_id"))
238
+ if not isinstance(session_id_raw, str) or not session_id_raw.strip():
239
+ raise RequestError.invalid_params(
240
+ {"reason": "sessionId is required", "method": method}
241
+ )
242
+ session_id = session_id_raw.strip()
243
+ metadata = await self._find_metadata(session_id)
244
+ harness = self._harnesses.pop(session_id, None)
245
+ if harness is not None:
246
+ await harness.abort()
247
+ # Idempotent delete: missing session still returns success.
248
+ if metadata is not None:
249
+ await self._repo.delete(metadata)
250
+ return {"sessionId": session_id, "deleted": True}
181
251
  raise RequestError.method_not_found(method)
182
252
 
183
253
  async def ext_notification(self, method: str, params: dict[str, Any]) -> None:
254
+ mode = _permission_mode_from_notification(method, params)
255
+ if mode is not None:
256
+ self._config = replace(self._config, permission=mode)
184
257
  return None
185
258
 
259
+ def _session_response_meta(self) -> dict[str, Any] | None:
260
+ model_id = self._config.model_id.strip()
261
+ if not model_id:
262
+ return None
263
+ meta: dict[str, Any] = {
264
+ "pi/currentModelId": model_id,
265
+ "pi/currentModelDisplayName": model_id,
266
+ }
267
+ provider = self._config.provider.strip()
268
+ if provider:
269
+ meta["pi/provider"] = provider
270
+ return meta
271
+
186
272
  def _require_harness(self, session_id: str) -> AgentHarness:
187
273
  harness = self._harnesses.get(session_id)
188
274
  if harness is None:
@@ -204,13 +290,14 @@ class PiAcpAgent(Agent):
204
290
  return await self._handle_tool_call(session_id, event)
205
291
 
206
292
  resources = await load_session_resources(cwd=cwd, config=self._config)
207
- harness = create_session_harness(
293
+ harness = await create_session_harness(
208
294
  session=session,
209
295
  cwd=cwd,
210
296
  config=self._config,
211
297
  stream_fn=self._stream_fn,
212
298
  resources=resources,
213
299
  on_tool_call=on_tool_call,
300
+ extensions=self._extensions,
214
301
  )
215
302
 
216
303
  async def on_event(event: Any, signal: Any | None = None) -> None:
@@ -218,11 +305,67 @@ class PiAcpAgent(Agent):
218
305
 
219
306
  harness.subscribe(on_event)
220
307
  self._harnesses[session_id] = harness
308
+ self._session_cwds[session_id] = cwd
309
+
310
+ # Eagerly load extensions so slash commands are available.
311
+ await harness.load_extensions()
312
+
313
+ def _schedule_deferred_advertise(self, session_id: str) -> None:
314
+ """Fire-and-forget: advertise commands after the current response flushes."""
315
+ task = asyncio.create_task(self._deferred_advertise_commands(session_id))
316
+ self._background_tasks.add(task)
317
+ task.add_done_callback(self._background_tasks.discard)
318
+
319
+ async def _deferred_advertise_commands(self, session_id: str) -> None:
320
+ """Advertise commands after yielding the event loop.
321
+
322
+ Zed registers ACP sessions only after processing the response to
323
+ ``session/new``. Notifications sent *before* that response are
324
+ silently dropped ("unknown session"). Yielding with ``sleep(0)``
325
+ lets the response flush first. (See zed-industries/zed#60199.)
326
+ """
327
+ await asyncio.sleep(0)
328
+ await self._advertise_commands(session_id)
329
+
330
+ async def _advertise_commands(self, session_id: str) -> None:
331
+ """Send ``AvailableCommandsUpdate`` so ACP clients show slash commands."""
332
+ if self._conn is None:
333
+ return
334
+ harness = self._harnesses.get(session_id)
335
+ if harness is None:
336
+ return
337
+ commands = harness.extension_registry.get_commands()
338
+ if not commands:
339
+ return
340
+ available: list[AvailableCommand] = []
341
+ for name, cmd in commands.items():
342
+ ac = AvailableCommand(
343
+ name=name,
344
+ description=cmd.description,
345
+ input=AvailableCommandInput(
346
+ root=UnstructuredCommandInput(hint="<args>"),
347
+ ),
348
+ )
349
+ available.append(ac)
350
+ logger.debug(
351
+ "_advertise_commands: sending %d commands: %s",
352
+ len(available),
353
+ [c.name for c in available],
354
+ )
355
+ await self._conn.session_update(
356
+ session_id=session_id,
357
+ update=AvailableCommandsUpdate(
358
+ session_update="available_commands_update",
359
+ available_commands=available,
360
+ ),
361
+ )
362
+ self._commands_advertised.add(session_id)
221
363
 
222
364
  async def _emit_updates(self, session_id: str, event: Any) -> None:
223
365
  if self._conn is None:
224
366
  return
225
- for update in project_event(event):
367
+ cwd = self._session_cwds.get(session_id)
368
+ for update in project_event(event, cwd=cwd):
226
369
  await self._conn.session_update(session_id=session_id, update=update)
227
370
 
228
371
  async def _handle_tool_call(self, session_id: str, event: Any) -> dict[str, Any] | None:
@@ -287,11 +430,43 @@ def _prompt_to_text_images(
287
430
 
288
431
 
289
432
  def _stop_reason(message: Any) -> str:
433
+ """Map internal AssistantMessage stopReason to ACP PromptResponse stop_reason.
434
+
435
+ ACP stopReason enum: 'end_turn' | 'max_tokens' | 'max_turn_requests' | 'refusal' | 'cancelled'.
436
+ Internal stopReason values: 'stop' | 'length' | 'toolUse' | 'error' | 'aborted'.
437
+ """
290
438
  reason = getattr(message, "stopReason", None) or "stop"
291
439
  if reason == "aborted":
292
440
  return "cancelled"
293
441
  if reason == "length":
294
442
  return "max_tokens"
295
443
  if reason == "error":
296
- return "refusal"
444
+ err = str(getattr(message, "errorMessage", "") or "")
445
+ if any(w in err.lower() for w in ("refus", "policy", "filter", "safety")):
446
+ return "refusal"
447
+ return "end_turn"
297
448
  return "end_turn"
449
+
450
+
451
+ def _permission_mode_from_notification(
452
+ method: str, params: dict[str, Any] | None
453
+ ) -> PermissionMode | None:
454
+ """Best-effort permission mode sync for live sessions.
455
+
456
+ TUI settings changes are sent as extension notifications; we accept known
457
+ suffixes and update in-memory mode so the next tool call in this process
458
+ uses the new policy immediately.
459
+ """
460
+ suffix = method.rsplit("/", 1)[-1].strip().lower()
461
+ if suffix not in {"yolo_mode_changed", "permission_mode_changed"}:
462
+ return None
463
+ payload = params or {}
464
+ raw = payload.get("permission_mode")
465
+ if raw is None:
466
+ raw = payload.get("permissionMode")
467
+ if not isinstance(raw, str):
468
+ return None
469
+ normalized = raw.strip().lower()
470
+ if normalized not in {"ask", "auto", "always-approve"}:
471
+ return None
472
+ return cast(PermissionMode, normalized)
@@ -0,0 +1,56 @@
1
+ """Benchmark evaluation subsystem for pi-agent-cli and pi-python harness."""
2
+
3
+ from pi_agent_cli.benchmarks.collector import (
4
+ format_markdown_report,
5
+ print_summary_table,
6
+ save_summary_artifacts,
7
+ )
8
+ from pi_agent_cli.benchmarks.egress_policy import (
9
+ OFFICIAL_ALLOWED_HOSTS,
10
+ EgressPolicy,
11
+ resolve_task_egress_policy,
12
+ )
13
+ from pi_agent_cli.benchmarks.evaluator import run_trial
14
+ from pi_agent_cli.benchmarks.golden_seed import (
15
+ get_seed_dir,
16
+ is_seed_ready,
17
+ load_golden_manifest,
18
+ provision_golden_seeds,
19
+ )
20
+ from pi_agent_cli.benchmarks.models import (
21
+ BenchmarkSummary,
22
+ BenchmarkTask,
23
+ TrialResult,
24
+ )
25
+ from pi_agent_cli.benchmarks.pelican import (
26
+ PELICAN_PROMPT,
27
+ PelicanSvgReport,
28
+ extract_svg,
29
+ save_pelican_artifact,
30
+ validate_pelican_svg,
31
+ )
32
+ from pi_agent_cli.benchmarks.task_loader import discover_tasks, load_task_from_dir
33
+
34
+ __all__ = [
35
+ "OFFICIAL_ALLOWED_HOSTS",
36
+ "PELICAN_PROMPT",
37
+ "BenchmarkSummary",
38
+ "BenchmarkTask",
39
+ "EgressPolicy",
40
+ "PelicanSvgReport",
41
+ "TrialResult",
42
+ "discover_tasks",
43
+ "extract_svg",
44
+ "format_markdown_report",
45
+ "get_seed_dir",
46
+ "is_seed_ready",
47
+ "load_golden_manifest",
48
+ "load_task_from_dir",
49
+ "print_summary_table",
50
+ "provision_golden_seeds",
51
+ "resolve_task_egress_policy",
52
+ "run_trial",
53
+ "save_pelican_artifact",
54
+ "save_summary_artifacts",
55
+ "validate_pelican_svg",
56
+ ]