Zot files
- AGENT.md optional - entry.pre - Pre turn or shell on zot files - entry.pre - Autoload skills and extentions after run - consent argument - third party repo resolution - --stream for non-interactive
- commit
- 189298b1293724e28655ad34b63f3e75f318f273
- author
- DaviSM · · 2026-08-01 18:16 UTC
- committer
- DaviSM · · 2026-08-01 18:18 UTC
- parent
- f3d8eb6
- signature
- unsigned
13 files · +930 -83
M README.md +1 -0
| @@ -234,6 +234,7 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||
| 234 | 234 | ||
| 235 | 235 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | |
| 236 | 236 | - **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | |
| 237 | + | - **Stream**: `zot --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | |
| 237 | 238 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | |
| 238 | 239 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | |
| 239 | 240 | ||
| @@ -234,6 +234,7 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se | |||||
| 234 | 234 | ||||
| 235 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | 235 | - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands. | ||
| 236 | - **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | 236 | - **Print**: `zot -p "prompt"` runs the agent to completion and writes only the final assistant text to stdout. | ||
| 237 | + | - **Stream**: `zot --stream "prompt"` runs without the TUI and writes assistant text to stdout as it arrives. Tool activity goes to stderr. | |||
| 237 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | 238 | - **JSON**: `zot --json "prompt"` emits one JSON object per agent event to stdout, newline-delimited. The schema is documented in [docs/rpc.md](docs/rpc.md). | ||
| 238 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | 239 | - **RPC**: `zot rpc` runs as a long-lived child process; commands in on stdin, events and responses out on stdout, both as NDJSON. Designed for embedding zot in third-party apps written in any language. See [docs/rpc.md](docs/rpc.md) for the wire schema and `examples/rpc/{python,node,shell,go}` for working clients. | ||
| 239 | 240 | ||||
M docs/zotfiles.md +27 -7
| @@ -88,14 +88,14 @@ The first run displays the declared permissions and asks for consent before the | |||
| 88 | 88 | ```text | |
| 89 | 89 | my-agent/ | |
| 90 | 90 | ├── manifest.json # identity, requirements, and permissions, required | |
| 91 | - | ├── AGENT.md # persona and standing instructions, required | |
| 91 | + | ├── AGENT.md # persona and standing instructions | |
| 92 | 92 | ├── skills/ # SKILL.md directories, optional | |
| 93 | 93 | │ └── my-skill/SKILL.md | |
| 94 | 94 | ├── assets/ # static files, optional | |
| 95 | 95 | └── README.md # human-facing documentation, optional | |
| 96 | 96 | ``` | |
| 97 | 97 | ||
| 98 | - | Only `manifest.json` and a regular `AGENT.md` file are required. Other ordinary files and directories are included in the archive, but the runtime gives no special behavior to `assets/` or `README.md` yet. Files in `assets/` are not automatically added to the model context or granted filesystem access. | |
| 98 | + | Only `manifest.json`. Other ordinary files and directories are included in the archive, but the runtime gives no special behavior to `assets/` or `README.md` yet. Files in `assets/` are not automatically added to the model context or granted filesystem access. | |
| 99 | 99 | ||
| 100 | 100 | Do not include executable extensions in `extensions/`. The local runtime rejects any bundled extension directory containing `extension.json` because extension subprocesses cannot yet be confined to the manifest permissions. This is a deliberate fail-closed restriction. | |
| 101 | 101 | ||
| @@ -103,7 +103,7 @@ Symlinks are not supported and cause `zot pack` to fail. | |||
| 103 | 103 | ||
| 104 | 104 | ## `AGENT.md` | |
| 105 | 105 | ||
| 106 | - | By default, `AGENT.md` is appended to zot's system prompt. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. | |
| 106 | + | `AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, the system prompt is left unchanged. | |
| 107 | 107 | ||
| 108 | 108 | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | |
| 109 | 109 | ||
| @@ -275,6 +275,16 @@ Zot checks these requirements before requesting consent. These zotfiles do not h | |||
| 275 | 275 | ||
| 276 | 276 | ### Entry fields | |
| 277 | 277 | ||
| 278 | + | `entry.pre` is auto-submitted once when `zot run` starts, before the initial prompt is applied. Values that begin with `!` use the interactive shell-escape path (or `BashTool` in `--print` / `--stream` / `--json`). Shell output streams live to the TUI (interactive) or stderr (non-interactive). Other values are sent as a normal user turn; in `--stream` mode that turn's assistant text also streams to stdout. After `pre` finishes, zot reloads extensions and rediscovers skills so anything installed by that command is available for the following turn. | |
| 279 | + | ||
| 280 | + | ```json | |
| 281 | + | { | |
| 282 | + | "entry": { | |
| 283 | + | "pre": "!npx -y skills add tt-a1i/archify --skill archify --agent cursor --copy --yes" | |
| 284 | + | } | |
| 285 | + | } | |
| 286 | + | ``` | |
| 287 | + | ||
| 278 | 288 | `entry.default_prompt` is used when the user runs the agent without a prompt. It may be a string or `null`: | |
| 279 | 289 | ||
| 280 | 290 | ```json | |
| @@ -285,7 +295,9 @@ Zot checks these requirements before requesting consent. These zotfiles do not h | |||
| 285 | 295 | } | |
| 286 | 296 | ``` | |
| 287 | 297 | ||
| 288 | - | A prompt supplied on the command line takes precedence. | |
| 298 | + | In interactive mode, when no prompt is given on the command line, `default_prompt` pre-fills the editor after `pre` finishes (or immediately when `pre` is empty). It is not auto-submitted. A prompt supplied on the command line takes precedence the same way. | |
| 299 | + | ||
| 300 | + | In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escapes abort the run on failure; plain-text `pre` is sent as a first agent turn before the main prompt (`default_prompt` or the CLI prompt). | |
| 289 | 301 | ||
| 290 | 302 | `entry.greeting` is part of the manifest, but the current local runtime does not render it yet. | |
| 291 | 303 | ||
| @@ -447,7 +459,11 @@ $ZOT_HOME/agents/<name>/consents/<digest>.json | |||
| 447 | 459 | ||
| 448 | 460 | Any change to the packaged artifact produces a different digest and requires consent again. `bash: ask` always requires fresh launch consent. | |
| 449 | 461 | ||
| 450 | - | Non-interactive runs refuse to bypass consent by default. For controlled automation, set: | |
| 462 | + | Non-interactive runs refuse to bypass consent by default. For controlled automation, pass `-y` / `--yes` or set: | |
| 463 | + | ||
| 464 | + | ```bash | |
| 465 | + | zot run -y ./code-reviewer.zot --print "Review this repository" | |
| 466 | + | ``` | |
| 451 | 467 | ||
| 452 | 468 | ```bash | |
| 453 | 469 | ZOT_AGENT_CONSENT=1 zot run ./code-reviewer.zot --print "Review this repository" | |
| @@ -481,7 +497,9 @@ Run an unpackaged directory during development: | |||
| 481 | 497 | ||
| 482 | 498 | ```bash | |
| 483 | 499 | zot run ./my-agent | |
| 500 | + | zot run -y ./my-agent | |
| 484 | 501 | zot run ./my-agent "Do the task" | |
| 502 | + | zot run ./my-agent -y --print "Do the task" | |
| 485 | 503 | ``` | |
| 486 | 504 | ||
| 487 | 505 | Run a packed artifact: | |
| @@ -489,6 +507,7 @@ Run a packed artifact: | |||
| 489 | 507 | ```bash | |
| 490 | 508 | zot run ./my-agent.zot | |
| 491 | 509 | zot run ./my-agent.zot --print "Do the task" | |
| 510 | + | zot run ./my-agent.zot --stream "Do the task" | |
| 492 | 511 | zot run ./my-agent.zot --json "Do the task" | |
| 493 | 512 | ``` | |
| 494 | 513 | ||
| @@ -502,7 +521,7 @@ zot run zot-maintenance | |||
| 502 | 521 | ||
| 503 | 522 | Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back to `https://github.com/patriceckhart/agents/zot-maintenance`. This makes local development override the remote collection without an install or cache step. | |
| 504 | 523 | ||
| 505 | - | A two-part name selects a GitHub repository under the `patriceckhart` organization: | |
| 524 | + | A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root: | |
| 506 | 525 | ||
| 507 | 526 | ```bash | |
| 508 | 527 | zot run agents/zot-maintenance | |
| @@ -525,7 +544,7 @@ zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | |||
| 525 | 544 | ||
| 526 | 545 | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | |
| 527 | 546 | ||
| 528 | - | Short-name fallback is a fixed GitHub mapping, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. | |
| 547 | + | Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. | |
| 529 | 548 | ||
| 530 | 549 | ### `zot pack` | |
| 531 | 550 | ||
| @@ -596,6 +615,7 @@ Implemented now: | |||
| 596 | 615 | ||
| 597 | 616 | - local directories and `.zot` archives | |
| 598 | 617 | - local-first short-name resolution to the official GitHub agent collection | |
| 618 | + | - local-first `owner/repo` resolution to `https://github.com/owner/repo` | |
| 599 | 619 | - temporary execution of agent directories from public GitHub repositories | |
| 600 | 620 | - `zot pack`, `zot inspect`, `zot verify`, and `zot run` | |
| 601 | 621 | - canonical tar creation with zstd compression | |
| @@ -88,14 +88,14 @@ The first run displays the declared permissions and asks for consent before the | |||||
| 88 | ```text | 88 | ```text | ||
| 89 | my-agent/ | 89 | my-agent/ | ||
| 90 | ├── manifest.json # identity, requirements, and permissions, required | 90 | ├── manifest.json # identity, requirements, and permissions, required | ||
| 91 | - | ├── AGENT.md # persona and standing instructions, required | 91 | + | ├── AGENT.md # persona and standing instructions |
| 92 | ├── skills/ # SKILL.md directories, optional | 92 | ├── skills/ # SKILL.md directories, optional | ||
| 93 | │ └── my-skill/SKILL.md | 93 | │ └── my-skill/SKILL.md | ||
| 94 | ├── assets/ # static files, optional | 94 | ├── assets/ # static files, optional | ||
| 95 | └── README.md # human-facing documentation, optional | 95 | └── README.md # human-facing documentation, optional | ||
| 96 | ``` | 96 | ``` | ||
| 97 | 97 | ||||
| 98 | - | Only `manifest.json` and a regular `AGENT.md` file are required. Other ordinary files and directories are included in the archive, but the runtime gives no special behavior to `assets/` or `README.md` yet. Files in `assets/` are not automatically added to the model context or granted filesystem access. | 98 | + | Only `manifest.json`. Other ordinary files and directories are included in the archive, but the runtime gives no special behavior to `assets/` or `README.md` yet. Files in `assets/` are not automatically added to the model context or granted filesystem access. |
| 99 | 99 | ||||
| 100 | Do not include executable extensions in `extensions/`. The local runtime rejects any bundled extension directory containing `extension.json` because extension subprocesses cannot yet be confined to the manifest permissions. This is a deliberate fail-closed restriction. | 100 | Do not include executable extensions in `extensions/`. The local runtime rejects any bundled extension directory containing `extension.json` because extension subprocesses cannot yet be confined to the manifest permissions. This is a deliberate fail-closed restriction. | ||
| 101 | 101 | ||||
| @@ -103,7 +103,7 @@ Symlinks are not supported and cause `zot pack` to fail. | |||||
| 103 | 103 | ||||
| 104 | ## `AGENT.md` | 104 | ## `AGENT.md` | ||
| 105 | 105 | ||||
| 106 | - | By default, `AGENT.md` is appended to zot's system prompt. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. | 106 | + | `AGENT.md` is optional. When present, it is appended to zot's system prompt by default. It should describe the agent's role, workflow, constraints, output format, and when to load bundled skills. When absent, the system prompt is left unchanged. |
| 107 | 107 | ||||
| 108 | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | 108 | Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose. | ||
| 109 | 109 | ||||
| @@ -275,6 +275,16 @@ Zot checks these requirements before requesting consent. These zotfiles do not h | |||||
| 275 | 275 | ||||
| 276 | ### Entry fields | 276 | ### Entry fields | ||
| 277 | 277 | ||||
| 278 | + | `entry.pre` is auto-submitted once when `zot run` starts, before the initial prompt is applied. Values that begin with `!` use the interactive shell-escape path (or `BashTool` in `--print` / `--stream` / `--json`). Shell output streams live to the TUI (interactive) or stderr (non-interactive). Other values are sent as a normal user turn; in `--stream` mode that turn's assistant text also streams to stdout. After `pre` finishes, zot reloads extensions and rediscovers skills so anything installed by that command is available for the following turn. | |||
| 279 | + | ||||
| 280 | + | ```json | |||
| 281 | + | { | |||
| 282 | + | "entry": { | |||
| 283 | + | "pre": "!npx -y skills add tt-a1i/archify --skill archify --agent cursor --copy --yes" | |||
| 284 | + | } | |||
| 285 | + | } | |||
| 286 | + | ``` | |||
| 287 | + | ||||
| 278 | `entry.default_prompt` is used when the user runs the agent without a prompt. It may be a string or `null`: | 288 | `entry.default_prompt` is used when the user runs the agent without a prompt. It may be a string or `null`: | ||
| 279 | 289 | ||||
| 280 | ```json | 290 | ```json | ||
| @@ -285,7 +295,9 @@ Zot checks these requirements before requesting consent. These zotfiles do not h | |||||
| 285 | } | 295 | } | ||
| 286 | ``` | 296 | ``` | ||
| 287 | 297 | ||||
| 288 | - | A prompt supplied on the command line takes precedence. | 298 | + | In interactive mode, when no prompt is given on the command line, `default_prompt` pre-fills the editor after `pre` finishes (or immediately when `pre` is empty). It is not auto-submitted. A prompt supplied on the command line takes precedence the same way. |
| 299 | + | ||||
| 300 | + | In `--print` / `--stream` / `--json` modes, `pre` still runs first: shell escapes abort the run on failure; plain-text `pre` is sent as a first agent turn before the main prompt (`default_prompt` or the CLI prompt). | |||
| 289 | 301 | ||||
| 290 | `entry.greeting` is part of the manifest, but the current local runtime does not render it yet. | 302 | `entry.greeting` is part of the manifest, but the current local runtime does not render it yet. | ||
| 291 | 303 | ||||
| @@ -447,7 +459,11 @@ $ZOT_HOME/agents/<name>/consents/<digest>.json | |||||
| 447 | 459 | ||||
| 448 | Any change to the packaged artifact produces a different digest and requires consent again. `bash: ask` always requires fresh launch consent. | 460 | Any change to the packaged artifact produces a different digest and requires consent again. `bash: ask` always requires fresh launch consent. | ||
| 449 | 461 | ||||
| 450 | - | Non-interactive runs refuse to bypass consent by default. For controlled automation, set: | 462 | + | Non-interactive runs refuse to bypass consent by default. For controlled automation, pass `-y` / `--yes` or set: |
| 463 | + | ||||
| 464 | + | ```bash | |||
| 465 | + | zot run -y ./code-reviewer.zot --print "Review this repository" | |||
| 466 | + | ``` | |||
| 451 | 467 | ||||
| 452 | ```bash | 468 | ```bash | ||
| 453 | ZOT_AGENT_CONSENT=1 zot run ./code-reviewer.zot --print "Review this repository" | 469 | ZOT_AGENT_CONSENT=1 zot run ./code-reviewer.zot --print "Review this repository" | ||
| @@ -481,7 +497,9 @@ Run an unpackaged directory during development: | |||||
| 481 | 497 | ||||
| 482 | ```bash | 498 | ```bash | ||
| 483 | zot run ./my-agent | 499 | zot run ./my-agent | ||
| 500 | + | zot run -y ./my-agent | |||
| 484 | zot run ./my-agent "Do the task" | 501 | zot run ./my-agent "Do the task" | ||
| 502 | + | zot run ./my-agent -y --print "Do the task" | |||
| 485 | ``` | 503 | ``` | ||
| 486 | 504 | ||||
| 487 | Run a packed artifact: | 505 | Run a packed artifact: | ||
| @@ -489,6 +507,7 @@ Run a packed artifact: | |||||
| 489 | ```bash | 507 | ```bash | ||
| 490 | zot run ./my-agent.zot | 508 | zot run ./my-agent.zot | ||
| 491 | zot run ./my-agent.zot --print "Do the task" | 509 | zot run ./my-agent.zot --print "Do the task" | ||
| 510 | + | zot run ./my-agent.zot --stream "Do the task" | |||
| 492 | zot run ./my-agent.zot --json "Do the task" | 511 | zot run ./my-agent.zot --json "Do the task" | ||
| 493 | ``` | 512 | ``` | ||
| 494 | 513 | ||||
| @@ -502,7 +521,7 @@ zot run zot-maintenance | |||||
| 502 | 521 | ||||
| 503 | Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back to `https://github.com/patriceckhart/agents/zot-maintenance`. This makes local development override the remote collection without an install or cache step. | 522 | Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back to `https://github.com/patriceckhart/agents/zot-maintenance`. This makes local development override the remote collection without an install or cache step. | ||
| 504 | 523 | ||||
| 505 | - | A two-part name selects a GitHub repository under the `patriceckhart` organization: | 524 | + | A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root: |
| 506 | 525 | ||||
| 507 | ```bash | 526 | ```bash | ||
| 508 | zot run agents/zot-maintenance | 527 | zot run agents/zot-maintenance | ||
| @@ -525,7 +544,7 @@ zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance | |||||
| 525 | 544 | ||||
| 526 | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | 545 | The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`. | ||
| 527 | 546 | ||||
| 528 | - | Short-name fallback is a fixed GitHub mapping, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. | 547 | + | Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet. |
| 529 | 548 | ||||
| 530 | ### `zot pack` | 549 | ### `zot pack` | ||
| 531 | 550 | ||||
| @@ -596,6 +615,7 @@ Implemented now: | |||||
| 596 | 615 | ||||
| 597 | - local directories and `.zot` archives | 616 | - local directories and `.zot` archives | ||
| 598 | - local-first short-name resolution to the official GitHub agent collection | 617 | - local-first short-name resolution to the official GitHub agent collection | ||
| 618 | + | - local-first `owner/repo` resolution to `https://github.com/owner/repo` | |||
| 599 | - temporary execution of agent directories from public GitHub repositories | 619 | - temporary execution of agent directories from public GitHub repositories | ||
| 600 | - `zot pack`, `zot inspect`, `zot verify`, and `zot run` | 620 | - `zot pack`, `zot inspect`, `zot verify`, and `zot run` | ||
| 601 | - canonical tar creation with zstd compression | 621 | - canonical tar creation with zstd compression | ||
M packages/agent/args.go +16 -0
| @@ -17,6 +17,7 @@ type Mode string | |||
| 17 | 17 | const ( | |
| 18 | 18 | ModeInteractive Mode = "interactive" | |
| 19 | 19 | ModePrint Mode = "print" | |
| 20 | + | ModeStream Mode = "stream" | |
| 20 | 21 | ModeJSON Mode = "json" | |
| 21 | 22 | ModeRPC Mode = "rpc" | |
| 22 | 23 | // ModeSwarmAgent is the long-lived, headless daemon mode used by | |
| @@ -97,12 +98,21 @@ type Args struct { | |||
| 97 | 98 | // freely so automated workflows keep working. | |
| 98 | 99 | NoYolo bool | |
| 99 | 100 | ||
| 101 | + | // Yes accepts zotfile launch consent without an interactive | |
| 102 | + | // Allow? prompt (zot run -y / --yes). Durable consent receipts | |
| 103 | + | // are still written for modes other than bash ask. | |
| 104 | + | Yes bool | |
| 105 | + | ||
| 100 | 106 | ListModels bool | |
| 101 | 107 | Help bool | |
| 102 | 108 | Version bool | |
| 103 | 109 | ||
| 104 | 110 | Prompt string // concatenated positional args | |
| 105 | 111 | ||
| 112 | + | // StartupPre is an optional zotfile entry.pre value. Interactive | |
| 113 | + | // mode auto-submits it once at startup before InitialInput handling. | |
| 114 | + | StartupPre string | |
| 115 | + | ||
| 106 | 116 | // SwarmAgent is the inbox-socket path when this process is a | |
| 107 | 117 | // swarm-spawned agent. Empty in every other mode. Set by | |
| 108 | 118 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | |
| @@ -138,6 +148,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 138 | 148 | a.Version = true | |
| 139 | 149 | case "-p", "--print": | |
| 140 | 150 | a.Mode = ModePrint | |
| 151 | + | case "--stream": | |
| 152 | + | a.Mode = ModeStream | |
| 141 | 153 | case "--json": | |
| 142 | 154 | a.Mode = ModeJSON | |
| 143 | 155 | case "--rpc": | |
| @@ -211,6 +223,8 @@ func ParseArgs(in []string) (Args, error) { | |||
| 211 | 223 | a.InsecureTLS = true | |
| 212 | 224 | case "--no-yolo": | |
| 213 | 225 | a.NoYolo = true | |
| 226 | + | case "-y", "--yes": | |
| 227 | + | a.Yes = true | |
| 214 | 228 | case "--reasoning": | |
| 215 | 229 | v, err := want(&i, arg) | |
| 216 | 230 | if err != nil { | |
| @@ -364,6 +378,7 @@ func PrintHelp(version string) { | |||
| 364 | 378 | row{"zot", "interactive tui"}, | |
| 365 | 379 | row{"zot \"prompt\"", "interactive, pre-filled prompt"}, | |
| 366 | 380 | row{"zot -p \"prompt\"", "print final text, exit"}, | |
| 381 | + | row{"zot --stream \"prompt\"", "stream assistant text live, exit"}, | |
| 367 | 382 | row{"zot --json \"prompt\"", "newline-delimited json events, exit"}, | |
| 368 | 383 | row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | |
| 369 | 384 | ) | |
| @@ -409,6 +424,7 @@ func PrintHelp(version string) { | |||
| 409 | 424 | row{"--no-tools", "disable all tools"}, | |
| 410 | 425 | row{"--tools csv", "only enable the listed tools"}, | |
| 411 | 426 | row{"--no-yolo", "ask before running every tool call"}, | |
| 427 | + | row{"-y, --yes", "accept zot run consent without prompting"}, | |
| 412 | 428 | row{"--no-ext", "skip extension discovery for this run"}, | |
| 413 | 429 | row{"--no-skill", "skip all skill discovery for this run"}, | |
| 414 | 430 | ) | |
| @@ -17,6 +17,7 @@ type Mode string | |||||
| 17 | const ( | 17 | const ( | ||
| 18 | ModeInteractive Mode = "interactive" | 18 | ModeInteractive Mode = "interactive" | ||
| 19 | ModePrint Mode = "print" | 19 | ModePrint Mode = "print" | ||
| 20 | + | ModeStream Mode = "stream" | |||
| 20 | ModeJSON Mode = "json" | 21 | ModeJSON Mode = "json" | ||
| 21 | ModeRPC Mode = "rpc" | 22 | ModeRPC Mode = "rpc" | ||
| 22 | // ModeSwarmAgent is the long-lived, headless daemon mode used by | 23 | // ModeSwarmAgent is the long-lived, headless daemon mode used by | ||
| @@ -97,12 +98,21 @@ type Args struct { | |||||
| 97 | // freely so automated workflows keep working. | 98 | // freely so automated workflows keep working. | ||
| 98 | NoYolo bool | 99 | NoYolo bool | ||
| 99 | 100 | ||||
| 101 | + | // Yes accepts zotfile launch consent without an interactive | |||
| 102 | + | // Allow? prompt (zot run -y / --yes). Durable consent receipts | |||
| 103 | + | // are still written for modes other than bash ask. | |||
| 104 | + | Yes bool | |||
| 105 | + | ||||
| 100 | ListModels bool | 106 | ListModels bool | ||
| 101 | Help bool | 107 | Help bool | ||
| 102 | Version bool | 108 | Version bool | ||
| 103 | 109 | ||||
| 104 | Prompt string // concatenated positional args | 110 | Prompt string // concatenated positional args | ||
| 105 | 111 | ||||
| 112 | + | // StartupPre is an optional zotfile entry.pre value. Interactive | |||
| 113 | + | // mode auto-submits it once at startup before InitialInput handling. | |||
| 114 | + | StartupPre string | |||
| 115 | + | ||||
| 106 | // SwarmAgent is the inbox-socket path when this process is a | 116 | // SwarmAgent is the inbox-socket path when this process is a | ||
| 107 | // swarm-spawned agent. Empty in every other mode. Set by | 117 | // swarm-spawned agent. Empty in every other mode. Set by | ||
| 108 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | 118 | // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent. | ||
| @@ -138,6 +148,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 138 | a.Version = true | 148 | a.Version = true | ||
| 139 | case "-p", "--print": | 149 | case "-p", "--print": | ||
| 140 | a.Mode = ModePrint | 150 | a.Mode = ModePrint | ||
| 151 | + | case "--stream": | |||
| 152 | + | a.Mode = ModeStream | |||
| 141 | case "--json": | 153 | case "--json": | ||
| 142 | a.Mode = ModeJSON | 154 | a.Mode = ModeJSON | ||
| 143 | case "--rpc": | 155 | case "--rpc": | ||
| @@ -211,6 +223,8 @@ func ParseArgs(in []string) (Args, error) { | |||||
| 211 | a.InsecureTLS = true | 223 | a.InsecureTLS = true | ||
| 212 | case "--no-yolo": | 224 | case "--no-yolo": | ||
| 213 | a.NoYolo = true | 225 | a.NoYolo = true | ||
| 226 | + | case "-y", "--yes": | |||
| 227 | + | a.Yes = true | |||
| 214 | case "--reasoning": | 228 | case "--reasoning": | ||
| 215 | v, err := want(&i, arg) | 229 | v, err := want(&i, arg) | ||
| 216 | if err != nil { | 230 | if err != nil { | ||
| @@ -364,6 +378,7 @@ func PrintHelp(version string) { | |||||
| 364 | row{"zot", "interactive tui"}, | 378 | row{"zot", "interactive tui"}, | ||
| 365 | row{"zot \"prompt\"", "interactive, pre-filled prompt"}, | 379 | row{"zot \"prompt\"", "interactive, pre-filled prompt"}, | ||
| 366 | row{"zot -p \"prompt\"", "print final text, exit"}, | 380 | row{"zot -p \"prompt\"", "print final text, exit"}, | ||
| 381 | + | row{"zot --stream \"prompt\"", "stream assistant text live, exit"}, | |||
| 367 | row{"zot --json \"prompt\"", "newline-delimited json events, exit"}, | 382 | row{"zot --json \"prompt\"", "newline-delimited json events, exit"}, | ||
| 368 | row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | 383 | row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"}, | ||
| 369 | ) | 384 | ) | ||
| @@ -409,6 +424,7 @@ func PrintHelp(version string) { | |||||
| 409 | row{"--no-tools", "disable all tools"}, | 424 | row{"--no-tools", "disable all tools"}, | ||
| 410 | row{"--tools csv", "only enable the listed tools"}, | 425 | row{"--tools csv", "only enable the listed tools"}, | ||
| 411 | row{"--no-yolo", "ask before running every tool call"}, | 426 | row{"--no-yolo", "ask before running every tool call"}, | ||
| 427 | + | row{"-y, --yes", "accept zot run consent without prompting"}, | |||
| 412 | row{"--no-ext", "skip extension discovery for this run"}, | 428 | row{"--no-ext", "skip extension discovery for this run"}, | ||
| 413 | row{"--no-skill", "skip all skill discovery for this run"}, | 429 | row{"--no-skill", "skip all skill discovery for this run"}, | ||
| 414 | ) | 430 | ) | ||
M packages/agent/args_test.go +25 -0
| @@ -17,3 +17,28 @@ func TestParseArgsTemperatureRejectsOutOfRange(t *testing.T) { | |||
| 17 | 17 | t.Fatal("ParseArgs accepted out-of-range temperature") | |
| 18 | 18 | } | |
| 19 | 19 | } | |
| 20 | + | ||
| 21 | + | func TestParseArgsYes(t *testing.T) { | |
| 22 | + | for _, flag := range []string{"-y", "--yes"} { | |
| 23 | + | args, err := ParseArgs([]string{flag, "--print", "hi"}) | |
| 24 | + | if err != nil { | |
| 25 | + | t.Fatalf("ParseArgs(%q): %v", flag, err) | |
| 26 | + | } | |
| 27 | + | if !args.Yes { | |
| 28 | + | t.Fatalf("ParseArgs(%q): Yes = false", flag) | |
| 29 | + | } | |
| 30 | + | if args.Mode != ModePrint || args.Prompt != "hi" { | |
| 31 | + | t.Fatalf("ParseArgs(%q): Mode=%q Prompt=%q", flag, args.Mode, args.Prompt) | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestParseArgsStream(t *testing.T) { | |
| 37 | + | args, err := ParseArgs([]string{"--stream", "hi"}) | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | if args.Mode != ModeStream || args.Prompt != "hi" { | |
| 42 | + | t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt) | |
| 43 | + | } | |
| 44 | + | } | |
| @@ -17,3 +17,28 @@ func TestParseArgsTemperatureRejectsOutOfRange(t *testing.T) { | |||||
| 17 | t.Fatal("ParseArgs accepted out-of-range temperature") | 17 | t.Fatal("ParseArgs accepted out-of-range temperature") | ||
| 18 | } | 18 | } | ||
| 19 | } | 19 | } | ||
| 20 | + | ||||
| 21 | + | func TestParseArgsYes(t *testing.T) { | |||
| 22 | + | for _, flag := range []string{"-y", "--yes"} { | |||
| 23 | + | args, err := ParseArgs([]string{flag, "--print", "hi"}) | |||
| 24 | + | if err != nil { | |||
| 25 | + | t.Fatalf("ParseArgs(%q): %v", flag, err) | |||
| 26 | + | } | |||
| 27 | + | if !args.Yes { | |||
| 28 | + | t.Fatalf("ParseArgs(%q): Yes = false", flag) | |||
| 29 | + | } | |||
| 30 | + | if args.Mode != ModePrint || args.Prompt != "hi" { | |||
| 31 | + | t.Fatalf("ParseArgs(%q): Mode=%q Prompt=%q", flag, args.Mode, args.Prompt) | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestParseArgsStream(t *testing.T) { | |||
| 37 | + | args, err := ParseArgs([]string{"--stream", "hi"}) | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | if args.Mode != ModeStream || args.Prompt != "hi" { | |||
| 42 | + | t.Fatalf("Mode=%q Prompt=%q", args.Mode, args.Prompt) | |||
| 43 | + | } | |||
| 44 | + | } | |||
M packages/agent/cli.go +226 -50
| @@ -248,6 +248,8 @@ func runWithArgs(args Args, version string) error { | |||
| 248 | 248 | switch args.Mode { | |
| 249 | 249 | case ModePrint: | |
| 250 | 250 | return runPrintMode(ctx, args, version) | |
| 251 | + | case ModeStream: | |
| 252 | + | return runStreamMode(ctx, args, version) | |
| 251 | 253 | case ModeJSON: | |
| 252 | 254 | return runJSONMode(ctx, args, version) | |
| 253 | 255 | case ModeRPC: | |
| @@ -354,11 +356,72 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||
| 354 | 356 | } | |
| 355 | 357 | ||
| 356 | 358 | start := len(ag.Messages()) | |
| 359 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil { | |
| 360 | + | return err | |
| 361 | + | } | |
| 362 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 357 | 363 | err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | |
| 358 | 364 | WriteNewTranscript(ag, sess, start) | |
| 359 | 365 | return err | |
| 360 | 366 | } | |
| 361 | 367 | ||
| 368 | + | func runStreamMode(ctx context.Context, args Args, version string) error { | |
| 369 | + | if args.NoYolo { | |
| 370 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation") | |
| 371 | + | } | |
| 372 | + | r, err := Resolve(args, true) | |
| 373 | + | if err != nil { | |
| 374 | + | return err | |
| 375 | + | } | |
| 376 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |
| 377 | + | defer stopExt() | |
| 378 | + | ||
| 379 | + | ag := r.NewAgent() | |
| 380 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |
| 381 | + | sess, _ := openOrCreateSession(args, r, ag, version) | |
| 382 | + | defer sess.Close() | |
| 383 | + | ||
| 384 | + | prompt := args.Prompt | |
| 385 | + | if prompt == "" { | |
| 386 | + | piped, _ := readAllStdin() | |
| 387 | + | prompt = strings.TrimSpace(piped) | |
| 388 | + | } | |
| 389 | + | if prompt == "" { | |
| 390 | + | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | |
| 391 | + | } | |
| 392 | + | ||
| 393 | + | start := len(ag.Messages()) | |
| 394 | + | preSink := func(ev core.AgentEvent) { | |
| 395 | + | switch e := ev.(type) { | |
| 396 | + | case core.EvTextDelta: | |
| 397 | + | fmt.Fprint(os.Stdout, e.Delta) | |
| 398 | + | _ = os.Stdout.Sync() | |
| 399 | + | case core.EvAssistantMessage: | |
| 400 | + | // Fallback when the provider does not emit deltas. | |
| 401 | + | var sb strings.Builder | |
| 402 | + | for _, c := range e.Message.Content { | |
| 403 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 404 | + | if sb.Len() > 0 { | |
| 405 | + | sb.WriteString("\n") | |
| 406 | + | } | |
| 407 | + | sb.WriteString(tb.Text) | |
| 408 | + | } | |
| 409 | + | } | |
| 410 | + | if sb.Len() > 0 { | |
| 411 | + | fmt.Fprint(os.Stdout, sb.String()) | |
| 412 | + | _ = os.Stdout.Sync() | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |
| 417 | + | return err | |
| 418 | + | } | |
| 419 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 420 | + | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |
| 421 | + | WriteNewTranscript(ag, sess, start) | |
| 422 | + | return err | |
| 423 | + | } | |
| 424 | + | ||
| 362 | 425 | func runJSONMode(ctx context.Context, args Args, version string) error { | |
| 363 | 426 | if args.NoYolo { | |
| 364 | 427 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation") | |
| @@ -385,11 +448,119 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||
| 385 | 448 | } | |
| 386 | 449 | ||
| 387 | 450 | start := len(ag.Messages()) | |
| 451 | + | enc := json.NewEncoder(os.Stdout) | |
| 452 | + | preSink := func(ev core.AgentEvent) { | |
| 453 | + | _ = enc.Encode(modes.EventToJSON(ev)) | |
| 454 | + | } | |
| 455 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |
| 456 | + | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | |
| 457 | + | return err | |
| 458 | + | } | |
| 459 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |
| 388 | 460 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | |
| 389 | 461 | WriteNewTranscript(ag, sess, start) | |
| 390 | 462 | return err | |
| 391 | 463 | } | |
| 392 | 464 | ||
| 465 | + | // runZotfileStartupPre runs entry.pre before the main non-interactive prompt. | |
| 466 | + | // "!command" values execute via BashTool; other values are sent as an agent turn. | |
| 467 | + | // shellOut receives live shell chunks (typically os.Stderr); sink receives | |
| 468 | + | // agent events for plain-text pre (stream mode wires a live text sink). | |
| 469 | + | func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error { | |
| 470 | + | pre = strings.TrimSpace(pre) | |
| 471 | + | if pre == "" { | |
| 472 | + | return nil | |
| 473 | + | } | |
| 474 | + | if cmd, ok := modes.ShellEscapeCommand(pre); ok { | |
| 475 | + | raw, err := json.Marshal(map[string]any{"command": cmd}) | |
| 476 | + | if err != nil { | |
| 477 | + | return err | |
| 478 | + | } | |
| 479 | + | if shellOut != nil { | |
| 480 | + | fmt.Fprintf(shellOut, "$ %s\n", cmd) | |
| 481 | + | if f, ok := shellOut.(interface{ Sync() error }); ok { | |
| 482 | + | _ = f.Sync() | |
| 483 | + | } | |
| 484 | + | } | |
| 485 | + | progress := func(chunk string) { | |
| 486 | + | if shellOut == nil || chunk == "" { | |
| 487 | + | return | |
| 488 | + | } | |
| 489 | + | fmt.Fprint(shellOut, chunk) | |
| 490 | + | if f, ok := shellOut.(interface{ Sync() error }); ok { | |
| 491 | + | _ = f.Sync() | |
| 492 | + | } | |
| 493 | + | } | |
| 494 | + | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | |
| 495 | + | res, err := bash.Execute(ctx, raw, progress) | |
| 496 | + | if err != nil { | |
| 497 | + | return fmt.Errorf("zotfile entry.pre: %w", err) | |
| 498 | + | } | |
| 499 | + | if res.IsError { | |
| 500 | + | var sb strings.Builder | |
| 501 | + | for _, c := range res.Content { | |
| 502 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 503 | + | sb.WriteString(tb.Text) | |
| 504 | + | } | |
| 505 | + | } | |
| 506 | + | msg := strings.TrimSpace(sb.String()) | |
| 507 | + | if msg == "" { | |
| 508 | + | msg = "command failed" | |
| 509 | + | } | |
| 510 | + | return fmt.Errorf("zotfile entry.pre: %s", msg) | |
| 511 | + | } | |
| 512 | + | return nil | |
| 513 | + | } | |
| 514 | + | if ag == nil { | |
| 515 | + | return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts") | |
| 516 | + | } | |
| 517 | + | if sink == nil { | |
| 518 | + | sink = func(core.AgentEvent) {} | |
| 519 | + | } | |
| 520 | + | return ag.Prompt(ctx, pre, nil, sink) | |
| 521 | + | } | |
| 522 | + | ||
| 523 | + | // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered | |
| 524 | + | // skills and currently loaded extension tools) and updates the live | |
| 525 | + | // agent's registry and system prompt. Used after /reload-ext and after | |
| 526 | + | // zotfile entry.pre installs new skills or extensions. | |
| 527 | + | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn). | |
| 528 | + | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) { | |
| 529 | + | if ag == nil { | |
| 530 | + | return | |
| 531 | + | } | |
| 532 | + | resolved, err := Resolve(args, true) | |
| 533 | + | if err != nil { | |
| 534 | + | return | |
| 535 | + | } | |
| 536 | + | if sharedSandbox != nil { | |
| 537 | + | resolved.UseSandbox(sharedSandbox) | |
| 538 | + | } | |
| 539 | + | if extToolAdapter != nil { | |
| 540 | + | resolved.MergeExtensionTools(extToolAdapter) | |
| 541 | + | } | |
| 542 | + | reg := resolved.ToolRegistry | |
| 543 | + | if mutateRegistry != nil { | |
| 544 | + | reg = mutateRegistry(reg) | |
| 545 | + | } | |
| 546 | + | ag.SetTools(reg) | |
| 547 | + | ag.System = resolved.SystemPrompt | |
| 548 | + | } | |
| 549 | + | ||
| 550 | + | // reloadResourcesAfterStartupPre reloads extensions (if any) and | |
| 551 | + | // refreshes the agent tool registry + system prompt so skills/extensions | |
| 552 | + | // installed by entry.pre are visible to the following turn. | |
| 553 | + | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) { | |
| 554 | + | if strings.TrimSpace(args.StartupPre) == "" || ag == nil { | |
| 555 | + | return | |
| 556 | + | } | |
| 557 | + | adapter := &extToolAdapter{mgr: extMgr} | |
| 558 | + | if extMgr != nil { | |
| 559 | + | _ = extMgr.Reload(ctx, 2*time.Second) | |
| 560 | + | } | |
| 561 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil) | |
| 562 | + | } | |
| 563 | + | ||
| 393 | 564 | // ---- interactive mode: opens the TUI even without credentials ---- | |
| 394 | 565 | ||
| 395 | 566 | func runInteractive(ctx context.Context, args Args, version string) error { | |
| @@ -653,18 +824,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 653 | 824 | // /model swap since spawn, so re-read the live `ag` on each | |
| 654 | 825 | // invocation. | |
| 655 | 826 | extMgr.SetOnReload(func() { | |
| 656 | - | current := ag | |
| 827 | + | current := liveInteractiveAgent(iv, ag) | |
| 657 | 828 | if current == nil { | |
| 658 | 829 | return | |
| 659 | 830 | } | |
| 660 | - | resolved, err := Resolve(args, true) | |
| 661 | - | if err != nil { | |
| 662 | - | return | |
| 663 | - | } | |
| 664 | - | resolved.UseSandbox(sharedSandbox) | |
| 665 | - | resolved.MergeExtensionTools(extToolAdapter) | |
| 666 | - | injectSwarmSpawn(resolved.ToolRegistry) | |
| 667 | - | current.SetTools(resolved.ToolRegistry) | |
| 831 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |
| 668 | 832 | }) | |
| 669 | 833 | ||
| 670 | 834 | // Fire session_start once we know the manager's running. | |
| @@ -1013,47 +1177,59 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 1013 | 1177 | } | |
| 1014 | 1178 | ||
| 1015 | 1179 | iv = modes.NewInteractive(modes.InteractiveConfig{ | |
| 1016 | - | Terminal: term, | |
| 1017 | - | Theme: theme, | |
| 1018 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 1019 | - | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1020 | - | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 1021 | - | JailByDefault: initialCfg.JailByDefault, | |
| 1022 | - | QuickModelShortcuts: quickModelShortcuts, | |
| 1023 | - | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 1024 | - | RespectGitignore: initialCfg.RespectGitignore, | |
| 1025 | - | CompactMode: initialCfg.CompactMode, | |
| 1026 | - | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 1027 | - | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 1028 | - | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 1029 | - | ThemeName: initialCfg.Theme, | |
| 1030 | - | FlatTools: initialCfg.FlatToolRender(), | |
| 1031 | - | CompactUser: initialCfg.CompactUserInput(), | |
| 1032 | - | ExtensionThemes: extMgr.ThemeOptions, | |
| 1033 | - | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| 1034 | - | SettingsStore: configSettingsStore{}, | |
| 1035 | - | Model: r.Model, | |
| 1036 | - | Provider: r.Provider, | |
| 1037 | - | AuthMethod: r.AuthMethod, | |
| 1038 | - | BaseURL: r.BaseURL, | |
| 1039 | - | Reasoning: r.Reasoning, | |
| 1040 | - | SystemPrompt: r.SystemPrompt, | |
| 1041 | - | Tools: r.ToolRegistry, | |
| 1042 | - | MaxSteps: r.MaxSteps, | |
| 1043 | - | CWD: r.CWD, | |
| 1044 | - | StartupAgentName: args.AgentName, | |
| 1045 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 1046 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 1047 | - | StartupExtensionErrors: startupExtensionErrors, | |
| 1048 | - | StartupSkillNames: startupSkillNames(startupSkills), | |
| 1049 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 1050 | - | ZotHome: ZotHome(), | |
| 1051 | - | SessionsRoot: agentSessionsRoot(ZotHome(), args), | |
| 1052 | - | Version: version, | |
| 1053 | - | UpdateInfoChan: updateCh, | |
| 1054 | - | Sandbox: sharedSandbox, | |
| 1055 | - | Agent: ag, | |
| 1056 | - | InitialInput: args.Prompt, | |
| 1180 | + | Terminal: term, | |
| 1181 | + | Theme: theme, | |
| 1182 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | |
| 1183 | + | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | |
| 1184 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | |
| 1185 | + | JailByDefault: initialCfg.JailByDefault, | |
| 1186 | + | QuickModelShortcuts: quickModelShortcuts, | |
| 1187 | + | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | |
| 1188 | + | RespectGitignore: initialCfg.RespectGitignore, | |
| 1189 | + | CompactMode: initialCfg.CompactMode, | |
| 1190 | + | TUIInputStyle: initialCfg.TUIInputStyle, | |
| 1191 | + | TUIStatusPosition: initialCfg.TUIStatusPosition, | |
| 1192 | + | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | |
| 1193 | + | ThemeName: initialCfg.Theme, | |
| 1194 | + | FlatTools: initialCfg.FlatToolRender(), | |
| 1195 | + | CompactUser: initialCfg.CompactUserInput(), | |
| 1196 | + | ExtensionThemes: extMgr.ThemeOptions, | |
| 1197 | + | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | |
| 1198 | + | SettingsStore: configSettingsStore{}, | |
| 1199 | + | Model: r.Model, | |
| 1200 | + | Provider: r.Provider, | |
| 1201 | + | AuthMethod: r.AuthMethod, | |
| 1202 | + | BaseURL: r.BaseURL, | |
| 1203 | + | Reasoning: r.Reasoning, | |
| 1204 | + | SystemPrompt: r.SystemPrompt, | |
| 1205 | + | Tools: r.ToolRegistry, | |
| 1206 | + | MaxSteps: r.MaxSteps, | |
| 1207 | + | CWD: r.CWD, | |
| 1208 | + | StartupAgentName: args.AgentName, | |
| 1209 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), | |
| 1210 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), | |
| 1211 | + | StartupExtensionErrors: startupExtensionErrors, | |
| 1212 | + | StartupSkillNames: startupSkillNames(startupSkills), | |
| 1213 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | |
| 1214 | + | ZotHome: ZotHome(), | |
| 1215 | + | SessionsRoot: agentSessionsRoot(ZotHome(), args), | |
| 1216 | + | Version: version, | |
| 1217 | + | UpdateInfoChan: updateCh, | |
| 1218 | + | Sandbox: sharedSandbox, | |
| 1219 | + | Agent: ag, | |
| 1220 | + | InitialInput: args.Prompt, | |
| 1221 | + | StartupPre: args.StartupPre, | |
| 1222 | + | OnStartupPreDone: func() { | |
| 1223 | + | // entry.pre often installs skills/extensions; rediscover them | |
| 1224 | + | // before the user starts a model turn. | |
| 1225 | + | current := liveInteractiveAgent(iv, ag) | |
| 1226 | + | if extMgr != nil { | |
| 1227 | + | _ = extMgr.Reload(context.Background(), 2*time.Second) | |
| 1228 | + | } | |
| 1229 | + | if current != nil { | |
| 1230 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |
| 1231 | + | } | |
| 1232 | + | }, | |
| 1057 | 1233 | AuthManager: mgr, | |
| 1058 | 1234 | LlamaCPPConfig: ResolveLlamaCPPConfig, | |
| 1059 | 1235 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | |
| @@ -248,6 +248,8 @@ func runWithArgs(args Args, version string) error { | |||||
| 248 | switch args.Mode { | 248 | switch args.Mode { | ||
| 249 | case ModePrint: | 249 | case ModePrint: | ||
| 250 | return runPrintMode(ctx, args, version) | 250 | return runPrintMode(ctx, args, version) | ||
| 251 | + | case ModeStream: | |||
| 252 | + | return runStreamMode(ctx, args, version) | |||
| 251 | case ModeJSON: | 253 | case ModeJSON: | ||
| 252 | return runJSONMode(ctx, args, version) | 254 | return runJSONMode(ctx, args, version) | ||
| 253 | case ModeRPC: | 255 | case ModeRPC: | ||
| @@ -354,11 +356,72 @@ func runPrintMode(ctx context.Context, args Args, version string) error { | |||||
| 354 | } | 356 | } | ||
| 355 | 357 | ||||
| 356 | start := len(ag.Messages()) | 358 | start := len(ag.Messages()) | ||
| 359 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil { | |||
| 360 | + | return err | |||
| 361 | + | } | |||
| 362 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |||
| 357 | err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | 363 | err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout) | ||
| 358 | WriteNewTranscript(ag, sess, start) | 364 | WriteNewTranscript(ag, sess, start) | ||
| 359 | return err | 365 | return err | ||
| 360 | } | 366 | } | ||
| 361 | 367 | ||||
| 368 | + | func runStreamMode(ctx context.Context, args Args, version string) error { | |||
| 369 | + | if args.NoYolo { | |||
| 370 | + | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in stream mode (no interactive prompt available); tools will run without confirmation") | |||
| 371 | + | } | |||
| 372 | + | r, err := Resolve(args, true) | |||
| 373 | + | if err != nil { | |||
| 374 | + | return err | |||
| 375 | + | } | |||
| 376 | + | extMgr, stopExt := setupNonInteractiveExtensions(ctx, args, &r, version) | |||
| 377 | + | defer stopExt() | |||
| 378 | + | ||||
| 379 | + | ag := r.NewAgent() | |||
| 380 | + | wireNonInteractiveAgentExtHooks(ctx, ag, extMgr) | |||
| 381 | + | sess, _ := openOrCreateSession(args, r, ag, version) | |||
| 382 | + | defer sess.Close() | |||
| 383 | + | ||||
| 384 | + | prompt := args.Prompt | |||
| 385 | + | if prompt == "" { | |||
| 386 | + | piped, _ := readAllStdin() | |||
| 387 | + | prompt = strings.TrimSpace(piped) | |||
| 388 | + | } | |||
| 389 | + | if prompt == "" { | |||
| 390 | + | return fmt.Errorf("stream mode requires a prompt (arg or stdin)") | |||
| 391 | + | } | |||
| 392 | + | ||||
| 393 | + | start := len(ag.Messages()) | |||
| 394 | + | preSink := func(ev core.AgentEvent) { | |||
| 395 | + | switch e := ev.(type) { | |||
| 396 | + | case core.EvTextDelta: | |||
| 397 | + | fmt.Fprint(os.Stdout, e.Delta) | |||
| 398 | + | _ = os.Stdout.Sync() | |||
| 399 | + | case core.EvAssistantMessage: | |||
| 400 | + | // Fallback when the provider does not emit deltas. | |||
| 401 | + | var sb strings.Builder | |||
| 402 | + | for _, c := range e.Message.Content { | |||
| 403 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 404 | + | if sb.Len() > 0 { | |||
| 405 | + | sb.WriteString("\n") | |||
| 406 | + | } | |||
| 407 | + | sb.WriteString(tb.Text) | |||
| 408 | + | } | |||
| 409 | + | } | |||
| 410 | + | if sb.Len() > 0 { | |||
| 411 | + | fmt.Fprint(os.Stdout, sb.String()) | |||
| 412 | + | _ = os.Stdout.Sync() | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |||
| 417 | + | return err | |||
| 418 | + | } | |||
| 419 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |||
| 420 | + | err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout) | |||
| 421 | + | WriteNewTranscript(ag, sess, start) | |||
| 422 | + | return err | |||
| 423 | + | } | |||
| 424 | + | ||||
| 362 | func runJSONMode(ctx context.Context, args Args, version string) error { | 425 | func runJSONMode(ctx context.Context, args Args, version string) error { | ||
| 363 | if args.NoYolo { | 426 | if args.NoYolo { | ||
| 364 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation") | 427 | fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation") | ||
| @@ -385,11 +448,119 @@ func runJSONMode(ctx context.Context, args Args, version string) error { | |||||
| 385 | } | 448 | } | ||
| 386 | 449 | ||||
| 387 | start := len(ag.Messages()) | 450 | start := len(ag.Messages()) | ||
| 451 | + | enc := json.NewEncoder(os.Stdout) | |||
| 452 | + | preSink := func(ev core.AgentEvent) { | |||
| 453 | + | _ = enc.Encode(modes.EventToJSON(ev)) | |||
| 454 | + | } | |||
| 455 | + | if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil { | |||
| 456 | + | _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()}) | |||
| 457 | + | return err | |||
| 458 | + | } | |||
| 459 | + | reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag) | |||
| 388 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | 460 | err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout) | ||
| 389 | WriteNewTranscript(ag, sess, start) | 461 | WriteNewTranscript(ag, sess, start) | ||
| 390 | return err | 462 | return err | ||
| 391 | } | 463 | } | ||
| 392 | 464 | ||||
| 465 | + | // runZotfileStartupPre runs entry.pre before the main non-interactive prompt. | |||
| 466 | + | // "!command" values execute via BashTool; other values are sent as an agent turn. | |||
| 467 | + | // shellOut receives live shell chunks (typically os.Stderr); sink receives | |||
| 468 | + | // agent events for plain-text pre (stream mode wires a live text sink). | |||
| 469 | + | func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error { | |||
| 470 | + | pre = strings.TrimSpace(pre) | |||
| 471 | + | if pre == "" { | |||
| 472 | + | return nil | |||
| 473 | + | } | |||
| 474 | + | if cmd, ok := modes.ShellEscapeCommand(pre); ok { | |||
| 475 | + | raw, err := json.Marshal(map[string]any{"command": cmd}) | |||
| 476 | + | if err != nil { | |||
| 477 | + | return err | |||
| 478 | + | } | |||
| 479 | + | if shellOut != nil { | |||
| 480 | + | fmt.Fprintf(shellOut, "$ %s\n", cmd) | |||
| 481 | + | if f, ok := shellOut.(interface{ Sync() error }); ok { | |||
| 482 | + | _ = f.Sync() | |||
| 483 | + | } | |||
| 484 | + | } | |||
| 485 | + | progress := func(chunk string) { | |||
| 486 | + | if shellOut == nil || chunk == "" { | |||
| 487 | + | return | |||
| 488 | + | } | |||
| 489 | + | fmt.Fprint(shellOut, chunk) | |||
| 490 | + | if f, ok := shellOut.(interface{ Sync() error }); ok { | |||
| 491 | + | _ = f.Sync() | |||
| 492 | + | } | |||
| 493 | + | } | |||
| 494 | + | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | |||
| 495 | + | res, err := bash.Execute(ctx, raw, progress) | |||
| 496 | + | if err != nil { | |||
| 497 | + | return fmt.Errorf("zotfile entry.pre: %w", err) | |||
| 498 | + | } | |||
| 499 | + | if res.IsError { | |||
| 500 | + | var sb strings.Builder | |||
| 501 | + | for _, c := range res.Content { | |||
| 502 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 503 | + | sb.WriteString(tb.Text) | |||
| 504 | + | } | |||
| 505 | + | } | |||
| 506 | + | msg := strings.TrimSpace(sb.String()) | |||
| 507 | + | if msg == "" { | |||
| 508 | + | msg = "command failed" | |||
| 509 | + | } | |||
| 510 | + | return fmt.Errorf("zotfile entry.pre: %s", msg) | |||
| 511 | + | } | |||
| 512 | + | return nil | |||
| 513 | + | } | |||
| 514 | + | if ag == nil { | |||
| 515 | + | return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts") | |||
| 516 | + | } | |||
| 517 | + | if sink == nil { | |||
| 518 | + | sink = func(core.AgentEvent) {} | |||
| 519 | + | } | |||
| 520 | + | return ag.Prompt(ctx, pre, nil, sink) | |||
| 521 | + | } | |||
| 522 | + | ||||
| 523 | + | // refreshAgentToolsAndPrompt re-resolves tools (including rediscovered | |||
| 524 | + | // skills and currently loaded extension tools) and updates the live | |||
| 525 | + | // agent's registry and system prompt. Used after /reload-ext and after | |||
| 526 | + | // zotfile entry.pre installs new skills or extensions. | |||
| 527 | + | // mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn). | |||
| 528 | + | func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) { | |||
| 529 | + | if ag == nil { | |||
| 530 | + | return | |||
| 531 | + | } | |||
| 532 | + | resolved, err := Resolve(args, true) | |||
| 533 | + | if err != nil { | |||
| 534 | + | return | |||
| 535 | + | } | |||
| 536 | + | if sharedSandbox != nil { | |||
| 537 | + | resolved.UseSandbox(sharedSandbox) | |||
| 538 | + | } | |||
| 539 | + | if extToolAdapter != nil { | |||
| 540 | + | resolved.MergeExtensionTools(extToolAdapter) | |||
| 541 | + | } | |||
| 542 | + | reg := resolved.ToolRegistry | |||
| 543 | + | if mutateRegistry != nil { | |||
| 544 | + | reg = mutateRegistry(reg) | |||
| 545 | + | } | |||
| 546 | + | ag.SetTools(reg) | |||
| 547 | + | ag.System = resolved.SystemPrompt | |||
| 548 | + | } | |||
| 549 | + | ||||
| 550 | + | // reloadResourcesAfterStartupPre reloads extensions (if any) and | |||
| 551 | + | // refreshes the agent tool registry + system prompt so skills/extensions | |||
| 552 | + | // installed by entry.pre are visible to the following turn. | |||
| 553 | + | func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) { | |||
| 554 | + | if strings.TrimSpace(args.StartupPre) == "" || ag == nil { | |||
| 555 | + | return | |||
| 556 | + | } | |||
| 557 | + | adapter := &extToolAdapter{mgr: extMgr} | |||
| 558 | + | if extMgr != nil { | |||
| 559 | + | _ = extMgr.Reload(ctx, 2*time.Second) | |||
| 560 | + | } | |||
| 561 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil) | |||
| 562 | + | } | |||
| 563 | + | ||||
| 393 | // ---- interactive mode: opens the TUI even without credentials ---- | 564 | // ---- interactive mode: opens the TUI even without credentials ---- | ||
| 394 | 565 | ||||
| 395 | func runInteractive(ctx context.Context, args Args, version string) error { | 566 | func runInteractive(ctx context.Context, args Args, version string) error { | ||
| @@ -653,18 +824,11 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 653 | // /model swap since spawn, so re-read the live `ag` on each | 824 | // /model swap since spawn, so re-read the live `ag` on each | ||
| 654 | // invocation. | 825 | // invocation. | ||
| 655 | extMgr.SetOnReload(func() { | 826 | extMgr.SetOnReload(func() { | ||
| 656 | - | current := ag | 827 | + | current := liveInteractiveAgent(iv, ag) |
| 657 | if current == nil { | 828 | if current == nil { | ||
| 658 | return | 829 | return | ||
| 659 | } | 830 | } | ||
| 660 | - | resolved, err := Resolve(args, true) | 831 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) |
| 661 | - | if err != nil { | |||
| 662 | - | return | |||
| 663 | - | } | |||
| 664 | - | resolved.UseSandbox(sharedSandbox) | |||
| 665 | - | resolved.MergeExtensionTools(extToolAdapter) | |||
| 666 | - | injectSwarmSpawn(resolved.ToolRegistry) | |||
| 667 | - | current.SetTools(resolved.ToolRegistry) | |||
| 668 | }) | 832 | }) | ||
| 669 | 833 | ||||
| 670 | // Fire session_start once we know the manager's running. | 834 | // Fire session_start once we know the manager's running. | ||
| @@ -1013,47 +1177,59 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||||
| 1013 | } | 1177 | } | ||
| 1014 | 1178 | ||||
| 1015 | iv = modes.NewInteractive(modes.InteractiveConfig{ | 1179 | iv = modes.NewInteractive(modes.InteractiveConfig{ | ||
| 1016 | - | Terminal: term, | 1180 | + | Terminal: term, |
| 1017 | - | Theme: theme, | 1181 | + | Theme: theme, |
| 1018 | - | InlineImagesEnabled: initialCfg.InlineImagesEnabled, | 1182 | + | InlineImagesEnabled: initialCfg.InlineImagesEnabled, |
| 1019 | - | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, | 1183 | + | AutoSwarmEnabled: initialCfg.AutoSwarmEnabled, |
| 1020 | - | AutoCompactThreshold: initialCfg.AutoCompactThreshold, | 1184 | + | AutoCompactThreshold: initialCfg.AutoCompactThreshold, |
| 1021 | - | JailByDefault: initialCfg.JailByDefault, | 1185 | + | JailByDefault: initialCfg.JailByDefault, |
| 1022 | - | QuickModelShortcuts: quickModelShortcuts, | 1186 | + | QuickModelShortcuts: quickModelShortcuts, |
| 1023 | - | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, | 1187 | + | RecursiveFileSuggest: initialCfg.RecursiveFileSuggest, |
| 1024 | - | RespectGitignore: initialCfg.RespectGitignore, | 1188 | + | RespectGitignore: initialCfg.RespectGitignore, |
| 1025 | - | CompactMode: initialCfg.CompactMode, | 1189 | + | CompactMode: initialCfg.CompactMode, |
| 1026 | - | TUIInputStyle: initialCfg.TUIInputStyle, | 1190 | + | TUIInputStyle: initialCfg.TUIInputStyle, |
| 1027 | - | TUIStatusPosition: initialCfg.TUIStatusPosition, | 1191 | + | TUIStatusPosition: initialCfg.TUIStatusPosition, |
| 1028 | - | TUIWorkingPosition: initialCfg.TUIWorkingPosition, | 1192 | + | TUIWorkingPosition: initialCfg.TUIWorkingPosition, |
| 1029 | - | ThemeName: initialCfg.Theme, | 1193 | + | ThemeName: initialCfg.Theme, |
| 1030 | - | FlatTools: initialCfg.FlatToolRender(), | 1194 | + | FlatTools: initialCfg.FlatToolRender(), |
| 1031 | - | CompactUser: initialCfg.CompactUserInput(), | 1195 | + | CompactUser: initialCfg.CompactUserInput(), |
| 1032 | - | ExtensionThemes: extMgr.ThemeOptions, | 1196 | + | ExtensionThemes: extMgr.ThemeOptions, |
| 1033 | - | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, | 1197 | + | AutoSwarmSystemAddendum: AutoSwarmSystemAddendum, |
| 1034 | - | SettingsStore: configSettingsStore{}, | 1198 | + | SettingsStore: configSettingsStore{}, |
| 1035 | - | Model: r.Model, | 1199 | + | Model: r.Model, |
| 1036 | - | Provider: r.Provider, | 1200 | + | Provider: r.Provider, |
| 1037 | - | AuthMethod: r.AuthMethod, | 1201 | + | AuthMethod: r.AuthMethod, |
| 1038 | - | BaseURL: r.BaseURL, | 1202 | + | BaseURL: r.BaseURL, |
| 1039 | - | Reasoning: r.Reasoning, | 1203 | + | Reasoning: r.Reasoning, |
| 1040 | - | SystemPrompt: r.SystemPrompt, | 1204 | + | SystemPrompt: r.SystemPrompt, |
| 1041 | - | Tools: r.ToolRegistry, | 1205 | + | Tools: r.ToolRegistry, |
| 1042 | - | MaxSteps: r.MaxSteps, | 1206 | + | MaxSteps: r.MaxSteps, |
| 1043 | - | CWD: r.CWD, | 1207 | + | CWD: r.CWD, |
| 1044 | - | StartupAgentName: args.AgentName, | 1208 | + | StartupAgentName: args.AgentName, |
| 1045 | - | StartupContextPaths: instructionContextPaths(r.ContextFiles), | 1209 | + | StartupContextPaths: instructionContextPaths(r.ContextFiles), |
| 1046 | - | StartupExtensionNames: startupExtensionNames(extMgr.All()), | 1210 | + | StartupExtensionNames: startupExtensionNames(extMgr.All()), |
| 1047 | - | StartupExtensionErrors: startupExtensionErrors, | 1211 | + | StartupExtensionErrors: startupExtensionErrors, |
| 1048 | - | StartupSkillNames: startupSkillNames(startupSkills), | 1212 | + | StartupSkillNames: startupSkillNames(startupSkills), |
| 1049 | - | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, | 1213 | + | ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup, |
| 1050 | - | ZotHome: ZotHome(), | 1214 | + | ZotHome: ZotHome(), |
| 1051 | - | SessionsRoot: agentSessionsRoot(ZotHome(), args), | 1215 | + | SessionsRoot: agentSessionsRoot(ZotHome(), args), |
| 1052 | - | Version: version, | 1216 | + | Version: version, |
| 1053 | - | UpdateInfoChan: updateCh, | 1217 | + | UpdateInfoChan: updateCh, |
| 1054 | - | Sandbox: sharedSandbox, | 1218 | + | Sandbox: sharedSandbox, |
| 1055 | - | Agent: ag, | 1219 | + | Agent: ag, |
| 1056 | - | InitialInput: args.Prompt, | 1220 | + | InitialInput: args.Prompt, |
| 1221 | + | StartupPre: args.StartupPre, | |||
| 1222 | + | OnStartupPreDone: func() { | |||
| 1223 | + | // entry.pre often installs skills/extensions; rediscover them | |||
| 1224 | + | // before the user starts a model turn. | |||
| 1225 | + | current := liveInteractiveAgent(iv, ag) | |||
| 1226 | + | if extMgr != nil { | |||
| 1227 | + | _ = extMgr.Reload(context.Background(), 2*time.Second) | |||
| 1228 | + | } | |||
| 1229 | + | if current != nil { | |||
| 1230 | + | refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn) | |||
| 1231 | + | } | |||
| 1232 | + | }, | |||
| 1057 | AuthManager: mgr, | 1233 | AuthManager: mgr, | ||
| 1058 | LlamaCPPConfig: ResolveLlamaCPPConfig, | 1234 | LlamaCPPConfig: ResolveLlamaCPPConfig, | ||
| 1059 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | 1235 | RefreshLlamaCPPModels: RefreshLlamaCPPModels, | ||
M packages/agent/modes/interactive.go +109 -4
| @@ -131,6 +131,21 @@ type InteractiveConfig struct { | |||
| 131 | 131 | ||
| 132 | 132 | InitialInput string | |
| 133 | 133 | ||
| 134 | + | // StartupPre is auto-submitted once when the interactive session | |
| 135 | + | // opens, before InitialInput is applied. Uses the same path as a | |
| 136 | + | // typed Enter submit ("!" → shell escape, otherwise a model turn). | |
| 137 | + | StartupPre string | |
| 138 | + | ||
| 139 | + | // OnStartupPreDone runs after StartupPre finishes (shell escape or | |
| 140 | + | // model turn), before deferred InitialInput is applied. Hosts use | |
| 141 | + | // it to rediscover skills / reload extensions installed by pre. | |
| 142 | + | OnStartupPreDone func() | |
| 143 | + | ||
| 144 | + | // AutoSubmitInitial, when true, auto-submits InitialInput after | |
| 145 | + | // StartupPre completes (or immediately when StartupPre is empty). | |
| 146 | + | // When false, InitialInput only pre-fills the editor. | |
| 147 | + | AutoSubmitInitial bool | |
| 148 | + | ||
| 134 | 149 | // Auth is required. When the user runs /login, Interactive talks to | |
| 135 | 150 | // AuthManager to open a browser and wait for the callback. | |
| 136 | 151 | AuthManager *auth.Manager | |
| @@ -522,6 +537,15 @@ type Interactive struct { | |||
| 522 | 537 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | |
| 523 | 538 | // escape can start while one is in flight. | |
| 524 | 539 | shellRunning bool | |
| 540 | + | // shellLive is the accumulating stdout/stderr of the in-flight | |
| 541 | + | // shell escape, updated via BashTool progress for live rendering. | |
| 542 | + | shellLive string | |
| 543 | + | ||
| 544 | + | // awaitingStartupPre is true while the zotfile entry.pre auto-submit | |
| 545 | + | // is in flight. When it clears, deferredInitialInput is applied. | |
| 546 | + | awaitingStartupPre bool | |
| 547 | + | deferredInitialInput string | |
| 548 | + | autoSubmitDeferred bool | |
| 525 | 549 | ||
| 526 | 550 | // sessionLoading is true while a /sessions selection is being read | |
| 527 | 551 | // on a background goroutine. Keeping this off the input goroutine | |
| @@ -703,7 +727,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||
| 703 | 727 | i.redraw() | |
| 704 | 728 | }) | |
| 705 | 729 | ||
| 706 | - | if i.cfg.InitialInput != "" { | |
| 730 | + | if i.cfg.StartupPre != "" { | |
| 731 | + | i.deferredInitialInput = i.cfg.InitialInput | |
| 732 | + | i.autoSubmitDeferred = i.cfg.AutoSubmitInitial | |
| 733 | + | i.awaitingStartupPre = true | |
| 734 | + | i.Submit(i.cfg.StartupPre) | |
| 735 | + | } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" { | |
| 736 | + | i.Submit(i.cfg.InitialInput) | |
| 737 | + | } else if i.cfg.InitialInput != "" { | |
| 707 | 738 | i.ed.SetValue(i.cfg.InitialInput) | |
| 708 | 739 | } | |
| 709 | 740 | ||
| @@ -1062,6 +1093,16 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||
| 1062 | 1093 | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") | |
| 1063 | 1094 | } | |
| 1064 | 1095 | ||
| 1096 | + | // Live shell-escape output (!command / entry.pre) streams into the | |
| 1097 | + | // transcript area while the command runs, then is replaced by the | |
| 1098 | + | // final user-context message when it finishes. | |
| 1099 | + | if i.shellRunning && i.shellLive != "" { | |
| 1100 | + | for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") { | |
| 1101 | + | chat = append(chat, line) | |
| 1102 | + | } | |
| 1103 | + | chat = append(chat, "") | |
| 1104 | + | } | |
| 1105 | + | ||
| 1065 | 1106 | // Extension notes (notify / display) live just under the | |
| 1066 | 1107 | // transcript, above the dialog/editor band. Cleared by /clear. | |
| 1067 | 1108 | if len(i.extNotes) > 0 { | |
| @@ -2942,6 +2983,36 @@ func (i *Interactive) Submit(text string) { | |||
| 2942 | 2983 | i.startTurn(i.runCtx, text) | |
| 2943 | 2984 | } | |
| 2944 | 2985 | ||
| 2986 | + | // completeStartupPre applies deferred InitialInput after entry.pre finishes. | |
| 2987 | + | // When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise | |
| 2988 | + | // it only pre-fills the editor (CLI-supplied prompts). | |
| 2989 | + | func (i *Interactive) completeStartupPre() { | |
| 2990 | + | i.mu.Lock() | |
| 2991 | + | if !i.awaitingStartupPre { | |
| 2992 | + | i.mu.Unlock() | |
| 2993 | + | return | |
| 2994 | + | } | |
| 2995 | + | i.awaitingStartupPre = false | |
| 2996 | + | deferred := i.deferredInitialInput | |
| 2997 | + | auto := i.autoSubmitDeferred | |
| 2998 | + | i.deferredInitialInput = "" | |
| 2999 | + | i.autoSubmitDeferred = false | |
| 3000 | + | onDone := i.cfg.OnStartupPreDone | |
| 3001 | + | i.mu.Unlock() | |
| 3002 | + | if onDone != nil { | |
| 3003 | + | onDone() | |
| 3004 | + | } | |
| 3005 | + | if deferred == "" { | |
| 3006 | + | return | |
| 3007 | + | } | |
| 3008 | + | if auto { | |
| 3009 | + | i.Submit(deferred) | |
| 3010 | + | return | |
| 3011 | + | } | |
| 3012 | + | i.ed.SetValue(deferred) | |
| 3013 | + | i.invalidate() | |
| 3014 | + | } | |
| 3015 | + | ||
| 2945 | 3016 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | |
| 2946 | 3017 | // not provide startup context metadata. | |
| 2947 | 3018 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | |
| @@ -5366,6 +5437,15 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||
| 5366 | 5437 | // treated as not an escape so it falls through to the normal prompt | |
| 5367 | 5438 | // path rather than running an empty shell. | |
| 5368 | 5439 | func shellEscapeCommand(text string) (string, bool) { | |
| 5440 | + | return ShellEscapeCommand(text) | |
| 5441 | + | } | |
| 5442 | + | ||
| 5443 | + | // ShellEscapeCommand reports whether text is a "!command" shell | |
| 5444 | + | // escape and, if so, returns the command with the leading '!' (and | |
| 5445 | + | // surrounding whitespace) stripped. A bare "!" with no command is | |
| 5446 | + | // treated as not an escape so it falls through to the normal prompt | |
| 5447 | + | // path rather than running an empty shell. | |
| 5448 | + | func ShellEscapeCommand(text string) (string, bool) { | |
| 5369 | 5449 | trimmed := strings.TrimLeft(text, " \t") | |
| 5370 | 5450 | if !strings.HasPrefix(trimmed, "!") { | |
| 5371 | 5451 | return "", false | |
| @@ -5401,6 +5481,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5401 | 5481 | ctx, cancel := context.WithCancel(parent) | |
| 5402 | 5482 | i.busy = true | |
| 5403 | 5483 | i.shellRunning = true | |
| 5484 | + | i.shellLive = "$ " + cmd + "\n\n" | |
| 5404 | 5485 | i.cancelTurn = cancel | |
| 5405 | 5486 | i.statusErr = "" | |
| 5406 | 5487 | i.statusOK = "" | |
| @@ -5421,7 +5502,13 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5421 | 5502 | defer cancel() | |
| 5422 | 5503 | raw, _ := json.Marshal(map[string]any{"command": cmd}) | |
| 5423 | 5504 | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | |
| 5424 | - | res, err := bash.Execute(ctx, raw, nil) | |
| 5505 | + | progress := func(chunk string) { | |
| 5506 | + | i.mu.Lock() | |
| 5507 | + | i.shellLive += chunk | |
| 5508 | + | i.mu.Unlock() | |
| 5509 | + | i.invalidate() | |
| 5510 | + | } | |
| 5511 | + | res, err := bash.Execute(ctx, raw, progress) | |
| 5425 | 5512 | ||
| 5426 | 5513 | var out string | |
| 5427 | 5514 | if err != nil { | |
| @@ -5443,8 +5530,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5443 | 5530 | ||
| 5444 | 5531 | i.mu.Lock() | |
| 5445 | 5532 | i.shellRunning = false | |
| 5533 | + | i.shellLive = "" | |
| 5446 | 5534 | i.busy = false | |
| 5447 | 5535 | i.cancelTurn = nil | |
| 5536 | + | awaitingPre := i.awaitingStartupPre | |
| 5448 | 5537 | if failed { | |
| 5449 | 5538 | if cancelled { | |
| 5450 | 5539 | i.statusErr = "shell command cancelled" | |
| @@ -5458,6 +5547,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||
| 5458 | 5547 | } | |
| 5459 | 5548 | i.mu.Unlock() | |
| 5460 | 5549 | i.invalidate() | |
| 5550 | + | if awaitingPre { | |
| 5551 | + | i.completeStartupPre() | |
| 5552 | + | } | |
| 5461 | 5553 | }() | |
| 5462 | 5554 | } | |
| 5463 | 5555 | ||
| @@ -5471,6 +5563,14 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||
| 5471 | 5563 | ||
| 5472 | 5564 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) { | |
| 5473 | 5565 | if i.agent == nil { | |
| 5566 | + | // Text startup pre cannot run without credentials; continue so | |
| 5567 | + | // deferred InitialInput (pre-fill or auto-submit) still applies. | |
| 5568 | + | i.mu.Lock() | |
| 5569 | + | awaitingPre := i.awaitingStartupPre | |
| 5570 | + | i.mu.Unlock() | |
| 5571 | + | if awaitingPre { | |
| 5572 | + | i.completeStartupPre() | |
| 5573 | + | } | |
| 5474 | 5574 | return | |
| 5475 | 5575 | } | |
| 5476 | 5576 | // Pre-turn safety: if the most recent context measurement is | |
| @@ -5612,10 +5712,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5612 | 5712 | flush() | |
| 5613 | 5713 | } | |
| 5614 | 5714 | i.mu.Lock() | |
| 5715 | + | awaitingPre := i.awaitingStartupPre | |
| 5615 | 5716 | // Pop the next queued message, if any, and relaunch. | |
| 5616 | 5717 | var next string | |
| 5617 | 5718 | var hasNext bool | |
| 5618 | - | if len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 5719 | + | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 5619 | 5720 | next, i.queued = i.queued[0], i.queued[1:] | |
| 5620 | 5721 | hasNext = true | |
| 5621 | 5722 | } | |
| @@ -5635,13 +5736,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||
| 5635 | 5736 | if i.agent != nil { | |
| 5636 | 5737 | agentQueued = i.agent.QueuedMessageCount() | |
| 5637 | 5738 | } | |
| 5638 | - | shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 5739 | + | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | |
| 5639 | 5740 | i.mu.Unlock() | |
| 5640 | 5741 | i.invalidate() | |
| 5641 | 5742 | parent := i.runCtx | |
| 5642 | 5743 | if parent == nil { | |
| 5643 | 5744 | parent = context.Background() | |
| 5644 | 5745 | } | |
| 5746 | + | if awaitingPre { | |
| 5747 | + | i.completeStartupPre() | |
| 5748 | + | return | |
| 5749 | + | } | |
| 5645 | 5750 | switch { | |
| 5646 | 5751 | case hasNext: | |
| 5647 | 5752 | i.startTurn(parent, next) | |
| @@ -131,6 +131,21 @@ type InteractiveConfig struct { | |||||
| 131 | 131 | ||||
| 132 | InitialInput string | 132 | InitialInput string | ||
| 133 | 133 | ||||
| 134 | + | // StartupPre is auto-submitted once when the interactive session | |||
| 135 | + | // opens, before InitialInput is applied. Uses the same path as a | |||
| 136 | + | // typed Enter submit ("!" → shell escape, otherwise a model turn). | |||
| 137 | + | StartupPre string | |||
| 138 | + | ||||
| 139 | + | // OnStartupPreDone runs after StartupPre finishes (shell escape or | |||
| 140 | + | // model turn), before deferred InitialInput is applied. Hosts use | |||
| 141 | + | // it to rediscover skills / reload extensions installed by pre. | |||
| 142 | + | OnStartupPreDone func() | |||
| 143 | + | ||||
| 144 | + | // AutoSubmitInitial, when true, auto-submits InitialInput after | |||
| 145 | + | // StartupPre completes (or immediately when StartupPre is empty). | |||
| 146 | + | // When false, InitialInput only pre-fills the editor. | |||
| 147 | + | AutoSubmitInitial bool | |||
| 148 | + | ||||
| 134 | // Auth is required. When the user runs /login, Interactive talks to | 149 | // Auth is required. When the user runs /login, Interactive talks to | ||
| 135 | // AuthManager to open a browser and wait for the callback. | 150 | // AuthManager to open a browser and wait for the callback. | ||
| 136 | AuthManager *auth.Manager | 151 | AuthManager *auth.Manager | ||
| @@ -522,6 +537,15 @@ type Interactive struct { | |||||
| 522 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | 537 | // i.busy/i.cancelTurn so esc cancels it and no turn or other shell | ||
| 523 | // escape can start while one is in flight. | 538 | // escape can start while one is in flight. | ||
| 524 | shellRunning bool | 539 | shellRunning bool | ||
| 540 | + | // shellLive is the accumulating stdout/stderr of the in-flight | |||
| 541 | + | // shell escape, updated via BashTool progress for live rendering. | |||
| 542 | + | shellLive string | |||
| 543 | + | ||||
| 544 | + | // awaitingStartupPre is true while the zotfile entry.pre auto-submit | |||
| 545 | + | // is in flight. When it clears, deferredInitialInput is applied. | |||
| 546 | + | awaitingStartupPre bool | |||
| 547 | + | deferredInitialInput string | |||
| 548 | + | autoSubmitDeferred bool | |||
| 525 | 549 | ||||
| 526 | // sessionLoading is true while a /sessions selection is being read | 550 | // sessionLoading is true while a /sessions selection is being read | ||
| 527 | // on a background goroutine. Keeping this off the input goroutine | 551 | // on a background goroutine. Keeping this off the input goroutine | ||
| @@ -703,7 +727,14 @@ func (i *Interactive) Run(ctx context.Context) error { | |||||
| 703 | i.redraw() | 727 | i.redraw() | ||
| 704 | }) | 728 | }) | ||
| 705 | 729 | ||||
| 706 | - | if i.cfg.InitialInput != "" { | 730 | + | if i.cfg.StartupPre != "" { |
| 731 | + | i.deferredInitialInput = i.cfg.InitialInput | |||
| 732 | + | i.autoSubmitDeferred = i.cfg.AutoSubmitInitial | |||
| 733 | + | i.awaitingStartupPre = true | |||
| 734 | + | i.Submit(i.cfg.StartupPre) | |||
| 735 | + | } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" { | |||
| 736 | + | i.Submit(i.cfg.InitialInput) | |||
| 737 | + | } else if i.cfg.InitialInput != "" { | |||
| 707 | i.ed.SetValue(i.cfg.InitialInput) | 738 | i.ed.SetValue(i.cfg.InitialInput) | ||
| 708 | } | 739 | } | ||
| 709 | 740 | ||||
| @@ -1062,6 +1093,16 @@ func (i *Interactive) buildChatLocked(cols int) []string { | |||||
| 1062 | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") | 1093 | chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "") | ||
| 1063 | } | 1094 | } | ||
| 1064 | 1095 | ||||
| 1096 | + | // Live shell-escape output (!command / entry.pre) streams into the | |||
| 1097 | + | // transcript area while the command runs, then is replaced by the | |||
| 1098 | + | // final user-context message when it finishes. | |||
| 1099 | + | if i.shellRunning && i.shellLive != "" { | |||
| 1100 | + | for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") { | |||
| 1101 | + | chat = append(chat, line) | |||
| 1102 | + | } | |||
| 1103 | + | chat = append(chat, "") | |||
| 1104 | + | } | |||
| 1105 | + | ||||
| 1065 | // Extension notes (notify / display) live just under the | 1106 | // Extension notes (notify / display) live just under the | ||
| 1066 | // transcript, above the dialog/editor band. Cleared by /clear. | 1107 | // transcript, above the dialog/editor band. Cleared by /clear. | ||
| 1067 | if len(i.extNotes) > 0 { | 1108 | if len(i.extNotes) > 0 { | ||
| @@ -2942,6 +2983,36 @@ func (i *Interactive) Submit(text string) { | |||||
| 2942 | i.startTurn(i.runCtx, text) | 2983 | i.startTurn(i.runCtx, text) | ||
| 2943 | } | 2984 | } | ||
| 2944 | 2985 | ||||
| 2986 | + | // completeStartupPre applies deferred InitialInput after entry.pre finishes. | |||
| 2987 | + | // When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise | |||
| 2988 | + | // it only pre-fills the editor (CLI-supplied prompts). | |||
| 2989 | + | func (i *Interactive) completeStartupPre() { | |||
| 2990 | + | i.mu.Lock() | |||
| 2991 | + | if !i.awaitingStartupPre { | |||
| 2992 | + | i.mu.Unlock() | |||
| 2993 | + | return | |||
| 2994 | + | } | |||
| 2995 | + | i.awaitingStartupPre = false | |||
| 2996 | + | deferred := i.deferredInitialInput | |||
| 2997 | + | auto := i.autoSubmitDeferred | |||
| 2998 | + | i.deferredInitialInput = "" | |||
| 2999 | + | i.autoSubmitDeferred = false | |||
| 3000 | + | onDone := i.cfg.OnStartupPreDone | |||
| 3001 | + | i.mu.Unlock() | |||
| 3002 | + | if onDone != nil { | |||
| 3003 | + | onDone() | |||
| 3004 | + | } | |||
| 3005 | + | if deferred == "" { | |||
| 3006 | + | return | |||
| 3007 | + | } | |||
| 3008 | + | if auto { | |||
| 3009 | + | i.Submit(deferred) | |||
| 3010 | + | return | |||
| 3011 | + | } | |||
| 3012 | + | i.ed.SetValue(deferred) | |||
| 3013 | + | i.invalidate() | |||
| 3014 | + | } | |||
| 3015 | + | ||||
| 2945 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | 3016 | // ApplyChangedCWD is called by hosts after a successful /cd hook that do | ||
| 2946 | // not provide startup context metadata. | 3017 | // not provide startup context metadata. | ||
| 2947 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | 3018 | func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) { | ||
| @@ -5366,6 +5437,15 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) { | |||||
| 5366 | // treated as not an escape so it falls through to the normal prompt | 5437 | // treated as not an escape so it falls through to the normal prompt | ||
| 5367 | // path rather than running an empty shell. | 5438 | // path rather than running an empty shell. | ||
| 5368 | func shellEscapeCommand(text string) (string, bool) { | 5439 | func shellEscapeCommand(text string) (string, bool) { | ||
| 5440 | + | return ShellEscapeCommand(text) | |||
| 5441 | + | } | |||
| 5442 | + | ||||
| 5443 | + | // ShellEscapeCommand reports whether text is a "!command" shell | |||
| 5444 | + | // escape and, if so, returns the command with the leading '!' (and | |||
| 5445 | + | // surrounding whitespace) stripped. A bare "!" with no command is | |||
| 5446 | + | // treated as not an escape so it falls through to the normal prompt | |||
| 5447 | + | // path rather than running an empty shell. | |||
| 5448 | + | func ShellEscapeCommand(text string) (string, bool) { | |||
| 5369 | trimmed := strings.TrimLeft(text, " \t") | 5449 | trimmed := strings.TrimLeft(text, " \t") | ||
| 5370 | if !strings.HasPrefix(trimmed, "!") { | 5450 | if !strings.HasPrefix(trimmed, "!") { | ||
| 5371 | return "", false | 5451 | return "", false | ||
| @@ -5401,6 +5481,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5401 | ctx, cancel := context.WithCancel(parent) | 5481 | ctx, cancel := context.WithCancel(parent) | ||
| 5402 | i.busy = true | 5482 | i.busy = true | ||
| 5403 | i.shellRunning = true | 5483 | i.shellRunning = true | ||
| 5484 | + | i.shellLive = "$ " + cmd + "\n\n" | |||
| 5404 | i.cancelTurn = cancel | 5485 | i.cancelTurn = cancel | ||
| 5405 | i.statusErr = "" | 5486 | i.statusErr = "" | ||
| 5406 | i.statusOK = "" | 5487 | i.statusOK = "" | ||
| @@ -5421,7 +5502,13 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5421 | defer cancel() | 5502 | defer cancel() | ||
| 5422 | raw, _ := json.Marshal(map[string]any{"command": cmd}) | 5503 | raw, _ := json.Marshal(map[string]any{"command": cmd}) | ||
| 5423 | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | 5504 | bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox} | ||
| 5424 | - | res, err := bash.Execute(ctx, raw, nil) | 5505 | + | progress := func(chunk string) { |
| 5506 | + | i.mu.Lock() | |||
| 5507 | + | i.shellLive += chunk | |||
| 5508 | + | i.mu.Unlock() | |||
| 5509 | + | i.invalidate() | |||
| 5510 | + | } | |||
| 5511 | + | res, err := bash.Execute(ctx, raw, progress) | |||
| 5425 | 5512 | ||||
| 5426 | var out string | 5513 | var out string | ||
| 5427 | if err != nil { | 5514 | if err != nil { | ||
| @@ -5443,8 +5530,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5443 | 5530 | ||||
| 5444 | i.mu.Lock() | 5531 | i.mu.Lock() | ||
| 5445 | i.shellRunning = false | 5532 | i.shellRunning = false | ||
| 5533 | + | i.shellLive = "" | |||
| 5446 | i.busy = false | 5534 | i.busy = false | ||
| 5447 | i.cancelTurn = nil | 5535 | i.cancelTurn = nil | ||
| 5536 | + | awaitingPre := i.awaitingStartupPre | |||
| 5448 | if failed { | 5537 | if failed { | ||
| 5449 | if cancelled { | 5538 | if cancelled { | ||
| 5450 | i.statusErr = "shell command cancelled" | 5539 | i.statusErr = "shell command cancelled" | ||
| @@ -5458,6 +5547,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) { | |||||
| 5458 | } | 5547 | } | ||
| 5459 | i.mu.Unlock() | 5548 | i.mu.Unlock() | ||
| 5460 | i.invalidate() | 5549 | i.invalidate() | ||
| 5550 | + | if awaitingPre { | |||
| 5551 | + | i.completeStartupPre() | |||
| 5552 | + | } | |||
| 5461 | }() | 5553 | }() | ||
| 5462 | } | 5554 | } | ||
| 5463 | 5555 | ||||
| @@ -5471,6 +5563,14 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string, | |||||
| 5471 | 5563 | ||||
| 5472 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) { | 5564 | func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) { | ||
| 5473 | if i.agent == nil { | 5565 | if i.agent == nil { | ||
| 5566 | + | // Text startup pre cannot run without credentials; continue so | |||
| 5567 | + | // deferred InitialInput (pre-fill or auto-submit) still applies. | |||
| 5568 | + | i.mu.Lock() | |||
| 5569 | + | awaitingPre := i.awaitingStartupPre | |||
| 5570 | + | i.mu.Unlock() | |||
| 5571 | + | if awaitingPre { | |||
| 5572 | + | i.completeStartupPre() | |||
| 5573 | + | } | |||
| 5474 | return | 5574 | return | ||
| 5475 | } | 5575 | } | ||
| 5476 | // Pre-turn safety: if the most recent context measurement is | 5576 | // Pre-turn safety: if the most recent context measurement is | ||
| @@ -5612,10 +5712,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 5612 | flush() | 5712 | flush() | ||
| 5613 | } | 5713 | } | ||
| 5614 | i.mu.Lock() | 5714 | i.mu.Lock() | ||
| 5715 | + | awaitingPre := i.awaitingStartupPre | |||
| 5615 | // Pop the next queued message, if any, and relaunch. | 5716 | // Pop the next queued message, if any, and relaunch. | ||
| 5616 | var next string | 5717 | var next string | ||
| 5617 | var hasNext bool | 5718 | var hasNext bool | ||
| 5618 | - | if len(i.queued) > 0 && ctx.Err() == nil && err == nil { | 5719 | + | if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil { |
| 5619 | next, i.queued = i.queued[0], i.queued[1:] | 5720 | next, i.queued = i.queued[0], i.queued[1:] | ||
| 5620 | hasNext = true | 5721 | hasNext = true | ||
| 5621 | } | 5722 | } | ||
| @@ -5635,13 +5736,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im | |||||
| 5635 | if i.agent != nil { | 5736 | if i.agent != nil { | ||
| 5636 | agentQueued = i.agent.QueuedMessageCount() | 5737 | agentQueued = i.agent.QueuedMessageCount() | ||
| 5637 | } | 5738 | } | ||
| 5638 | - | shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() | 5739 | + | shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked() |
| 5639 | i.mu.Unlock() | 5740 | i.mu.Unlock() | ||
| 5640 | i.invalidate() | 5741 | i.invalidate() | ||
| 5641 | parent := i.runCtx | 5742 | parent := i.runCtx | ||
| 5642 | if parent == nil { | 5743 | if parent == nil { | ||
| 5643 | parent = context.Background() | 5744 | parent = context.Background() | ||
| 5644 | } | 5745 | } | ||
| 5746 | + | if awaitingPre { | |||
| 5747 | + | i.completeStartupPre() | |||
| 5748 | + | return | |||
| 5749 | + | } | |||
| 5645 | switch { | 5750 | switch { | ||
| 5646 | case hasNext: | 5751 | case hasNext: | ||
| 5647 | i.startTurn(parent, next) | 5752 | i.startTurn(parent, next) | ||
A packages/agent/modes/startup_pre_test.go +107 -0
| @@ -0,0 +1,107 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "runtime" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | |
| 14 | + | i := NewInteractive(InteractiveConfig{}) | |
| 15 | + | i.awaitingStartupPre = true | |
| 16 | + | i.deferredInitialInput = "Bom dia!" | |
| 17 | + | i.autoSubmitDeferred = false | |
| 18 | + | i.completeStartupPre() | |
| 19 | + | if got := i.ed.Value(); got != "Bom dia!" { | |
| 20 | + | t.Fatalf("editor = %q, want Bom dia!", got) | |
| 21 | + | } | |
| 22 | + | i.mu.Lock() | |
| 23 | + | awaiting := i.awaitingStartupPre | |
| 24 | + | i.mu.Unlock() | |
| 25 | + | if awaiting { | |
| 26 | + | t.Fatal("awaitingStartupPre still set after completeStartupPre") | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestCompleteStartupPreCallsOnDone(t *testing.T) { | |
| 31 | + | called := false | |
| 32 | + | i := NewInteractive(InteractiveConfig{ | |
| 33 | + | OnStartupPreDone: func() { called = true }, | |
| 34 | + | }) | |
| 35 | + | i.awaitingStartupPre = true | |
| 36 | + | i.completeStartupPre() | |
| 37 | + | if !called { | |
| 38 | + | t.Fatal("OnStartupPreDone was not called") | |
| 39 | + | } | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | func TestStartupPreShellThenPrefill(t *testing.T) { | |
| 43 | + | agent := core.NewAgent(nil, "", "", nil) | |
| 44 | + | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |
| 45 | + | i.runCtx = context.Background() | |
| 46 | + | i.awaitingStartupPre = true | |
| 47 | + | i.deferredInitialInput = "after-pre" | |
| 48 | + | i.autoSubmitDeferred = false | |
| 49 | + | ||
| 50 | + | cmd := "printf zot-pre" | |
| 51 | + | if runtime.GOOS == "windows" { | |
| 52 | + | cmd = "echo zot-pre" | |
| 53 | + | } | |
| 54 | + | i.startShellEscape(context.Background(), cmd) | |
| 55 | + | ||
| 56 | + | deadline := time.Now().Add(5 * time.Second) | |
| 57 | + | for time.Now().Before(deadline) { | |
| 58 | + | i.mu.Lock() | |
| 59 | + | running := i.shellRunning | |
| 60 | + | awaiting := i.awaitingStartupPre | |
| 61 | + | i.mu.Unlock() | |
| 62 | + | if !running && !awaiting { | |
| 63 | + | if got := i.ed.Value(); got != "after-pre" { | |
| 64 | + | t.Fatalf("editor = %q, want after-pre", got) | |
| 65 | + | } | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | time.Sleep(10 * time.Millisecond) | |
| 69 | + | } | |
| 70 | + | t.Fatal("startup pre did not complete and prefill the editor") | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func TestStartupPreShellThenAutoSubmitShell(t *testing.T) { | |
| 74 | + | agent := core.NewAgent(nil, "", "", nil) | |
| 75 | + | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |
| 76 | + | i.runCtx = context.Background() | |
| 77 | + | i.awaitingStartupPre = true | |
| 78 | + | i.deferredInitialInput = "!printf zot-default" | |
| 79 | + | if runtime.GOOS == "windows" { | |
| 80 | + | i.deferredInitialInput = "!echo zot-default" | |
| 81 | + | } | |
| 82 | + | i.autoSubmitDeferred = true | |
| 83 | + | ||
| 84 | + | cmd := "printf zot-pre" | |
| 85 | + | if runtime.GOOS == "windows" { | |
| 86 | + | cmd = "echo zot-pre" | |
| 87 | + | } | |
| 88 | + | i.startShellEscape(context.Background(), cmd) | |
| 89 | + | ||
| 90 | + | deadline := time.Now().Add(5 * time.Second) | |
| 91 | + | for time.Now().Before(deadline) { | |
| 92 | + | messages := agent.Messages() | |
| 93 | + | i.mu.Lock() | |
| 94 | + | running := i.shellRunning || i.busy | |
| 95 | + | awaiting := i.awaitingStartupPre | |
| 96 | + | i.mu.Unlock() | |
| 97 | + | if len(messages) >= 2 && !running && !awaiting { | |
| 98 | + | joined := userMessageText(messages[0]) + "\n" + userMessageText(messages[1]) | |
| 99 | + | if !strings.Contains(joined, "zot-pre") || !strings.Contains(joined, "zot-default") { | |
| 100 | + | t.Fatalf("context = %q, want both pre and default shell output", joined) | |
| 101 | + | } | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | time.Sleep(10 * time.Millisecond) | |
| 105 | + | } | |
| 106 | + | t.Fatal("startup pre did not auto-submit the deferred prompt") | |
| 107 | + | } | |
| @@ -0,0 +1,107 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "runtime" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestCompleteStartupPrePrefillsEditor(t *testing.T) { | |||
| 14 | + | i := NewInteractive(InteractiveConfig{}) | |||
| 15 | + | i.awaitingStartupPre = true | |||
| 16 | + | i.deferredInitialInput = "Bom dia!" | |||
| 17 | + | i.autoSubmitDeferred = false | |||
| 18 | + | i.completeStartupPre() | |||
| 19 | + | if got := i.ed.Value(); got != "Bom dia!" { | |||
| 20 | + | t.Fatalf("editor = %q, want Bom dia!", got) | |||
| 21 | + | } | |||
| 22 | + | i.mu.Lock() | |||
| 23 | + | awaiting := i.awaitingStartupPre | |||
| 24 | + | i.mu.Unlock() | |||
| 25 | + | if awaiting { | |||
| 26 | + | t.Fatal("awaitingStartupPre still set after completeStartupPre") | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestCompleteStartupPreCallsOnDone(t *testing.T) { | |||
| 31 | + | called := false | |||
| 32 | + | i := NewInteractive(InteractiveConfig{ | |||
| 33 | + | OnStartupPreDone: func() { called = true }, | |||
| 34 | + | }) | |||
| 35 | + | i.awaitingStartupPre = true | |||
| 36 | + | i.completeStartupPre() | |||
| 37 | + | if !called { | |||
| 38 | + | t.Fatal("OnStartupPreDone was not called") | |||
| 39 | + | } | |||
| 40 | + | } | |||
| 41 | + | ||||
| 42 | + | func TestStartupPreShellThenPrefill(t *testing.T) { | |||
| 43 | + | agent := core.NewAgent(nil, "", "", nil) | |||
| 44 | + | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |||
| 45 | + | i.runCtx = context.Background() | |||
| 46 | + | i.awaitingStartupPre = true | |||
| 47 | + | i.deferredInitialInput = "after-pre" | |||
| 48 | + | i.autoSubmitDeferred = false | |||
| 49 | + | ||||
| 50 | + | cmd := "printf zot-pre" | |||
| 51 | + | if runtime.GOOS == "windows" { | |||
| 52 | + | cmd = "echo zot-pre" | |||
| 53 | + | } | |||
| 54 | + | i.startShellEscape(context.Background(), cmd) | |||
| 55 | + | ||||
| 56 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 57 | + | for time.Now().Before(deadline) { | |||
| 58 | + | i.mu.Lock() | |||
| 59 | + | running := i.shellRunning | |||
| 60 | + | awaiting := i.awaitingStartupPre | |||
| 61 | + | i.mu.Unlock() | |||
| 62 | + | if !running && !awaiting { | |||
| 63 | + | if got := i.ed.Value(); got != "after-pre" { | |||
| 64 | + | t.Fatalf("editor = %q, want after-pre", got) | |||
| 65 | + | } | |||
| 66 | + | return | |||
| 67 | + | } | |||
| 68 | + | time.Sleep(10 * time.Millisecond) | |||
| 69 | + | } | |||
| 70 | + | t.Fatal("startup pre did not complete and prefill the editor") | |||
| 71 | + | } | |||
| 72 | + | ||||
| 73 | + | func TestStartupPreShellThenAutoSubmitShell(t *testing.T) { | |||
| 74 | + | agent := core.NewAgent(nil, "", "", nil) | |||
| 75 | + | i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()}) | |||
| 76 | + | i.runCtx = context.Background() | |||
| 77 | + | i.awaitingStartupPre = true | |||
| 78 | + | i.deferredInitialInput = "!printf zot-default" | |||
| 79 | + | if runtime.GOOS == "windows" { | |||
| 80 | + | i.deferredInitialInput = "!echo zot-default" | |||
| 81 | + | } | |||
| 82 | + | i.autoSubmitDeferred = true | |||
| 83 | + | ||||
| 84 | + | cmd := "printf zot-pre" | |||
| 85 | + | if runtime.GOOS == "windows" { | |||
| 86 | + | cmd = "echo zot-pre" | |||
| 87 | + | } | |||
| 88 | + | i.startShellEscape(context.Background(), cmd) | |||
| 89 | + | ||||
| 90 | + | deadline := time.Now().Add(5 * time.Second) | |||
| 91 | + | for time.Now().Before(deadline) { | |||
| 92 | + | messages := agent.Messages() | |||
| 93 | + | i.mu.Lock() | |||
| 94 | + | running := i.shellRunning || i.busy | |||
| 95 | + | awaiting := i.awaitingStartupPre | |||
| 96 | + | i.mu.Unlock() | |||
| 97 | + | if len(messages) >= 2 && !running && !awaiting { | |||
| 98 | + | joined := userMessageText(messages[0]) + "\n" + userMessageText(messages[1]) | |||
| 99 | + | if !strings.Contains(joined, "zot-pre") || !strings.Contains(joined, "zot-default") { | |||
| 100 | + | t.Fatalf("context = %q, want both pre and default shell output", joined) | |||
| 101 | + | } | |||
| 102 | + | return | |||
| 103 | + | } | |||
| 104 | + | time.Sleep(10 * time.Millisecond) | |||
| 105 | + | } | |||
| 106 | + | t.Fatal("startup pre did not auto-submit the deferred prompt") | |||
| 107 | + | } | |||
A packages/agent/modes/stream.go +115 -0
| @@ -0,0 +1,115 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "strings" | |
| 9 | + | ||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // RunStream runs the agent and writes assistant text to out as it | |
| 15 | + | // arrives (EvTextDelta). Tool activity is written to stderr so stdout | |
| 16 | + | // stays the live assistant transcript. When a provider does not emit | |
| 17 | + | // deltas, the full assistant text is written when the message completes. | |
| 18 | + | func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | |
| 19 | + | return RunStreamWithDiag(ctx, ag, prompt, images, out, os.Stderr) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // RunStreamWithDiag is RunStream with an explicit diagnostics writer | |
| 23 | + | // for tool activity (tests pass a buffer). | |
| 24 | + | func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error { | |
| 25 | + | if diag == nil { | |
| 26 | + | diag = io.Discard | |
| 27 | + | } | |
| 28 | + | var ( | |
| 29 | + | runErr error | |
| 30 | + | streamed bool | |
| 31 | + | wroteText bool | |
| 32 | + | lastWasNL bool | |
| 33 | + | ) | |
| 34 | + | flush := func() { | |
| 35 | + | if f, ok := out.(interface{ Sync() error }); ok { | |
| 36 | + | _ = f.Sync() | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | writeText := func(s string) { | |
| 40 | + | if s == "" { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | _, _ = fmt.Fprint(out, s) | |
| 44 | + | flush() | |
| 45 | + | wroteText = true | |
| 46 | + | lastWasNL = strings.HasSuffix(s, "\n") | |
| 47 | + | } | |
| 48 | + | assistantText := func(m provider.Message) string { | |
| 49 | + | var sb strings.Builder | |
| 50 | + | for _, c := range m.Content { | |
| 51 | + | if tb, ok := c.(provider.TextBlock); ok { | |
| 52 | + | if sb.Len() > 0 { | |
| 53 | + | sb.WriteString("\n") | |
| 54 | + | } | |
| 55 | + | sb.WriteString(tb.Text) | |
| 56 | + | } | |
| 57 | + | } | |
| 58 | + | return sb.String() | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | sink := func(ev core.AgentEvent) { | |
| 62 | + | switch e := ev.(type) { | |
| 63 | + | case core.EvAssistantStart: | |
| 64 | + | streamed = false | |
| 65 | + | case core.EvTextDelta: | |
| 66 | + | streamed = true | |
| 67 | + | writeText(e.Delta) | |
| 68 | + | case core.EvAssistantMessage: | |
| 69 | + | if !streamed { | |
| 70 | + | writeText(assistantText(e.Message)) | |
| 71 | + | } | |
| 72 | + | case core.EvToolCall: | |
| 73 | + | fmt.Fprintf(diag, "\n[tool] %s\n", e.Name) | |
| 74 | + | if len(e.Args) > 0 { | |
| 75 | + | fmt.Fprintf(diag, "%s\n", string(e.Args)) | |
| 76 | + | } | |
| 77 | + | case core.EvToolProgress: | |
| 78 | + | if e.Text != "" { | |
| 79 | + | fmt.Fprint(diag, e.Text) | |
| 80 | + | } | |
| 81 | + | case core.EvToolResult: | |
| 82 | + | for _, c := range e.Result.Content { | |
| 83 | + | if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" { | |
| 84 | + | fmt.Fprint(diag, tb.Text) | |
| 85 | + | if !strings.HasSuffix(tb.Text, "\n") { | |
| 86 | + | fmt.Fprint(diag, "\n") | |
| 87 | + | } | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | if e.Result.IsError { | |
| 91 | + | fmt.Fprintln(diag, "[tool error]") | |
| 92 | + | } | |
| 93 | + | case core.EvTurnEnd: | |
| 94 | + | if e.Err != nil { | |
| 95 | + | runErr = e.Err | |
| 96 | + | } | |
| 97 | + | case core.EvError: | |
| 98 | + | if e.Err != nil { | |
| 99 | + | runErr = e.Err | |
| 100 | + | } | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |
| 105 | + | return err | |
| 106 | + | } | |
| 107 | + | if runErr != nil { | |
| 108 | + | return runErr | |
| 109 | + | } | |
| 110 | + | if wroteText && !lastWasNL { | |
| 111 | + | _, _ = fmt.Fprint(out, "\n") | |
| 112 | + | flush() | |
| 113 | + | } | |
| 114 | + | return nil | |
| 115 | + | } | |
| @@ -0,0 +1,115 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "strings" | |||
| 9 | + | ||||
| 10 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 11 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // RunStream runs the agent and writes assistant text to out as it | |||
| 15 | + | // arrives (EvTextDelta). Tool activity is written to stderr so stdout | |||
| 16 | + | // stays the live assistant transcript. When a provider does not emit | |||
| 17 | + | // deltas, the full assistant text is written when the message completes. | |||
| 18 | + | func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error { | |||
| 19 | + | return RunStreamWithDiag(ctx, ag, prompt, images, out, os.Stderr) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // RunStreamWithDiag is RunStream with an explicit diagnostics writer | |||
| 23 | + | // for tool activity (tests pass a buffer). | |||
| 24 | + | func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error { | |||
| 25 | + | if diag == nil { | |||
| 26 | + | diag = io.Discard | |||
| 27 | + | } | |||
| 28 | + | var ( | |||
| 29 | + | runErr error | |||
| 30 | + | streamed bool | |||
| 31 | + | wroteText bool | |||
| 32 | + | lastWasNL bool | |||
| 33 | + | ) | |||
| 34 | + | flush := func() { | |||
| 35 | + | if f, ok := out.(interface{ Sync() error }); ok { | |||
| 36 | + | _ = f.Sync() | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | writeText := func(s string) { | |||
| 40 | + | if s == "" { | |||
| 41 | + | return | |||
| 42 | + | } | |||
| 43 | + | _, _ = fmt.Fprint(out, s) | |||
| 44 | + | flush() | |||
| 45 | + | wroteText = true | |||
| 46 | + | lastWasNL = strings.HasSuffix(s, "\n") | |||
| 47 | + | } | |||
| 48 | + | assistantText := func(m provider.Message) string { | |||
| 49 | + | var sb strings.Builder | |||
| 50 | + | for _, c := range m.Content { | |||
| 51 | + | if tb, ok := c.(provider.TextBlock); ok { | |||
| 52 | + | if sb.Len() > 0 { | |||
| 53 | + | sb.WriteString("\n") | |||
| 54 | + | } | |||
| 55 | + | sb.WriteString(tb.Text) | |||
| 56 | + | } | |||
| 57 | + | } | |||
| 58 | + | return sb.String() | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | sink := func(ev core.AgentEvent) { | |||
| 62 | + | switch e := ev.(type) { | |||
| 63 | + | case core.EvAssistantStart: | |||
| 64 | + | streamed = false | |||
| 65 | + | case core.EvTextDelta: | |||
| 66 | + | streamed = true | |||
| 67 | + | writeText(e.Delta) | |||
| 68 | + | case core.EvAssistantMessage: | |||
| 69 | + | if !streamed { | |||
| 70 | + | writeText(assistantText(e.Message)) | |||
| 71 | + | } | |||
| 72 | + | case core.EvToolCall: | |||
| 73 | + | fmt.Fprintf(diag, "\n[tool] %s\n", e.Name) | |||
| 74 | + | if len(e.Args) > 0 { | |||
| 75 | + | fmt.Fprintf(diag, "%s\n", string(e.Args)) | |||
| 76 | + | } | |||
| 77 | + | case core.EvToolProgress: | |||
| 78 | + | if e.Text != "" { | |||
| 79 | + | fmt.Fprint(diag, e.Text) | |||
| 80 | + | } | |||
| 81 | + | case core.EvToolResult: | |||
| 82 | + | for _, c := range e.Result.Content { | |||
| 83 | + | if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" { | |||
| 84 | + | fmt.Fprint(diag, tb.Text) | |||
| 85 | + | if !strings.HasSuffix(tb.Text, "\n") { | |||
| 86 | + | fmt.Fprint(diag, "\n") | |||
| 87 | + | } | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | if e.Result.IsError { | |||
| 91 | + | fmt.Fprintln(diag, "[tool error]") | |||
| 92 | + | } | |||
| 93 | + | case core.EvTurnEnd: | |||
| 94 | + | if e.Err != nil { | |||
| 95 | + | runErr = e.Err | |||
| 96 | + | } | |||
| 97 | + | case core.EvError: | |||
| 98 | + | if e.Err != nil { | |||
| 99 | + | runErr = e.Err | |||
| 100 | + | } | |||
| 101 | + | } | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | if err := ag.Prompt(ctx, prompt, images, sink); err != nil { | |||
| 105 | + | return err | |||
| 106 | + | } | |||
| 107 | + | if runErr != nil { | |||
| 108 | + | return runErr | |||
| 109 | + | } | |||
| 110 | + | if wroteText && !lastWasNL { | |||
| 111 | + | _, _ = fmt.Fprint(out, "\n") | |||
| 112 | + | flush() | |||
| 113 | + | } | |||
| 114 | + | return nil | |||
| 115 | + | } | |||
A packages/agent/modes/stream_test.go +77 -0
| @@ -0,0 +1,77 @@ | |||
| 1 | + | package modes | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | type streamTestClient struct { | |
| 13 | + | events []provider.Event | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (c *streamTestClient) Name() string { return "stream-test" } | |
| 17 | + | ||
| 18 | + | func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |
| 19 | + | out := make(chan provider.Event, len(c.events)) | |
| 20 | + | go func() { | |
| 21 | + | defer close(out) | |
| 22 | + | for _, ev := range c.events { | |
| 23 | + | select { | |
| 24 | + | case <-ctx.Done(): | |
| 25 | + | return | |
| 26 | + | case out <- ev: | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | }() | |
| 30 | + | return out, nil | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestRunStreamWritesDeltasLive(t *testing.T) { | |
| 34 | + | client := &streamTestClient{events: []provider.Event{ | |
| 35 | + | provider.EventTextDelta{Delta: "Hel"}, | |
| 36 | + | provider.EventTextDelta{Delta: "lo"}, | |
| 37 | + | provider.EventDone{ | |
| 38 | + | Stop: provider.StopEnd, | |
| 39 | + | Message: provider.Message{ | |
| 40 | + | Role: provider.RoleAssistant, | |
| 41 | + | Content: []provider.Content{provider.TextBlock{Text: "Hello"}}, | |
| 42 | + | }, | |
| 43 | + | }, | |
| 44 | + | }} | |
| 45 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 46 | + | var out, diag bytes.Buffer | |
| 47 | + | if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, &diag); err != nil { | |
| 48 | + | t.Fatal(err) | |
| 49 | + | } | |
| 50 | + | if got := out.String(); got != "Hello\n" { | |
| 51 | + | t.Fatalf("stdout = %q, want Hello\\n", got) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestRunStreamFallbackWithoutDeltas(t *testing.T) { | |
| 56 | + | client := &streamTestClient{events: []provider.Event{ | |
| 57 | + | provider.EventDone{ | |
| 58 | + | Stop: provider.StopEnd, | |
| 59 | + | Message: provider.Message{ | |
| 60 | + | Role: provider.RoleAssistant, | |
| 61 | + | Content: []provider.Content{provider.TextBlock{Text: "done"}}, | |
| 62 | + | }, | |
| 63 | + | }, | |
| 64 | + | }} | |
| 65 | + | ag := core.NewAgent(client, "test-model", "", nil) | |
| 66 | + | var out bytes.Buffer | |
| 67 | + | if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, ioDiscard{}); err != nil { | |
| 68 | + | t.Fatal(err) | |
| 69 | + | } | |
| 70 | + | if got := out.String(); got != "done\n" { | |
| 71 | + | t.Fatalf("stdout = %q, want done\\n", got) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | type ioDiscard struct{} | |
| 76 | + | ||
| 77 | + | func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } | |
| @@ -0,0 +1,77 @@ | |||||
| 1 | + | package modes | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/patriceckhart/zot/packages/core" | |||
| 9 | + | "github.com/patriceckhart/zot/packages/provider" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type streamTestClient struct { | |||
| 13 | + | events []provider.Event | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (c *streamTestClient) Name() string { return "stream-test" } | |||
| 17 | + | ||||
| 18 | + | func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) { | |||
| 19 | + | out := make(chan provider.Event, len(c.events)) | |||
| 20 | + | go func() { | |||
| 21 | + | defer close(out) | |||
| 22 | + | for _, ev := range c.events { | |||
| 23 | + | select { | |||
| 24 | + | case <-ctx.Done(): | |||
| 25 | + | return | |||
| 26 | + | case out <- ev: | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | }() | |||
| 30 | + | return out, nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestRunStreamWritesDeltasLive(t *testing.T) { | |||
| 34 | + | client := &streamTestClient{events: []provider.Event{ | |||
| 35 | + | provider.EventTextDelta{Delta: "Hel"}, | |||
| 36 | + | provider.EventTextDelta{Delta: "lo"}, | |||
| 37 | + | provider.EventDone{ | |||
| 38 | + | Stop: provider.StopEnd, | |||
| 39 | + | Message: provider.Message{ | |||
| 40 | + | Role: provider.RoleAssistant, | |||
| 41 | + | Content: []provider.Content{provider.TextBlock{Text: "Hello"}}, | |||
| 42 | + | }, | |||
| 43 | + | }, | |||
| 44 | + | }} | |||
| 45 | + | ag := core.NewAgent(client, "test-model", "", nil) | |||
| 46 | + | var out, diag bytes.Buffer | |||
| 47 | + | if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, &diag); err != nil { | |||
| 48 | + | t.Fatal(err) | |||
| 49 | + | } | |||
| 50 | + | if got := out.String(); got != "Hello\n" { | |||
| 51 | + | t.Fatalf("stdout = %q, want Hello\\n", got) | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestRunStreamFallbackWithoutDeltas(t *testing.T) { | |||
| 56 | + | client := &streamTestClient{events: []provider.Event{ | |||
| 57 | + | provider.EventDone{ | |||
| 58 | + | Stop: provider.StopEnd, | |||
| 59 | + | Message: provider.Message{ | |||
| 60 | + | Role: provider.RoleAssistant, | |||
| 61 | + | Content: []provider.Content{provider.TextBlock{Text: "done"}}, | |||
| 62 | + | }, | |||
| 63 | + | }, | |||
| 64 | + | }} | |||
| 65 | + | ag := core.NewAgent(client, "test-model", "", nil) | |||
| 66 | + | var out bytes.Buffer | |||
| 67 | + | if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, ioDiscard{}); err != nil { | |||
| 68 | + | t.Fatal(err) | |||
| 69 | + | } | |||
| 70 | + | if got := out.String(); got != "done\n" { | |||
| 71 | + | t.Fatalf("stdout = %q, want done\\n", got) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | type ioDiscard struct{} | |||
| 76 | + | ||||
| 77 | + | func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil } | |||
M packages/agent/tools/permissions_test.go +20 -0
| @@ -63,3 +63,23 @@ func TestBashAllowlistRejectsShellEscapes(t *testing.T) { | |||
| 63 | 63 | t.Fatal("unlisted command was allowed") | |
| 64 | 64 | } | |
| 65 | 65 | } | |
| 66 | + | ||
| 67 | + | func TestBashModeNoneDenies(t *testing.T) { | |
| 68 | + | var p PermissionSet | |
| 69 | + | p.Bash.Mode = "none" | |
| 70 | + | s := NewSandbox(t.TempDir()) | |
| 71 | + | s.SetPermissions(&p) | |
| 72 | + | if err := s.CheckBashPermission("echo ok"); err == nil { | |
| 73 | + | t.Fatal("none mode allowed bash") | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | func TestBashModeAskAllows(t *testing.T) { | |
| 78 | + | var p PermissionSet | |
| 79 | + | p.Bash.Mode = "ask" | |
| 80 | + | s := NewSandbox(t.TempDir()) | |
| 81 | + | s.SetPermissions(&p) | |
| 82 | + | if err := s.CheckBashPermission("rm -rf /tmp/x"); err != nil { | |
| 83 | + | t.Fatalf("ask mode rejected command: %v", err) | |
| 84 | + | } | |
| 85 | + | } | |
| @@ -63,3 +63,23 @@ func TestBashAllowlistRejectsShellEscapes(t *testing.T) { | |||||
| 63 | t.Fatal("unlisted command was allowed") | 63 | t.Fatal("unlisted command was allowed") | ||
| 64 | } | 64 | } | ||
| 65 | } | 65 | } | ||
| 66 | + | ||||
| 67 | + | func TestBashModeNoneDenies(t *testing.T) { | |||
| 68 | + | var p PermissionSet | |||
| 69 | + | p.Bash.Mode = "none" | |||
| 70 | + | s := NewSandbox(t.TempDir()) | |||
| 71 | + | s.SetPermissions(&p) | |||
| 72 | + | if err := s.CheckBashPermission("echo ok"); err == nil { | |||
| 73 | + | t.Fatal("none mode allowed bash") | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | func TestBashModeAskAllows(t *testing.T) { | |||
| 78 | + | var p PermissionSet | |||
| 79 | + | p.Bash.Mode = "ask" | |||
| 80 | + | s := NewSandbox(t.TempDir()) | |||
| 81 | + | s.SetPermissions(&p) | |||
| 82 | + | if err := s.CheckBashPermission("rm -rf /tmp/x"); err != nil { | |||
| 83 | + | t.Fatalf("ask mode rejected command: %v", err) | |||
| 84 | + | } | |||
| 85 | + | } | |||
M packages/agent/zotfile.go +64 -20
| @@ -50,6 +50,7 @@ type ZotfileManifest struct { | |||
| 50 | 50 | } `json:"requirements"` | |
| 51 | 51 | Entry struct { | |
| 52 | 52 | Greeting string `json:"greeting"` | |
| 53 | + | Pre string `json:"pre"` | |
| 53 | 54 | DefaultPrompt *string `json:"default_prompt"` | |
| 54 | 55 | } `json:"entry"` | |
| 55 | 56 | ReplaceSystemPrompt bool `json:"replace_system_prompt"` | |
| @@ -99,13 +100,20 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) { | |||
| 99 | 100 | if len(rawArgs) < 2 { | |
| 100 | 101 | return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL") | |
| 101 | 102 | } | |
| 102 | - | ref := rawArgs[1] | |
| 103 | - | rest := rawArgs[2:] | |
| 103 | + | yes, remaining := peelLeadingYes(rawArgs[1:]) | |
| 104 | + | if len(remaining) < 1 { | |
| 105 | + | return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL") | |
| 106 | + | } | |
| 107 | + | ref := remaining[0] | |
| 108 | + | rest := remaining[1:] | |
| 104 | 109 | args, err := ParseArgs(rest) | |
| 105 | 110 | if err != nil { | |
| 106 | 111 | PrintHelp(version) | |
| 107 | 112 | return true, err | |
| 108 | 113 | } | |
| 114 | + | if yes { | |
| 115 | + | args.Yes = true | |
| 116 | + | } | |
| 109 | 117 | return true, runLocalZotfile(ref, args, version) | |
| 110 | 118 | default: | |
| 111 | 119 | return false, nil | |
| @@ -129,26 +137,32 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||
| 129 | 137 | return err | |
| 130 | 138 | } | |
| 131 | 139 | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | |
| 132 | - | allowed, err := consentZotfile(zf, perms) | |
| 140 | + | allowed, err := consentZotfile(zf, perms, args.Yes) | |
| 133 | 141 | if err != nil { | |
| 134 | 142 | return err | |
| 135 | 143 | } | |
| 136 | 144 | if !allowed { | |
| 137 | 145 | return nil | |
| 138 | 146 | } | |
| 139 | - | agentPath := filepath.Join(zf.Dir, "AGENT.md") | |
| 140 | - | agentPrompt, err := os.ReadFile(agentPath) | |
| 141 | - | if err != nil { | |
| 142 | - | return fmt.Errorf("read AGENT.md: %w", err) | |
| 143 | - | } | |
| 144 | 147 | if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil { | |
| 145 | 148 | return err | |
| 146 | 149 | } | |
| 147 | - | if zf.Manifest.ReplaceSystemPrompt { | |
| 148 | - | args.SystemPrompt = strings.TrimSpace(string(agentPrompt)) | |
| 149 | - | } else { | |
| 150 | - | args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt))) | |
| 150 | + | agentPath := filepath.Join(zf.Dir, "AGENT.md") | |
| 151 | + | agentPrompt, err := os.ReadFile(agentPath) | |
| 152 | + | switch { | |
| 153 | + | case err == nil: | |
| 154 | + | text := strings.TrimSpace(string(agentPrompt)) | |
| 155 | + | if zf.Manifest.ReplaceSystemPrompt { | |
| 156 | + | args.SystemPrompt = text | |
| 157 | + | } else { | |
| 158 | + | args.AppendSystemPrompt = append(args.AppendSystemPrompt, text) | |
| 159 | + | } | |
| 160 | + | case os.IsNotExist(err): | |
| 161 | + | // AGENT.md is optional; missing file leaves the system prompt unchanged. | |
| 162 | + | default: | |
| 163 | + | return fmt.Errorf("read AGENT.md: %w", err) | |
| 151 | 164 | } | |
| 165 | + | args.StartupPre = strings.TrimSpace(zf.Manifest.Entry.Pre) | |
| 152 | 166 | if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil { | |
| 153 | 167 | args.Prompt = *zf.Manifest.Entry.DefaultPrompt | |
| 154 | 168 | } | |
| @@ -397,7 +411,7 @@ func resolveZotfileRef(ref string) (string, error) { | |||
| 397 | 411 | return officialZotfileCollection + "/" + parts[0], nil | |
| 398 | 412 | } | |
| 399 | 413 | if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) { | |
| 400 | - | return "https://github.com/patriceckhart/" + parts[0] + "/" + parts[1], nil | |
| 414 | + | return "https://github.com/" + parts[0] + "/" + parts[1], nil | |
| 401 | 415 | } | |
| 402 | 416 | return ref, nil | |
| 403 | 417 | } | |
| @@ -566,9 +580,17 @@ func readZotManifest(dir string) (ZotfileManifest, error) { | |||
| 566 | 580 | } | |
| 567 | 581 | ||
| 568 | 582 | func validateZotfileDir(dir string) error { | |
| 569 | - | st, err := os.Stat(filepath.Join(dir, "AGENT.md")) | |
| 570 | - | if err != nil || !st.Mode().IsRegular() { | |
| 571 | - | return fmt.Errorf("AGENT.md is required") | |
| 583 | + | agentPath := filepath.Join(dir, "AGENT.md") | |
| 584 | + | st, err := os.Stat(agentPath) | |
| 585 | + | switch { | |
| 586 | + | case err == nil: | |
| 587 | + | if !st.Mode().IsRegular() { | |
| 588 | + | return fmt.Errorf("AGENT.md must be a regular file") | |
| 589 | + | } | |
| 590 | + | case os.IsNotExist(err): | |
| 591 | + | // AGENT.md is optional. | |
| 592 | + | default: | |
| 593 | + | return fmt.Errorf("stat AGENT.md: %w", err) | |
| 572 | 594 | } | |
| 573 | 595 | if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 { | |
| 574 | 596 | return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions") | |
| @@ -755,9 +777,23 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |||
| 755 | 777 | ||
| 756 | 778 | var execLookPath = exec.LookPath | |
| 757 | 779 | ||
| 758 | - | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | |
| 759 | - | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |
| 760 | - | return true, nil | |
| 780 | + | func peelLeadingYes(args []string) (yes bool, rest []string) { | |
| 781 | + | rest = args | |
| 782 | + | for len(rest) > 0 { | |
| 783 | + | switch rest[0] { | |
| 784 | + | case "-y", "--yes": | |
| 785 | + | yes = true | |
| 786 | + | rest = rest[1:] | |
| 787 | + | default: | |
| 788 | + | return yes, rest | |
| 789 | + | } | |
| 790 | + | } | |
| 791 | + | return yes, rest | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) { | |
| 795 | + | if autoYes || os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |
| 796 | + | return acceptZotfileConsent(zf, perms) | |
| 761 | 797 | } | |
| 762 | 798 | // "ask" deliberately requires approval on every launch. Other consent | |
| 763 | 799 | // is durable only for this exact artifact digest, so any package change | |
| @@ -776,15 +812,23 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | |||
| 776 | 812 | } | |
| 777 | 813 | fmt.Print(formatZotfileConsent(zf, perms, th, interactive)) | |
| 778 | 814 | if !interactive { | |
| 779 | - | return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | |
| 815 | + | return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZOT_AGENT_CONSENT=1 to allow") | |
| 780 | 816 | } | |
| 781 | 817 | allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th) | |
| 782 | 818 | if err != nil || !allowed { | |
| 783 | 819 | return allowed, err | |
| 784 | 820 | } | |
| 821 | + | return acceptZotfileConsent(zf, perms) | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | // acceptZotfileConsent records durable consent when the bash mode is not | |
| 825 | + | // "ask". Ask mode never caches receipts so every launch re-prompts unless | |
| 826 | + | // -y / ZOT_AGENT_CONSENT is used for that run. | |
| 827 | + | func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | |
| 785 | 828 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | |
| 786 | 829 | return true, nil | |
| 787 | 830 | } | |
| 831 | + | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | |
| 788 | 832 | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | |
| 789 | 833 | return false, fmt.Errorf("save agent consent: %w", err) | |
| 790 | 834 | } | |
| @@ -50,6 +50,7 @@ type ZotfileManifest struct { | |||||
| 50 | } `json:"requirements"` | 50 | } `json:"requirements"` | ||
| 51 | Entry struct { | 51 | Entry struct { | ||
| 52 | Greeting string `json:"greeting"` | 52 | Greeting string `json:"greeting"` | ||
| 53 | + | Pre string `json:"pre"` | |||
| 53 | DefaultPrompt *string `json:"default_prompt"` | 54 | DefaultPrompt *string `json:"default_prompt"` | ||
| 54 | } `json:"entry"` | 55 | } `json:"entry"` | ||
| 55 | ReplaceSystemPrompt bool `json:"replace_system_prompt"` | 56 | ReplaceSystemPrompt bool `json:"replace_system_prompt"` | ||
| @@ -99,13 +100,20 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) { | |||||
| 99 | if len(rawArgs) < 2 { | 100 | if len(rawArgs) < 2 { | ||
| 100 | return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL") | 101 | return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL") | ||
| 101 | } | 102 | } | ||
| 102 | - | ref := rawArgs[1] | 103 | + | yes, remaining := peelLeadingYes(rawArgs[1:]) |
| 103 | - | rest := rawArgs[2:] | 104 | + | if len(remaining) < 1 { |
| 105 | + | return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL") | |||
| 106 | + | } | |||
| 107 | + | ref := remaining[0] | |||
| 108 | + | rest := remaining[1:] | |||
| 104 | args, err := ParseArgs(rest) | 109 | args, err := ParseArgs(rest) | ||
| 105 | if err != nil { | 110 | if err != nil { | ||
| 106 | PrintHelp(version) | 111 | PrintHelp(version) | ||
| 107 | return true, err | 112 | return true, err | ||
| 108 | } | 113 | } | ||
| 114 | + | if yes { | |||
| 115 | + | args.Yes = true | |||
| 116 | + | } | |||
| 109 | return true, runLocalZotfile(ref, args, version) | 117 | return true, runLocalZotfile(ref, args, version) | ||
| 110 | default: | 118 | default: | ||
| 111 | return false, nil | 119 | return false, nil | ||
| @@ -129,26 +137,32 @@ func runLocalZotfile(ref string, args Args, version string) error { | |||||
| 129 | return err | 137 | return err | ||
| 130 | } | 138 | } | ||
| 131 | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | 139 | perms := zf.Manifest.Permissions.Expand(args.CWD, agentData) | ||
| 132 | - | allowed, err := consentZotfile(zf, perms) | 140 | + | allowed, err := consentZotfile(zf, perms, args.Yes) |
| 133 | if err != nil { | 141 | if err != nil { | ||
| 134 | return err | 142 | return err | ||
| 135 | } | 143 | } | ||
| 136 | if !allowed { | 144 | if !allowed { | ||
| 137 | return nil | 145 | return nil | ||
| 138 | } | 146 | } | ||
| 139 | - | agentPath := filepath.Join(zf.Dir, "AGENT.md") | |||
| 140 | - | agentPrompt, err := os.ReadFile(agentPath) | |||
| 141 | - | if err != nil { | |||
| 142 | - | return fmt.Errorf("read AGENT.md: %w", err) | |||
| 143 | - | } | |||
| 144 | if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil { | 147 | if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil { | ||
| 145 | return err | 148 | return err | ||
| 146 | } | 149 | } | ||
| 147 | - | if zf.Manifest.ReplaceSystemPrompt { | 150 | + | agentPath := filepath.Join(zf.Dir, "AGENT.md") |
| 148 | - | args.SystemPrompt = strings.TrimSpace(string(agentPrompt)) | 151 | + | agentPrompt, err := os.ReadFile(agentPath) |
| 149 | - | } else { | 152 | + | switch { |
| 150 | - | args.AppendSystemPrompt = append(args.AppendSystemPrompt, strings.TrimSpace(string(agentPrompt))) | 153 | + | case err == nil: |
| 154 | + | text := strings.TrimSpace(string(agentPrompt)) | |||
| 155 | + | if zf.Manifest.ReplaceSystemPrompt { | |||
| 156 | + | args.SystemPrompt = text | |||
| 157 | + | } else { | |||
| 158 | + | args.AppendSystemPrompt = append(args.AppendSystemPrompt, text) | |||
| 159 | + | } | |||
| 160 | + | case os.IsNotExist(err): | |||
| 161 | + | // AGENT.md is optional; missing file leaves the system prompt unchanged. | |||
| 162 | + | default: | |||
| 163 | + | return fmt.Errorf("read AGENT.md: %w", err) | |||
| 151 | } | 164 | } | ||
| 165 | + | args.StartupPre = strings.TrimSpace(zf.Manifest.Entry.Pre) | |||
| 152 | if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil { | 166 | if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil { | ||
| 153 | args.Prompt = *zf.Manifest.Entry.DefaultPrompt | 167 | args.Prompt = *zf.Manifest.Entry.DefaultPrompt | ||
| 154 | } | 168 | } | ||
| @@ -397,7 +411,7 @@ func resolveZotfileRef(ref string) (string, error) { | |||||
| 397 | return officialZotfileCollection + "/" + parts[0], nil | 411 | return officialZotfileCollection + "/" + parts[0], nil | ||
| 398 | } | 412 | } | ||
| 399 | if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) { | 413 | if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) { | ||
| 400 | - | return "https://github.com/patriceckhart/" + parts[0] + "/" + parts[1], nil | 414 | + | return "https://github.com/" + parts[0] + "/" + parts[1], nil |
| 401 | } | 415 | } | ||
| 402 | return ref, nil | 416 | return ref, nil | ||
| 403 | } | 417 | } | ||
| @@ -566,9 +580,17 @@ func readZotManifest(dir string) (ZotfileManifest, error) { | |||||
| 566 | } | 580 | } | ||
| 567 | 581 | ||||
| 568 | func validateZotfileDir(dir string) error { | 582 | func validateZotfileDir(dir string) error { | ||
| 569 | - | st, err := os.Stat(filepath.Join(dir, "AGENT.md")) | 583 | + | agentPath := filepath.Join(dir, "AGENT.md") |
| 570 | - | if err != nil || !st.Mode().IsRegular() { | 584 | + | st, err := os.Stat(agentPath) |
| 571 | - | return fmt.Errorf("AGENT.md is required") | 585 | + | switch { |
| 586 | + | case err == nil: | |||
| 587 | + | if !st.Mode().IsRegular() { | |||
| 588 | + | return fmt.Errorf("AGENT.md must be a regular file") | |||
| 589 | + | } | |||
| 590 | + | case os.IsNotExist(err): | |||
| 591 | + | // AGENT.md is optional. | |||
| 592 | + | default: | |||
| 593 | + | return fmt.Errorf("stat AGENT.md: %w", err) | |||
| 572 | } | 594 | } | ||
| 573 | if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 { | 595 | if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 { | ||
| 574 | return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions") | 596 | return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions") | ||
| @@ -755,9 +777,23 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error { | |||||
| 755 | 777 | ||||
| 756 | var execLookPath = exec.LookPath | 778 | var execLookPath = exec.LookPath | ||
| 757 | 779 | ||||
| 758 | - | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | 780 | + | func peelLeadingYes(args []string) (yes bool, rest []string) { |
| 759 | - | if os.Getenv("ZOT_AGENT_CONSENT") == "1" { | 781 | + | rest = args |
| 760 | - | return true, nil | 782 | + | for len(rest) > 0 { |
| 783 | + | switch rest[0] { | |||
| 784 | + | case "-y", "--yes": | |||
| 785 | + | yes = true | |||
| 786 | + | rest = rest[1:] | |||
| 787 | + | default: | |||
| 788 | + | return yes, rest | |||
| 789 | + | } | |||
| 790 | + | } | |||
| 791 | + | return yes, rest | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) { | |||
| 795 | + | if autoYes || os.Getenv("ZOT_AGENT_CONSENT") == "1" { | |||
| 796 | + | return acceptZotfileConsent(zf, perms) | |||
| 761 | } | 797 | } | ||
| 762 | // "ask" deliberately requires approval on every launch. Other consent | 798 | // "ask" deliberately requires approval on every launch. Other consent | ||
| 763 | // is durable only for this exact artifact digest, so any package change | 799 | // is durable only for this exact artifact digest, so any package change | ||
| @@ -776,15 +812,23 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | |||||
| 776 | } | 812 | } | ||
| 777 | fmt.Print(formatZotfileConsent(zf, perms, th, interactive)) | 813 | fmt.Print(formatZotfileConsent(zf, perms, th, interactive)) | ||
| 778 | if !interactive { | 814 | if !interactive { | ||
| 779 | - | return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow") | 815 | + | return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZOT_AGENT_CONSENT=1 to allow") |
| 780 | } | 816 | } | ||
| 781 | allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th) | 817 | allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th) | ||
| 782 | if err != nil || !allowed { | 818 | if err != nil || !allowed { | ||
| 783 | return allowed, err | 819 | return allowed, err | ||
| 784 | } | 820 | } | ||
| 821 | + | return acceptZotfileConsent(zf, perms) | |||
| 822 | + | } | |||
| 823 | + | ||||
| 824 | + | // acceptZotfileConsent records durable consent when the bash mode is not | |||
| 825 | + | // "ask". Ask mode never caches receipts so every launch re-prompts unless | |||
| 826 | + | // -y / ZOT_AGENT_CONSENT is used for that run. | |||
| 827 | + | func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) { | |||
| 785 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | 828 | if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" { | ||
| 786 | return true, nil | 829 | return true, nil | ||
| 787 | } | 830 | } | ||
| 831 | + | consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json") | |||
| 788 | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | 832 | if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil { | ||
| 789 | return false, fmt.Errorf("save agent consent: %w", err) | 833 | return false, fmt.Errorf("save agent consent: %w", err) | ||
| 790 | } | 834 | } | ||
A packages/agent/zotfile_pre_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||
| 1 | + | package agent | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "runtime" | |
| 6 | + | "strings" | |
| 7 | + | "testing" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestRunZotfileStartupPreShellFailure(t *testing.T) { | |
| 11 | + | pre := "!false" | |
| 12 | + | if runtime.GOOS == "windows" { | |
| 13 | + | pre = "!exit 1" | |
| 14 | + | } | |
| 15 | + | err := runZotfileStartupPre(context.Background(), pre, t.TempDir(), nil, nil, nil, nil) | |
| 16 | + | if err == nil { | |
| 17 | + | t.Fatal("expected entry.pre shell failure") | |
| 18 | + | } | |
| 19 | + | if !strings.Contains(err.Error(), "entry.pre") { | |
| 20 | + | t.Fatalf("error = %v, want entry.pre context", err) | |
| 21 | + | } | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func TestRunZotfileStartupPreEmpty(t *testing.T) { | |
| 25 | + | if err := runZotfileStartupPre(context.Background(), " ", t.TempDir(), nil, nil, nil, nil); err != nil { | |
| 26 | + | t.Fatalf("empty pre: %v", err) | |
| 27 | + | } | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func TestRunZotfileStartupPreShellStreams(t *testing.T) { | |
| 31 | + | cmd := "!printf streamed-pre" | |
| 32 | + | if runtime.GOOS == "windows" { | |
| 33 | + | cmd = "!echo streamed-pre" | |
| 34 | + | } | |
| 35 | + | var out strings.Builder | |
| 36 | + | if err := runZotfileStartupPre(context.Background(), cmd, t.TempDir(), nil, nil, nil, &out); err != nil { | |
| 37 | + | t.Fatal(err) | |
| 38 | + | } | |
| 39 | + | if !strings.Contains(out.String(), "streamed-pre") { | |
| 40 | + | t.Fatalf("shell output not streamed: %q", out.String()) | |
| 41 | + | } | |
| 42 | + | } | |
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package agent | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "runtime" | |||
| 6 | + | "strings" | |||
| 7 | + | "testing" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestRunZotfileStartupPreShellFailure(t *testing.T) { | |||
| 11 | + | pre := "!false" | |||
| 12 | + | if runtime.GOOS == "windows" { | |||
| 13 | + | pre = "!exit 1" | |||
| 14 | + | } | |||
| 15 | + | err := runZotfileStartupPre(context.Background(), pre, t.TempDir(), nil, nil, nil, nil) | |||
| 16 | + | if err == nil { | |||
| 17 | + | t.Fatal("expected entry.pre shell failure") | |||
| 18 | + | } | |||
| 19 | + | if !strings.Contains(err.Error(), "entry.pre") { | |||
| 20 | + | t.Fatalf("error = %v, want entry.pre context", err) | |||
| 21 | + | } | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func TestRunZotfileStartupPreEmpty(t *testing.T) { | |||
| 25 | + | if err := runZotfileStartupPre(context.Background(), " ", t.TempDir(), nil, nil, nil, nil); err != nil { | |||
| 26 | + | t.Fatalf("empty pre: %v", err) | |||
| 27 | + | } | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func TestRunZotfileStartupPreShellStreams(t *testing.T) { | |||
| 31 | + | cmd := "!printf streamed-pre" | |||
| 32 | + | if runtime.GOOS == "windows" { | |||
| 33 | + | cmd = "!echo streamed-pre" | |||
| 34 | + | } | |||
| 35 | + | var out strings.Builder | |||
| 36 | + | if err := runZotfileStartupPre(context.Background(), cmd, t.TempDir(), nil, nil, nil, &out); err != nil { | |||
| 37 | + | t.Fatal(err) | |||
| 38 | + | } | |||
| 39 | + | if !strings.Contains(out.String(), "streamed-pre") { | |||
| 40 | + | t.Fatalf("shell output not streamed: %q", out.String()) | |||
| 41 | + | } | |||
| 42 | + | } | |||
M packages/agent/zotfile_test.go +101 -2
| @@ -93,6 +93,33 @@ func writeTestZotfile(t *testing.T, manifest string) string { | |||
| 93 | 93 | return dir | |
| 94 | 94 | } | |
| 95 | 95 | ||
| 96 | + | func TestLoadZotfileAllowsMissingAgentMD(t *testing.T) { | |
| 97 | + | dir := t.TempDir() | |
| 98 | + | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zotfile":1,"name":"test"}`), 0o600); err != nil { | |
| 99 | + | t.Fatal(err) | |
| 100 | + | } | |
| 101 | + | zf, cleanup, err := loadZotfile(dir) | |
| 102 | + | if cleanup != nil { | |
| 103 | + | defer cleanup() | |
| 104 | + | } | |
| 105 | + | if err != nil { | |
| 106 | + | t.Fatalf("load without AGENT.md: %v", err) | |
| 107 | + | } | |
| 108 | + | if zf.Manifest.Name != "test" { | |
| 109 | + | t.Fatalf("name = %q, want test", zf.Manifest.Name) | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | func TestValidateZotfileDirRejectsAgentMDDirectory(t *testing.T) { | |
| 114 | + | dir := t.TempDir() | |
| 115 | + | if err := os.Mkdir(filepath.Join(dir, "AGENT.md"), 0o700); err != nil { | |
| 116 | + | t.Fatal(err) | |
| 117 | + | } | |
| 118 | + | if err := validateZotfileDir(dir); err == nil || !strings.Contains(err.Error(), "regular file") { | |
| 119 | + | t.Fatalf("unexpected error: %v", err) | |
| 120 | + | } | |
| 121 | + | } | |
| 122 | + | ||
| 96 | 123 | func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | |
| 97 | 124 | for _, field := range []string{ | |
| 98 | 125 | `"net":{"allow":["example.com"]}`, | |
| @@ -105,6 +132,27 @@ func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | |||
| 105 | 132 | } | |
| 106 | 133 | } | |
| 107 | 134 | ||
| 135 | + | func TestLoadZotfileBashModes(t *testing.T) { | |
| 136 | + | for _, mode := range []string{"none", "ask", "allowlist"} { | |
| 137 | + | manifest := `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"` + mode + `"}}}` | |
| 138 | + | if mode == "allowlist" { | |
| 139 | + | manifest = `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"allowlist","allow":["git"]}}}` | |
| 140 | + | } | |
| 141 | + | dir := writeTestZotfile(t, manifest) | |
| 142 | + | if _, _, err := loadZotfile(dir); err != nil { | |
| 143 | + | t.Fatalf("mode %q rejected: %v", mode, err) | |
| 144 | + | } | |
| 145 | + | } | |
| 146 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"all"}}}`) | |
| 147 | + | if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") { | |
| 148 | + | t.Fatalf("unexpected error for all mode: %v", err) | |
| 149 | + | } | |
| 150 | + | dir = writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"everything"}}}`) | |
| 151 | + | if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") { | |
| 152 | + | t.Fatalf("unexpected error for unknown mode: %v", err) | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 108 | 156 | func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) { | |
| 109 | 157 | for _, name := range []string{"...", "Name", "two words", "a/b"} { | |
| 110 | 158 | dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`) | |
| @@ -132,8 +180,9 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) { | |||
| 132 | 180 | {"packed-agent", "packed-agent.zot"}, | |
| 133 | 181 | {"remote-agent", officialZotfileCollection + "/remote-agent"}, | |
| 134 | 182 | {"missing.zot", "missing.zot"}, | |
| 135 | - | {"agents/remote-agent", "https://github.com/patriceckhart/agents/remote-agent"}, | |
| 136 | - | {`agents\remote-agent`, "https://github.com/patriceckhart/agents/remote-agent"}, | |
| 183 | + | {"agents/remote-agent", "https://github.com/agents/remote-agent"}, | |
| 184 | + | {`agents\remote-agent`, "https://github.com/agents/remote-agent"}, | |
| 185 | + | {"frkr/zot-archify", "https://github.com/frkr/zot-archify"}, | |
| 137 | 186 | {"./missing-agent", "./missing-agent"}, | |
| 138 | 187 | {"Remote-Agent", "Remote-Agent"}, | |
| 139 | 188 | {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"}, | |
| @@ -307,3 +356,53 @@ func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | |||
| 307 | 356 | t.Fatalf("summary did not show denied scopes:\n%s", got) | |
| 308 | 357 | } | |
| 309 | 358 | } | |
| 359 | + | ||
| 360 | + | func TestPeelLeadingYes(t *testing.T) { | |
| 361 | + | yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"}) | |
| 362 | + | if !yes { | |
| 363 | + | t.Fatal("expected yes") | |
| 364 | + | } | |
| 365 | + | if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" { | |
| 366 | + | t.Fatalf("rest = %v", rest) | |
| 367 | + | } | |
| 368 | + | yes, rest = peelLeadingYes([]string{"agent", "-y"}) | |
| 369 | + | if yes || len(rest) != 2 { | |
| 370 | + | t.Fatalf("yes=%v rest=%v", yes, rest) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestConsentZotfileAutoYesWritesReceipt(t *testing.T) { | |
| 375 | + | home := t.TempDir() | |
| 376 | + | t.Setenv("ZOT_HOME", home) | |
| 377 | + | zf := zotfileLoaded{Digest: "abc123", Manifest: ZotfileManifest{Name: "demo", Version: "1.0.0"}} | |
| 378 | + | var perms tools.PermissionSet | |
| 379 | + | perms.Bash.Mode = "none" | |
| 380 | + | allowed, err := consentZotfile(zf, perms, true) | |
| 381 | + | if err != nil || !allowed { | |
| 382 | + | t.Fatalf("auto yes: allowed=%v err=%v", allowed, err) | |
| 383 | + | } | |
| 384 | + | path := filepath.Join(home, "agents", "demo", "consents", "abc123.json") | |
| 385 | + | if _, err := os.Stat(path); err != nil { | |
| 386 | + | t.Fatalf("consent receipt missing: %v", err) | |
| 387 | + | } | |
| 388 | + | // Second call without -y should use the receipt. | |
| 389 | + | allowed, err = consentZotfile(zf, perms, false) | |
| 390 | + | if err != nil || !allowed { | |
| 391 | + | t.Fatalf("cached consent: allowed=%v err=%v", allowed, err) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestConsentZotfileAutoYesAskDoesNotCache(t *testing.T) { | |
| 396 | + | home := t.TempDir() | |
| 397 | + | t.Setenv("ZOT_HOME", home) | |
| 398 | + | zf := zotfileLoaded{Digest: "askdig", Manifest: ZotfileManifest{Name: "ask-agent", Version: "1.0.0"}} | |
| 399 | + | var perms tools.PermissionSet | |
| 400 | + | perms.Bash.Mode = "ask" | |
| 401 | + | if _, err := consentZotfile(zf, perms, true); err != nil { | |
| 402 | + | t.Fatal(err) | |
| 403 | + | } | |
| 404 | + | path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json") | |
| 405 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |
| 406 | + | t.Fatalf("ask mode should not cache consent, err=%v", err) | |
| 407 | + | } | |
| 408 | + | } | |
| @@ -93,6 +93,33 @@ func writeTestZotfile(t *testing.T, manifest string) string { | |||||
| 93 | return dir | 93 | return dir | ||
| 94 | } | 94 | } | ||
| 95 | 95 | ||||
| 96 | + | func TestLoadZotfileAllowsMissingAgentMD(t *testing.T) { | |||
| 97 | + | dir := t.TempDir() | |||
| 98 | + | if err := os.WriteFile(filepath.Join(dir, "manifest.json"), []byte(`{"zotfile":1,"name":"test"}`), 0o600); err != nil { | |||
| 99 | + | t.Fatal(err) | |||
| 100 | + | } | |||
| 101 | + | zf, cleanup, err := loadZotfile(dir) | |||
| 102 | + | if cleanup != nil { | |||
| 103 | + | defer cleanup() | |||
| 104 | + | } | |||
| 105 | + | if err != nil { | |||
| 106 | + | t.Fatalf("load without AGENT.md: %v", err) | |||
| 107 | + | } | |||
| 108 | + | if zf.Manifest.Name != "test" { | |||
| 109 | + | t.Fatalf("name = %q, want test", zf.Manifest.Name) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | func TestValidateZotfileDirRejectsAgentMDDirectory(t *testing.T) { | |||
| 114 | + | dir := t.TempDir() | |||
| 115 | + | if err := os.Mkdir(filepath.Join(dir, "AGENT.md"), 0o700); err != nil { | |||
| 116 | + | t.Fatal(err) | |||
| 117 | + | } | |||
| 118 | + | if err := validateZotfileDir(dir); err == nil || !strings.Contains(err.Error(), "regular file") { | |||
| 119 | + | t.Fatalf("unexpected error: %v", err) | |||
| 120 | + | } | |||
| 121 | + | } | |||
| 122 | + | ||||
| 96 | func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | 123 | func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | ||
| 97 | for _, field := range []string{ | 124 | for _, field := range []string{ | ||
| 98 | `"net":{"allow":["example.com"]}`, | 125 | `"net":{"allow":["example.com"]}`, | ||
| @@ -105,6 +132,27 @@ func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) { | |||||
| 105 | } | 132 | } | ||
| 106 | } | 133 | } | ||
| 107 | 134 | ||||
| 135 | + | func TestLoadZotfileBashModes(t *testing.T) { | |||
| 136 | + | for _, mode := range []string{"none", "ask", "allowlist"} { | |||
| 137 | + | manifest := `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"` + mode + `"}}}` | |||
| 138 | + | if mode == "allowlist" { | |||
| 139 | + | manifest = `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"allowlist","allow":["git"]}}}` | |||
| 140 | + | } | |||
| 141 | + | dir := writeTestZotfile(t, manifest) | |||
| 142 | + | if _, _, err := loadZotfile(dir); err != nil { | |||
| 143 | + | t.Fatalf("mode %q rejected: %v", mode, err) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | dir := writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"all"}}}`) | |||
| 147 | + | if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") { | |||
| 148 | + | t.Fatalf("unexpected error for all mode: %v", err) | |||
| 149 | + | } | |||
| 150 | + | dir = writeTestZotfile(t, `{"zotfile":1,"name":"test","permissions":{"bash":{"mode":"everything"}}}`) | |||
| 151 | + | if _, _, err := loadZotfile(dir); err == nil || !strings.Contains(err.Error(), "unsupported bash permission mode") { | |||
| 152 | + | t.Fatalf("unexpected error for unknown mode: %v", err) | |||
| 153 | + | } | |||
| 154 | + | } | |||
| 155 | + | ||||
| 108 | func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) { | 156 | func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) { | ||
| 109 | for _, name := range []string{"...", "Name", "two words", "a/b"} { | 157 | for _, name := range []string{"...", "Name", "two words", "a/b"} { | ||
| 110 | dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`) | 158 | dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`) | ||
| @@ -132,8 +180,9 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) { | |||||
| 132 | {"packed-agent", "packed-agent.zot"}, | 180 | {"packed-agent", "packed-agent.zot"}, | ||
| 133 | {"remote-agent", officialZotfileCollection + "/remote-agent"}, | 181 | {"remote-agent", officialZotfileCollection + "/remote-agent"}, | ||
| 134 | {"missing.zot", "missing.zot"}, | 182 | {"missing.zot", "missing.zot"}, | ||
| 135 | - | {"agents/remote-agent", "https://github.com/patriceckhart/agents/remote-agent"}, | 183 | + | {"agents/remote-agent", "https://github.com/agents/remote-agent"}, |
| 136 | - | {`agents\remote-agent`, "https://github.com/patriceckhart/agents/remote-agent"}, | 184 | + | {`agents\remote-agent`, "https://github.com/agents/remote-agent"}, |
| 185 | + | {"frkr/zot-archify", "https://github.com/frkr/zot-archify"}, | |||
| 137 | {"./missing-agent", "./missing-agent"}, | 186 | {"./missing-agent", "./missing-agent"}, | ||
| 138 | {"Remote-Agent", "Remote-Agent"}, | 187 | {"Remote-Agent", "Remote-Agent"}, | ||
| 139 | {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"}, | 188 | {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"}, | ||
| @@ -307,3 +356,53 @@ func TestPermissionSummaryShowsDeniedScopes(t *testing.T) { | |||||
| 307 | t.Fatalf("summary did not show denied scopes:\n%s", got) | 356 | t.Fatalf("summary did not show denied scopes:\n%s", got) | ||
| 308 | } | 357 | } | ||
| 309 | } | 358 | } | ||
| 359 | + | ||||
| 360 | + | func TestPeelLeadingYes(t *testing.T) { | |||
| 361 | + | yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"}) | |||
| 362 | + | if !yes { | |||
| 363 | + | t.Fatal("expected yes") | |||
| 364 | + | } | |||
| 365 | + | if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" { | |||
| 366 | + | t.Fatalf("rest = %v", rest) | |||
| 367 | + | } | |||
| 368 | + | yes, rest = peelLeadingYes([]string{"agent", "-y"}) | |||
| 369 | + | if yes || len(rest) != 2 { | |||
| 370 | + | t.Fatalf("yes=%v rest=%v", yes, rest) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func TestConsentZotfileAutoYesWritesReceipt(t *testing.T) { | |||
| 375 | + | home := t.TempDir() | |||
| 376 | + | t.Setenv("ZOT_HOME", home) | |||
| 377 | + | zf := zotfileLoaded{Digest: "abc123", Manifest: ZotfileManifest{Name: "demo", Version: "1.0.0"}} | |||
| 378 | + | var perms tools.PermissionSet | |||
| 379 | + | perms.Bash.Mode = "none" | |||
| 380 | + | allowed, err := consentZotfile(zf, perms, true) | |||
| 381 | + | if err != nil || !allowed { | |||
| 382 | + | t.Fatalf("auto yes: allowed=%v err=%v", allowed, err) | |||
| 383 | + | } | |||
| 384 | + | path := filepath.Join(home, "agents", "demo", "consents", "abc123.json") | |||
| 385 | + | if _, err := os.Stat(path); err != nil { | |||
| 386 | + | t.Fatalf("consent receipt missing: %v", err) | |||
| 387 | + | } | |||
| 388 | + | // Second call without -y should use the receipt. | |||
| 389 | + | allowed, err = consentZotfile(zf, perms, false) | |||
| 390 | + | if err != nil || !allowed { | |||
| 391 | + | t.Fatalf("cached consent: allowed=%v err=%v", allowed, err) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestConsentZotfileAutoYesAskDoesNotCache(t *testing.T) { | |||
| 396 | + | home := t.TempDir() | |||
| 397 | + | t.Setenv("ZOT_HOME", home) | |||
| 398 | + | zf := zotfileLoaded{Digest: "askdig", Manifest: ZotfileManifest{Name: "ask-agent", Version: "1.0.0"}} | |||
| 399 | + | var perms tools.PermissionSet | |||
| 400 | + | perms.Bash.Mode = "ask" | |||
| 401 | + | if _, err := consentZotfile(zf, perms, true); err != nil { | |||
| 402 | + | t.Fatal(err) | |||
| 403 | + | } | |||
| 404 | + | path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json") | |||
| 405 | + | if _, err := os.Stat(path); !os.IsNotExist(err) { | |||
| 406 | + | t.Fatalf("ask mode should not cache consent, err=%v", err) | |||
| 407 | + | } | |||
| 408 | + | } | |||