detached 2501d48

Merge pull request #124 from frkr/feat/thirdzot

Zot files
commit
2501d484832ff530902fb804923c6423194508e8
author
Patric Eckhart · · 2026-08-02 15:17 UTC
committer
GitHub · · 2026-08-02 15:17 UTC
merge of
f3d8eb6 + 73513b2 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.3.30
13 files · +1039 -87
M README.md +1 -0
@@ -234,6 +234,7 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se
234234
235235 - **Interactive** (default): chat TUI with streaming output, spinner, cost meter, slash commands.
236236 - **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.
237238 - **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).
238239 - **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.
239240
@@ -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 +36 -10
@@ -88,14 +88,14 @@ The first run displays the declared permissions and asks for consent before the
8888 ```text
8989 my-agent/
9090 ├── manifest.json # identity, requirements, and permissions, required
91-├── AGENT.md # persona and standing instructions, required
91+├── AGENT.md # persona and standing instructions
9292 ├── skills/ # SKILL.md directories, optional
9393 │ └── my-skill/SKILL.md
9494 ├── assets/ # static files, optional
9595 └── README.md # human-facing documentation, optional
9696 ```
9797
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.
9999
100100 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.
101101
@@ -103,7 +103,7 @@ Symlinks are not supported and cause `zot pack` to fail.
103103
104104 ## `AGENT.md`
105105
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.
107107
108108 Keep capability and security declarations out of this file. Permissions come only from `manifest.json` and are enforced independently of agent-authored prose.
109109
@@ -275,6 +275,16 @@ Zot checks these requirements before requesting consent. These zotfiles do not h
275275
276276 ### Entry fields
277277
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+
278288 `entry.default_prompt` is used when the user runs the agent without a prompt. It may be a string or `null`:
279289
280290 ```json
@@ -285,7 +295,9 @@ Zot checks these requirements before requesting consent. These zotfiles do not h
285295 }
286296 ```
287297
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).
289301
290302 `entry.greeting` is part of the manifest, but the current local runtime does not render it yet.
291303
@@ -447,7 +459,11 @@ $ZOT_HOME/agents/<name>/consents/<digest>.json
447459
448460 Any change to the packaged artifact produces a different digest and requires consent again. `bash: ask` always requires fresh launch consent.
449461
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+```
451467
452468 ```bash
453469 ZOT_AGENT_CONSENT=1 zot run ./code-reviewer.zot --print "Review this repository"
@@ -481,7 +497,9 @@ Run an unpackaged directory during development:
481497
482498 ```bash
483499 zot run ./my-agent
500+zot run -y ./my-agent
484501 zot run ./my-agent "Do the task"
502+zot run ./my-agent -y --print "Do the task"
485503 ```
486504
487505 Run a packed artifact:
@@ -489,6 +507,7 @@ Run a packed artifact:
489507 ```bash
490508 zot run ./my-agent.zot
491509 zot run ./my-agent.zot --print "Do the task"
510+zot run ./my-agent.zot --stream "Do the task"
492511 zot run ./my-agent.zot --json "Do the task"
493512 ```
494513
@@ -502,14 +521,20 @@ zot run zot-maintenance
502521
503522 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.
504523
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:
525+
526+```bash
527+zot run frkr/zot-archify
528+```
529+
530+The legacy `agents/<name>` form remains reserved for selecting an agent directory from the official `patriceckhart/agents` collection:
506531
507532 ```bash
508533 zot run agents/zot-maintenance
509534 zot run agents\zot-maintenance # also accepted on Windows
510535 ```
511536
512-The first part is the repository and the second part is the agent directory. Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.
537+Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.
513538
514539 For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:
515540
@@ -517,15 +542,15 @@ For GitHub, zot downloads the repository archive into a temporary directory, sel
517542 zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
518543 ```
519544
520-Both the short subdirectory form above and a standard GitHub tree URL are supported:
545+Both the legacy official-collection form above and a standard GitHub tree URL are supported:
521546
522547 ```bash
523548 zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
524549 ```
525550
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`.
551+Short forms read the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and branch or tag names containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
527552
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.
553+Short-name and `owner/repo` fallbacks are direct GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
529554
530555 ### `zot pack`
531556
@@ -596,6 +621,7 @@ Implemented now:
596621
597622 - local directories and `.zot` archives
598623 - local-first short-name resolution to the official GitHub agent collection
624+- local-first `owner/repo` resolution to `https://github.com/owner/repo`
599625 - temporary execution of agent directories from public GitHub repositories
600626 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`
601627 - canonical tar creation with zstd compression
@@ -88,14 +88,14 @@ The first run displays the declared permissions and asks for consent before the
88 ```text88 ```text
89 my-agent/89 my-agent/
90 ├── manifest.json # identity, requirements, and permissions, required90 ├── manifest.json # identity, requirements, and permissions, required
91-├── AGENT.md # persona and standing instructions, required91+├── AGENT.md # persona and standing instructions
92 ├── skills/ # SKILL.md directories, optional92 ├── skills/ # SKILL.md directories, optional
93 │ └── my-skill/SKILL.md93 │ └── my-skill/SKILL.md
94 ├── assets/ # static files, optional94 ├── assets/ # static files, optional
95 └── README.md # human-facing documentation, optional95 └── 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 fields276 ### 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 ```json290 ```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 ```bash468 ```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 ```bash498 ```bash
483 zot run ./my-agent499 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 ```bash507 ```bash
490 zot run ./my-agent.zot508 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,14 +521,20 @@ 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:
525+
526+```bash
527+zot run frkr/zot-archify
528+```
529+
530+The legacy `agents/<name>` form remains reserved for selecting an agent directory from the official `patriceckhart/agents` collection:
506 531
507 ```bash532 ```bash
508 zot run agents/zot-maintenance533 zot run agents/zot-maintenance
509 zot run agents\zot-maintenance # also accepted on Windows534 zot run agents\zot-maintenance # also accepted on Windows
510 ```535 ```
511 536
512-The first part is the repository and the second part is the agent directory. Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.537+Both parts must use lowercase letters, digits, dots, hyphens, or underscores. Prefix a reference with `./`, use an absolute path, retain the `.zot` suffix, or provide a complete GitHub URL when remote fallback is not wanted.
513 538
514 For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:539 For GitHub, zot downloads the repository archive into a temporary directory, selects the requested agent subdirectory, validates it, runs it, and removes the downloaded files when the command exits:
515 540
@@ -517,15 +542,15 @@ For GitHub, zot downloads the repository archive into a temporary directory, sel
517 zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot542 zot run https://github.com/patriceckhart/agents/zot-maintenance --cwd /path/to/zot
518 ```543 ```
519 544
520-Both the short subdirectory form above and a standard GitHub tree URL are supported:545+Both the legacy official-collection form above and a standard GitHub tree URL are supported:
521 546
522 ```bash547 ```bash
523 zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance548 zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
524 ```549 ```
525 550
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`.551+Short forms read the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and branch or tag names containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
527 552
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.553+Short-name and `owner/repo` fallbacks are direct GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
529 554
530 ### `zot pack`555 ### `zot pack`
531 556
@@ -596,6 +621,7 @@ Implemented now:
596 621
597 - local directories and `.zot` archives622 - local directories and `.zot` archives
598 - local-first short-name resolution to the official GitHub agent collection623 - local-first short-name resolution to the official GitHub agent collection
624+- local-first `owner/repo` resolution to `https://github.com/owner/repo`
599 - temporary execution of agent directories from public GitHub repositories625 - temporary execution of agent directories from public GitHub repositories
600 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`626 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`
601 - canonical tar creation with zstd compression627 - canonical tar creation with zstd compression
M packages/agent/args.go +16 -0
@@ -17,6 +17,7 @@ type Mode string
1717 const (
1818 ModeInteractive Mode = "interactive"
1919 ModePrint Mode = "print"
20+ ModeStream Mode = "stream"
2021 ModeJSON Mode = "json"
2122 ModeRPC Mode = "rpc"
2223 // ModeSwarmAgent is the long-lived, headless daemon mode used by
@@ -97,12 +98,21 @@ type Args struct {
9798 // freely so automated workflows keep working.
9899 NoYolo bool
99100
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+
100106 ListModels bool
101107 Help bool
102108 Version bool
103109
104110 Prompt string // concatenated positional args
105111
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+
106116 // SwarmAgent is the inbox-socket path when this process is a
107117 // swarm-spawned agent. Empty in every other mode. Set by
108118 // --swarm-agent <path>; presence flips Mode to ModeSwarmAgent.
@@ -138,6 +148,8 @@ func ParseArgs(in []string) (Args, error) {
138148 a.Version = true
139149 case "-p", "--print":
140150 a.Mode = ModePrint
151+ case "--stream":
152+ a.Mode = ModeStream
141153 case "--json":
142154 a.Mode = ModeJSON
143155 case "--rpc":
@@ -211,6 +223,8 @@ func ParseArgs(in []string) (Args, error) {
211223 a.InsecureTLS = true
212224 case "--no-yolo":
213225 a.NoYolo = true
226+ case "-y", "--yes":
227+ a.Yes = true
214228 case "--reasoning":
215229 v, err := want(&i, arg)
216230 if err != nil {
@@ -364,6 +378,7 @@ func PrintHelp(version string) {
364378 row{"zot", "interactive tui"},
365379 row{"zot \"prompt\"", "interactive, pre-filled prompt"},
366380 row{"zot -p \"prompt\"", "print final text, exit"},
381+ row{"zot --stream \"prompt\"", "stream assistant text live, exit"},
367382 row{"zot --json \"prompt\"", "newline-delimited json events, exit"},
368383 row{"zot rpc", "json-rpc loop on stdin/stdout (see docs/rpc.md)"},
369384 )
@@ -409,6 +424,7 @@ func PrintHelp(version string) {
409424 row{"--no-tools", "disable all tools"},
410425 row{"--tools csv", "only enable the listed tools"},
411426 row{"--no-yolo", "ask before running every tool call"},
427+ row{"-y, --yes", "accept zot run consent without prompting"},
412428 row{"--no-ext", "skip extension discovery for this run"},
413429 row{"--no-skill", "skip all skill discovery for this run"},
414430 )
@@ -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 by23 // 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 bool99 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 bool106 ListModels bool
101 Help bool107 Help bool
102 Version bool108 Version bool
103 109
104 Prompt string // concatenated positional args110 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 a116 // SwarmAgent is the inbox-socket path when this process is a
107 // swarm-spawned agent. Empty in every other mode. Set by117 // 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 = true148 a.Version = true
139 case "-p", "--print":149 case "-p", "--print":
140 a.Mode = ModePrint150 a.Mode = ModePrint
151+ case "--stream":
152+ a.Mode = ModeStream
141 case "--json":153 case "--json":
142 a.Mode = ModeJSON154 a.Mode = ModeJSON
143 case "--rpc":155 case "--rpc":
@@ -211,6 +223,8 @@ func ParseArgs(in []string) (Args, error) {
211 a.InsecureTLS = true223 a.InsecureTLS = true
212 case "--no-yolo":224 case "--no-yolo":
213 a.NoYolo = true225 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) {
1717 t.Fatal("ParseArgs accepted out-of-range temperature")
1818 }
1919 }
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 +251 -50
@@ -248,6 +248,8 @@ func runWithArgs(args Args, version string) error {
248248 switch args.Mode {
249249 case ModePrint:
250250 return runPrintMode(ctx, args, version)
251+ case ModeStream:
252+ return runStreamMode(ctx, args, version)
251253 case ModeJSON:
252254 return runJSONMode(ctx, args, version)
253255 case ModeRPC:
@@ -354,11 +356,97 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
354356 }
355357
356358 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)
357363 err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
358364 WriteNewTranscript(ag, sess, start)
359365 return err
360366 }
361367
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, finishPre := newStreamTextSink(os.Stdout)
395+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
396+ finishPre()
397+ return err
398+ }
399+ finishPre()
400+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
401+ err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
402+ WriteNewTranscript(ag, sess, start)
403+ return err
404+}
405+
406+func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
407+ var streamed, wroteText, lastWasNL bool
408+ writeText := func(text string) {
409+ if text == "" {
410+ return
411+ }
412+ _, _ = fmt.Fprint(out, text)
413+ if syncer, ok := out.(interface{ Sync() error }); ok {
414+ _ = syncer.Sync()
415+ }
416+ wroteText = true
417+ lastWasNL = strings.HasSuffix(text, "\n")
418+ }
419+ sink := func(ev core.AgentEvent) {
420+ switch e := ev.(type) {
421+ case core.EvAssistantStart:
422+ streamed = false
423+ case core.EvTextDelta:
424+ streamed = true
425+ writeText(e.Delta)
426+ case core.EvAssistantMessage:
427+ if streamed {
428+ return
429+ }
430+ var text strings.Builder
431+ for _, content := range e.Message.Content {
432+ if block, ok := content.(provider.TextBlock); ok {
433+ if text.Len() > 0 {
434+ text.WriteString("\n")
435+ }
436+ text.WriteString(block.Text)
437+ }
438+ }
439+ writeText(text.String())
440+ }
441+ }
442+ finish := func() {
443+ if wroteText && !lastWasNL {
444+ writeText("\n")
445+ }
446+ }
447+ return sink, finish
448+}
449+
362450 func runJSONMode(ctx context.Context, args Args, version string) error {
363451 if args.NoYolo {
364452 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
@@ -385,11 +473,119 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
385473 }
386474
387475 start := len(ag.Messages())
476+ enc := json.NewEncoder(os.Stdout)
477+ preSink := func(ev core.AgentEvent) {
478+ _ = enc.Encode(modes.EventToJSON(ev))
479+ }
480+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
481+ _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
482+ return err
483+ }
484+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
388485 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
389486 WriteNewTranscript(ag, sess, start)
390487 return err
391488 }
392489
490+// runZotfileStartupPre runs entry.pre before the main non-interactive prompt.
491+// "!command" values execute via BashTool; other values are sent as an agent turn.
492+// shellOut receives live shell chunks (typically os.Stderr); sink receives
493+// agent events for plain-text pre (stream mode wires a live text sink).
494+func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
495+ pre = strings.TrimSpace(pre)
496+ if pre == "" {
497+ return nil
498+ }
499+ if cmd, ok := modes.ShellEscapeCommand(pre); ok {
500+ raw, err := json.Marshal(map[string]any{"command": cmd})
501+ if err != nil {
502+ return err
503+ }
504+ if shellOut != nil {
505+ fmt.Fprintf(shellOut, "$ %s\n", cmd)
506+ if f, ok := shellOut.(interface{ Sync() error }); ok {
507+ _ = f.Sync()
508+ }
509+ }
510+ progress := func(chunk string) {
511+ if shellOut == nil || chunk == "" {
512+ return
513+ }
514+ fmt.Fprint(shellOut, chunk)
515+ if f, ok := shellOut.(interface{ Sync() error }); ok {
516+ _ = f.Sync()
517+ }
518+ }
519+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
520+ res, err := bash.Execute(ctx, raw, progress)
521+ if err != nil {
522+ return fmt.Errorf("zotfile entry.pre: %w", err)
523+ }
524+ if res.IsError {
525+ var sb strings.Builder
526+ for _, c := range res.Content {
527+ if tb, ok := c.(provider.TextBlock); ok {
528+ sb.WriteString(tb.Text)
529+ }
530+ }
531+ msg := strings.TrimSpace(sb.String())
532+ if msg == "" {
533+ msg = "command failed"
534+ }
535+ return fmt.Errorf("zotfile entry.pre: %s", msg)
536+ }
537+ return nil
538+ }
539+ if ag == nil {
540+ return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts")
541+ }
542+ if sink == nil {
543+ sink = func(core.AgentEvent) {}
544+ }
545+ return ag.Prompt(ctx, pre, nil, sink)
546+}
547+
548+// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
549+// skills and currently loaded extension tools) and updates the live
550+// agent's registry and system prompt. Used after /reload-ext and after
551+// zotfile entry.pre installs new skills or extensions.
552+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn).
553+func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
554+ if ag == nil {
555+ return
556+ }
557+ resolved, err := Resolve(args, true)
558+ if err != nil {
559+ return
560+ }
561+ if sharedSandbox != nil {
562+ resolved.UseSandbox(sharedSandbox)
563+ }
564+ if extToolAdapter != nil {
565+ resolved.MergeExtensionTools(extToolAdapter)
566+ }
567+ reg := resolved.ToolRegistry
568+ if mutateRegistry != nil {
569+ reg = mutateRegistry(reg)
570+ }
571+ ag.SetTools(reg)
572+ ag.System = resolved.SystemPrompt
573+}
574+
575+// reloadResourcesAfterStartupPre reloads extensions (if any) and
576+// refreshes the agent tool registry + system prompt so skills/extensions
577+// installed by entry.pre are visible to the following turn.
578+func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {
579+ if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
580+ return
581+ }
582+ adapter := &extToolAdapter{mgr: extMgr}
583+ if extMgr != nil {
584+ _ = extMgr.Reload(ctx, 2*time.Second)
585+ }
586+ refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)
587+}
588+
393589 // ---- interactive mode: opens the TUI even without credentials ----
394590
395591 func runInteractive(ctx context.Context, args Args, version string) error {
@@ -653,18 +849,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
653849 // /model swap since spawn, so re-read the live `ag` on each
654850 // invocation.
655851 extMgr.SetOnReload(func() {
656- current := ag
852+ current := liveInteractiveAgent(iv, ag)
657853 if current == nil {
658854 return
659855 }
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)
856+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
668857 })
669858
670859 // Fire session_start once we know the manager's running.
@@ -1013,47 +1202,59 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10131202 }
10141203
10151204 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,
1205+ Terminal: term,
1206+ Theme: theme,
1207+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1208+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1209+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1210+ JailByDefault: initialCfg.JailByDefault,
1211+ QuickModelShortcuts: quickModelShortcuts,
1212+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1213+ RespectGitignore: initialCfg.RespectGitignore,
1214+ CompactMode: initialCfg.CompactMode,
1215+ TUIInputStyle: initialCfg.TUIInputStyle,
1216+ TUIStatusPosition: initialCfg.TUIStatusPosition,
1217+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1218+ ThemeName: initialCfg.Theme,
1219+ FlatTools: initialCfg.FlatToolRender(),
1220+ CompactUser: initialCfg.CompactUserInput(),
1221+ ExtensionThemes: extMgr.ThemeOptions,
1222+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1223+ SettingsStore: configSettingsStore{},
1224+ Model: r.Model,
1225+ Provider: r.Provider,
1226+ AuthMethod: r.AuthMethod,
1227+ BaseURL: r.BaseURL,
1228+ Reasoning: r.Reasoning,
1229+ SystemPrompt: r.SystemPrompt,
1230+ Tools: r.ToolRegistry,
1231+ MaxSteps: r.MaxSteps,
1232+ CWD: r.CWD,
1233+ StartupAgentName: args.AgentName,
1234+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
1235+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
1236+ StartupExtensionErrors: startupExtensionErrors,
1237+ StartupSkillNames: startupSkillNames(startupSkills),
1238+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1239+ ZotHome: ZotHome(),
1240+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1241+ Version: version,
1242+ UpdateInfoChan: updateCh,
1243+ Sandbox: sharedSandbox,
1244+ Agent: ag,
1245+ InitialInput: args.Prompt,
1246+ StartupPre: args.StartupPre,
1247+ OnStartupPreDone: func() {
1248+ // entry.pre often installs skills/extensions; rediscover them
1249+ // before the user starts a model turn.
1250+ current := liveInteractiveAgent(iv, ag)
1251+ if extMgr != nil {
1252+ _ = extMgr.Reload(context.Background(), 2*time.Second)
1253+ }
1254+ if current != nil {
1255+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1256+ }
1257+ },
10571258 AuthManager: mgr,
10581259 LlamaCPPConfig: ResolveLlamaCPPConfig,
10591260 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,97 @@ 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 err365 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, finishPre := newStreamTextSink(os.Stdout)
395+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
396+ finishPre()
397+ return err
398+ }
399+ finishPre()
400+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
401+ err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
402+ WriteNewTranscript(ag, sess, start)
403+ return err
404+}
405+
406+func newStreamTextSink(out io.Writer) (func(core.AgentEvent), func()) {
407+ var streamed, wroteText, lastWasNL bool
408+ writeText := func(text string) {
409+ if text == "" {
410+ return
411+ }
412+ _, _ = fmt.Fprint(out, text)
413+ if syncer, ok := out.(interface{ Sync() error }); ok {
414+ _ = syncer.Sync()
415+ }
416+ wroteText = true
417+ lastWasNL = strings.HasSuffix(text, "\n")
418+ }
419+ sink := func(ev core.AgentEvent) {
420+ switch e := ev.(type) {
421+ case core.EvAssistantStart:
422+ streamed = false
423+ case core.EvTextDelta:
424+ streamed = true
425+ writeText(e.Delta)
426+ case core.EvAssistantMessage:
427+ if streamed {
428+ return
429+ }
430+ var text strings.Builder
431+ for _, content := range e.Message.Content {
432+ if block, ok := content.(provider.TextBlock); ok {
433+ if text.Len() > 0 {
434+ text.WriteString("\n")
435+ }
436+ text.WriteString(block.Text)
437+ }
438+ }
439+ writeText(text.String())
440+ }
441+ }
442+ finish := func() {
443+ if wroteText && !lastWasNL {
444+ writeText("\n")
445+ }
446+ }
447+ return sink, finish
448+}
449+
362 func runJSONMode(ctx context.Context, args Args, version string) error {450 func runJSONMode(ctx context.Context, args Args, version string) error {
363 if args.NoYolo {451 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")452 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
@@ -385,11 +473,119 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
385 }473 }
386 474
387 start := len(ag.Messages())475 start := len(ag.Messages())
476+ enc := json.NewEncoder(os.Stdout)
477+ preSink := func(ev core.AgentEvent) {
478+ _ = enc.Encode(modes.EventToJSON(ev))
479+ }
480+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
481+ _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
482+ return err
483+ }
484+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
388 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)485 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
389 WriteNewTranscript(ag, sess, start)486 WriteNewTranscript(ag, sess, start)
390 return err487 return err
391 }488 }
392 489
490+// runZotfileStartupPre runs entry.pre before the main non-interactive prompt.
491+// "!command" values execute via BashTool; other values are sent as an agent turn.
492+// shellOut receives live shell chunks (typically os.Stderr); sink receives
493+// agent events for plain-text pre (stream mode wires a live text sink).
494+func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
495+ pre = strings.TrimSpace(pre)
496+ if pre == "" {
497+ return nil
498+ }
499+ if cmd, ok := modes.ShellEscapeCommand(pre); ok {
500+ raw, err := json.Marshal(map[string]any{"command": cmd})
501+ if err != nil {
502+ return err
503+ }
504+ if shellOut != nil {
505+ fmt.Fprintf(shellOut, "$ %s\n", cmd)
506+ if f, ok := shellOut.(interface{ Sync() error }); ok {
507+ _ = f.Sync()
508+ }
509+ }
510+ progress := func(chunk string) {
511+ if shellOut == nil || chunk == "" {
512+ return
513+ }
514+ fmt.Fprint(shellOut, chunk)
515+ if f, ok := shellOut.(interface{ Sync() error }); ok {
516+ _ = f.Sync()
517+ }
518+ }
519+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
520+ res, err := bash.Execute(ctx, raw, progress)
521+ if err != nil {
522+ return fmt.Errorf("zotfile entry.pre: %w", err)
523+ }
524+ if res.IsError {
525+ var sb strings.Builder
526+ for _, c := range res.Content {
527+ if tb, ok := c.(provider.TextBlock); ok {
528+ sb.WriteString(tb.Text)
529+ }
530+ }
531+ msg := strings.TrimSpace(sb.String())
532+ if msg == "" {
533+ msg = "command failed"
534+ }
535+ return fmt.Errorf("zotfile entry.pre: %s", msg)
536+ }
537+ return nil
538+ }
539+ if ag == nil {
540+ return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts")
541+ }
542+ if sink == nil {
543+ sink = func(core.AgentEvent) {}
544+ }
545+ return ag.Prompt(ctx, pre, nil, sink)
546+}
547+
548+// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
549+// skills and currently loaded extension tools) and updates the live
550+// agent's registry and system prompt. Used after /reload-ext and after
551+// zotfile entry.pre installs new skills or extensions.
552+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn).
553+func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
554+ if ag == nil {
555+ return
556+ }
557+ resolved, err := Resolve(args, true)
558+ if err != nil {
559+ return
560+ }
561+ if sharedSandbox != nil {
562+ resolved.UseSandbox(sharedSandbox)
563+ }
564+ if extToolAdapter != nil {
565+ resolved.MergeExtensionTools(extToolAdapter)
566+ }
567+ reg := resolved.ToolRegistry
568+ if mutateRegistry != nil {
569+ reg = mutateRegistry(reg)
570+ }
571+ ag.SetTools(reg)
572+ ag.System = resolved.SystemPrompt
573+}
574+
575+// reloadResourcesAfterStartupPre reloads extensions (if any) and
576+// refreshes the agent tool registry + system prompt so skills/extensions
577+// installed by entry.pre are visible to the following turn.
578+func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {
579+ if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
580+ return
581+ }
582+ adapter := &extToolAdapter{mgr: extMgr}
583+ if extMgr != nil {
584+ _ = extMgr.Reload(ctx, 2*time.Second)
585+ }
586+ refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)
587+}
588+
393 // ---- interactive mode: opens the TUI even without credentials ----589 // ---- interactive mode: opens the TUI even without credentials ----
394 590
395 func runInteractive(ctx context.Context, args Args, version string) error {591 func runInteractive(ctx context.Context, args Args, version string) error {
@@ -653,18 +849,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
653 // /model swap since spawn, so re-read the live `ag` on each849 // /model swap since spawn, so re-read the live `ag` on each
654 // invocation.850 // invocation.
655 extMgr.SetOnReload(func() {851 extMgr.SetOnReload(func() {
656- current := ag852+ current := liveInteractiveAgent(iv, ag)
657 if current == nil {853 if current == nil {
658 return854 return
659 }855 }
660- resolved, err := Resolve(args, true)856+ 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 })857 })
669 858
670 // Fire session_start once we know the manager's running.859 // Fire session_start once we know the manager's running.
@@ -1013,47 +1202,59 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1013 }1202 }
1014 1203
1015 iv = modes.NewInteractive(modes.InteractiveConfig{1204 iv = modes.NewInteractive(modes.InteractiveConfig{
1016- Terminal: term,1205+ Terminal: term,
1017- Theme: theme,1206+ Theme: theme,
1018- InlineImagesEnabled: initialCfg.InlineImagesEnabled,1207+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1019- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1208+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1020- AutoCompactThreshold: initialCfg.AutoCompactThreshold,1209+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1021- JailByDefault: initialCfg.JailByDefault,1210+ JailByDefault: initialCfg.JailByDefault,
1022- QuickModelShortcuts: quickModelShortcuts,1211+ QuickModelShortcuts: quickModelShortcuts,
1023- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,1212+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1024- RespectGitignore: initialCfg.RespectGitignore,1213+ RespectGitignore: initialCfg.RespectGitignore,
1025- CompactMode: initialCfg.CompactMode,1214+ CompactMode: initialCfg.CompactMode,
1026- TUIInputStyle: initialCfg.TUIInputStyle,1215+ TUIInputStyle: initialCfg.TUIInputStyle,
1027- TUIStatusPosition: initialCfg.TUIStatusPosition,1216+ TUIStatusPosition: initialCfg.TUIStatusPosition,
1028- TUIWorkingPosition: initialCfg.TUIWorkingPosition,1217+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1029- ThemeName: initialCfg.Theme,1218+ ThemeName: initialCfg.Theme,
1030- FlatTools: initialCfg.FlatToolRender(),1219+ FlatTools: initialCfg.FlatToolRender(),
1031- CompactUser: initialCfg.CompactUserInput(),1220+ CompactUser: initialCfg.CompactUserInput(),
1032- ExtensionThemes: extMgr.ThemeOptions,1221+ ExtensionThemes: extMgr.ThemeOptions,
1033- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,1222+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1034- SettingsStore: configSettingsStore{},1223+ SettingsStore: configSettingsStore{},
1035- Model: r.Model,1224+ Model: r.Model,
1036- Provider: r.Provider,1225+ Provider: r.Provider,
1037- AuthMethod: r.AuthMethod,1226+ AuthMethod: r.AuthMethod,
1038- BaseURL: r.BaseURL,1227+ BaseURL: r.BaseURL,
1039- Reasoning: r.Reasoning,1228+ Reasoning: r.Reasoning,
1040- SystemPrompt: r.SystemPrompt,1229+ SystemPrompt: r.SystemPrompt,
1041- Tools: r.ToolRegistry,1230+ Tools: r.ToolRegistry,
1042- MaxSteps: r.MaxSteps,1231+ MaxSteps: r.MaxSteps,
1043- CWD: r.CWD,1232+ CWD: r.CWD,
1044- StartupAgentName: args.AgentName,1233+ StartupAgentName: args.AgentName,
1045- StartupContextPaths: instructionContextPaths(r.ContextFiles),1234+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
1046- StartupExtensionNames: startupExtensionNames(extMgr.All()),1235+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
1047- StartupExtensionErrors: startupExtensionErrors,1236+ StartupExtensionErrors: startupExtensionErrors,
1048- StartupSkillNames: startupSkillNames(startupSkills),1237+ StartupSkillNames: startupSkillNames(startupSkills),
1049- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,1238+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1050- ZotHome: ZotHome(),1239+ ZotHome: ZotHome(),
1051- SessionsRoot: agentSessionsRoot(ZotHome(), args),1240+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1052- Version: version,1241+ Version: version,
1053- UpdateInfoChan: updateCh,1242+ UpdateInfoChan: updateCh,
1054- Sandbox: sharedSandbox,1243+ Sandbox: sharedSandbox,
1055- Agent: ag,1244+ Agent: ag,
1056- InitialInput: args.Prompt,1245+ InitialInput: args.Prompt,
1246+ StartupPre: args.StartupPre,
1247+ OnStartupPreDone: func() {
1248+ // entry.pre often installs skills/extensions; rediscover them
1249+ // before the user starts a model turn.
1250+ current := liveInteractiveAgent(iv, ag)
1251+ if extMgr != nil {
1252+ _ = extMgr.Reload(context.Background(), 2*time.Second)
1253+ }
1254+ if current != nil {
1255+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1256+ }
1257+ },
1057 AuthManager: mgr,1258 AuthManager: mgr,
1058 LlamaCPPConfig: ResolveLlamaCPPConfig,1259 LlamaCPPConfig: ResolveLlamaCPPConfig,
1059 RefreshLlamaCPPModels: RefreshLlamaCPPModels,1260 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
M packages/agent/modes/interactive.go +131 -5
@@ -131,6 +131,21 @@ type InteractiveConfig struct {
131131
132132 InitialInput string
133133
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+
134149 // Auth is required. When the user runs /login, Interactive talks to
135150 // AuthManager to open a browser and wait for the callback.
136151 AuthManager *auth.Manager
@@ -386,6 +401,7 @@ type Interactive struct {
386401 busy bool
387402 dirty chan struct{}
388403 modelRefresh chan modelRefreshResult
404+ startupPreDone chan startupPreResult
389405 cancelTurn context.CancelFunc
390406 scrollOffset int // rows from the bottom; 0 = pinned to latest
391407 prevScrollOffset int // last value redraw snapped against; tracks intent
@@ -522,6 +538,15 @@ type Interactive struct {
522538 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
523539 // escape can start while one is in flight.
524540 shellRunning bool
541+ // shellLive is the accumulating stdout/stderr of the in-flight
542+ // shell escape, updated via BashTool progress for live rendering.
543+ shellLive string
544+
545+ // awaitingStartupPre is true while the zotfile entry.pre auto-submit
546+ // is in flight. When it clears, deferredInitialInput is applied.
547+ awaitingStartupPre bool
548+ deferredInitialInput string
549+ autoSubmitDeferred bool
525550
526551 // sessionLoading is true while a /sessions selection is being read
527552 // on a background goroutine. Keeping this off the input goroutine
@@ -560,6 +585,11 @@ const initialResumeTailLimit = 80
560585 // reveal that would feel jerky.
561586 const resumeTailExpandStep = 80
562587
588+type startupPreResult struct {
589+ deferred string
590+ autoSubmit bool
591+}
592+
563593 // NewInteractive constructs an Interactive from cfg.
564594 func NewInteractive(cfg InteractiveConfig) *Interactive {
565595 renderer := tui.NewRenderer(cfg.Terminal)
@@ -596,6 +626,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
596626 toolGate: map[string]int{},
597627 dirty: make(chan struct{}, 8),
598628 modelRefresh: make(chan modelRefreshResult, 1),
629+ startupPreDone: make(chan startupPreResult, 1),
599630 dialog: newLoginDialog(),
600631 modelDialog: newModelDialog(),
601632 llamaDialog: newLlamaDialog(),
@@ -703,7 +734,14 @@ func (i *Interactive) Run(ctx context.Context) error {
703734 i.redraw()
704735 })
705736
706- if i.cfg.InitialInput != "" {
737+ if i.cfg.StartupPre != "" {
738+ i.deferredInitialInput = i.cfg.InitialInput
739+ i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
740+ i.awaitingStartupPre = true
741+ i.Submit(i.cfg.StartupPre)
742+ } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
743+ i.Submit(i.cfg.InitialInput)
744+ } else if i.cfg.InitialInput != "" {
707745 i.ed.SetValue(i.cfg.InitialInput)
708746 }
709747
@@ -853,6 +891,9 @@ func (i *Interactive) Run(ctx context.Context) error {
853891 case result := <-i.modelRefresh:
854892 i.openModelPickerAfterRefresh(result.err)
855893 i.invalidate()
894+ case result := <-i.startupPreDone:
895+ i.applyStartupPreResult(result)
896+ i.invalidate()
856897 case info, ok := <-updates:
857898 if ok && info.Available {
858899 i.mu.Lock()
@@ -1062,6 +1103,16 @@ func (i *Interactive) buildChatLocked(cols int) []string {
10621103 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
10631104 }
10641105
1106+ // Live shell-escape output (!command / entry.pre) streams into the
1107+ // transcript area while the command runs, then is replaced by the
1108+ // final user-context message when it finishes.
1109+ if i.shellRunning && i.shellLive != "" {
1110+ for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") {
1111+ chat = append(chat, line)
1112+ }
1113+ chat = append(chat, "")
1114+ }
1115+
10651116 // Extension notes (notify / display) live just under the
10661117 // transcript, above the dialog/editor band. Cleared by /clear.
10671118 if len(i.extNotes) > 0 {
@@ -2942,6 +2993,45 @@ func (i *Interactive) Submit(text string) {
29422993 i.startTurn(i.runCtx, text)
29432994 }
29442995
2996+// completeStartupPre applies deferred InitialInput after entry.pre finishes.
2997+// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
2998+// it only pre-fills the editor (CLI-supplied prompts).
2999+func (i *Interactive) completeStartupPre() {
3000+ i.mu.Lock()
3001+ if !i.awaitingStartupPre {
3002+ i.mu.Unlock()
3003+ return
3004+ }
3005+ i.awaitingStartupPre = false
3006+ deferred := i.deferredInitialInput
3007+ auto := i.autoSubmitDeferred
3008+ i.deferredInitialInput = ""
3009+ i.autoSubmitDeferred = false
3010+ onDone := i.cfg.OnStartupPreDone
3011+ i.mu.Unlock()
3012+ if onDone != nil {
3013+ onDone()
3014+ }
3015+ i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3016+ i.invalidate()
3017+}
3018+
3019+// applyStartupPreResult runs on the TUI event loop so the editor remains
3020+// single-threaded. Input entered while resources were reloading wins over the
3021+// deferred prefill rather than being overwritten.
3022+func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3023+ if result.deferred == "" {
3024+ return
3025+ }
3026+ if result.autoSubmit {
3027+ i.Submit(result.deferred)
3028+ return
3029+ }
3030+ if i.ed.IsEmpty() {
3031+ i.ed.SetValue(result.deferred)
3032+ }
3033+}
3034+
29453035 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
29463036 // not provide startup context metadata.
29473037 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -5366,6 +5456,15 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
53665456 // treated as not an escape so it falls through to the normal prompt
53675457 // path rather than running an empty shell.
53685458 func shellEscapeCommand(text string) (string, bool) {
5459+ return ShellEscapeCommand(text)
5460+}
5461+
5462+// ShellEscapeCommand reports whether text is a "!command" shell
5463+// escape and, if so, returns the command with the leading '!' (and
5464+// surrounding whitespace) stripped. A bare "!" with no command is
5465+// treated as not an escape so it falls through to the normal prompt
5466+// path rather than running an empty shell.
5467+func ShellEscapeCommand(text string) (string, bool) {
53695468 trimmed := strings.TrimLeft(text, " \t")
53705469 if !strings.HasPrefix(trimmed, "!") {
53715470 return "", false
@@ -5401,6 +5500,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54015500 ctx, cancel := context.WithCancel(parent)
54025501 i.busy = true
54035502 i.shellRunning = true
5503+ i.shellLive = "$ " + cmd + "\n\n"
54045504 i.cancelTurn = cancel
54055505 i.statusErr = ""
54065506 i.statusOK = ""
@@ -5421,7 +5521,13 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54215521 defer cancel()
54225522 raw, _ := json.Marshal(map[string]any{"command": cmd})
54235523 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5424- res, err := bash.Execute(ctx, raw, nil)
5524+ progress := func(chunk string) {
5525+ i.mu.Lock()
5526+ i.shellLive += chunk
5527+ i.mu.Unlock()
5528+ i.invalidate()
5529+ }
5530+ res, err := bash.Execute(ctx, raw, progress)
54255531
54265532 var out string
54275533 if err != nil {
@@ -5439,12 +5545,16 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54395545 out += "\n\n[cancelled]"
54405546 }
54415547
5442- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5548+ if i.agent != nil {
5549+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5550+ }
54435551
54445552 i.mu.Lock()
54455553 i.shellRunning = false
5554+ i.shellLive = ""
54465555 i.busy = false
54475556 i.cancelTurn = nil
5557+ awaitingPre := i.awaitingStartupPre
54485558 if failed {
54495559 if cancelled {
54505560 i.statusErr = "shell command cancelled"
@@ -5458,6 +5568,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54585568 }
54595569 i.mu.Unlock()
54605570 i.invalidate()
5571+ if awaitingPre {
5572+ i.completeStartupPre()
5573+ }
54615574 }()
54625575 }
54635576
@@ -5471,6 +5584,14 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54715584
54725585 func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
54735586 if i.agent == nil {
5587+ // Text startup pre cannot run without credentials; continue so
5588+ // deferred InitialInput (pre-fill or auto-submit) still applies.
5589+ i.mu.Lock()
5590+ awaitingPre := i.awaitingStartupPre
5591+ i.mu.Unlock()
5592+ if awaitingPre {
5593+ i.completeStartupPre()
5594+ }
54745595 return
54755596 }
54765597 // Pre-turn safety: if the most recent context measurement is
@@ -5612,10 +5733,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
56125733 flush()
56135734 }
56145735 i.mu.Lock()
5736+ awaitingPre := i.awaitingStartupPre
56155737 // Pop the next queued message, if any, and relaunch.
56165738 var next string
56175739 var hasNext bool
5618- if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5740+ if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
56195741 next, i.queued = i.queued[0], i.queued[1:]
56205742 hasNext = true
56215743 }
@@ -5635,13 +5757,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
56355757 if i.agent != nil {
56365758 agentQueued = i.agent.QueuedMessageCount()
56375759 }
5638- shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5760+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
56395761 i.mu.Unlock()
56405762 i.invalidate()
56415763 parent := i.runCtx
56425764 if parent == nil {
56435765 parent = context.Background()
56445766 }
5767+ if awaitingPre {
5768+ i.completeStartupPre()
5769+ return
5770+ }
56455771 switch {
56465772 case hasNext:
56475773 i.startTurn(parent, next)
@@ -131,6 +131,21 @@ type InteractiveConfig struct {
131 131
132 InitialInput string132 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 to149 // 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.Manager151 AuthManager *auth.Manager
@@ -386,6 +401,7 @@ type Interactive struct {
386 busy bool401 busy bool
387 dirty chan struct{}402 dirty chan struct{}
388 modelRefresh chan modelRefreshResult403 modelRefresh chan modelRefreshResult
404+ startupPreDone chan startupPreResult
389 cancelTurn context.CancelFunc405 cancelTurn context.CancelFunc
390 scrollOffset int // rows from the bottom; 0 = pinned to latest406 scrollOffset int // rows from the bottom; 0 = pinned to latest
391 prevScrollOffset int // last value redraw snapped against; tracks intent407 prevScrollOffset int // last value redraw snapped against; tracks intent
@@ -522,6 +538,15 @@ type Interactive struct {
522 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell538 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
523 // escape can start while one is in flight.539 // escape can start while one is in flight.
524 shellRunning bool540 shellRunning bool
541+ // shellLive is the accumulating stdout/stderr of the in-flight
542+ // shell escape, updated via BashTool progress for live rendering.
543+ shellLive string
544+
545+ // awaitingStartupPre is true while the zotfile entry.pre auto-submit
546+ // is in flight. When it clears, deferredInitialInput is applied.
547+ awaitingStartupPre bool
548+ deferredInitialInput string
549+ autoSubmitDeferred bool
525 550
526 // sessionLoading is true while a /sessions selection is being read551 // sessionLoading is true while a /sessions selection is being read
527 // on a background goroutine. Keeping this off the input goroutine552 // on a background goroutine. Keeping this off the input goroutine
@@ -560,6 +585,11 @@ const initialResumeTailLimit = 80
560 // reveal that would feel jerky.585 // reveal that would feel jerky.
561 const resumeTailExpandStep = 80586 const resumeTailExpandStep = 80
562 587
588+type startupPreResult struct {
589+ deferred string
590+ autoSubmit bool
591+}
592+
563 // NewInteractive constructs an Interactive from cfg.593 // NewInteractive constructs an Interactive from cfg.
564 func NewInteractive(cfg InteractiveConfig) *Interactive {594 func NewInteractive(cfg InteractiveConfig) *Interactive {
565 renderer := tui.NewRenderer(cfg.Terminal)595 renderer := tui.NewRenderer(cfg.Terminal)
@@ -596,6 +626,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive {
596 toolGate: map[string]int{},626 toolGate: map[string]int{},
597 dirty: make(chan struct{}, 8),627 dirty: make(chan struct{}, 8),
598 modelRefresh: make(chan modelRefreshResult, 1),628 modelRefresh: make(chan modelRefreshResult, 1),
629+ startupPreDone: make(chan startupPreResult, 1),
599 dialog: newLoginDialog(),630 dialog: newLoginDialog(),
600 modelDialog: newModelDialog(),631 modelDialog: newModelDialog(),
601 llamaDialog: newLlamaDialog(),632 llamaDialog: newLlamaDialog(),
@@ -703,7 +734,14 @@ func (i *Interactive) Run(ctx context.Context) error {
703 i.redraw()734 i.redraw()
704 })735 })
705 736
706- if i.cfg.InitialInput != "" {737+ if i.cfg.StartupPre != "" {
738+ i.deferredInitialInput = i.cfg.InitialInput
739+ i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
740+ i.awaitingStartupPre = true
741+ i.Submit(i.cfg.StartupPre)
742+ } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
743+ i.Submit(i.cfg.InitialInput)
744+ } else if i.cfg.InitialInput != "" {
707 i.ed.SetValue(i.cfg.InitialInput)745 i.ed.SetValue(i.cfg.InitialInput)
708 }746 }
709 747
@@ -853,6 +891,9 @@ func (i *Interactive) Run(ctx context.Context) error {
853 case result := <-i.modelRefresh:891 case result := <-i.modelRefresh:
854 i.openModelPickerAfterRefresh(result.err)892 i.openModelPickerAfterRefresh(result.err)
855 i.invalidate()893 i.invalidate()
894+ case result := <-i.startupPreDone:
895+ i.applyStartupPreResult(result)
896+ i.invalidate()
856 case info, ok := <-updates:897 case info, ok := <-updates:
857 if ok && info.Available {898 if ok && info.Available {
858 i.mu.Lock()899 i.mu.Lock()
@@ -1062,6 +1103,16 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1062 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")1103 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
1063 }1104 }
1064 1105
1106+ // Live shell-escape output (!command / entry.pre) streams into the
1107+ // transcript area while the command runs, then is replaced by the
1108+ // final user-context message when it finishes.
1109+ if i.shellRunning && i.shellLive != "" {
1110+ for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") {
1111+ chat = append(chat, line)
1112+ }
1113+ chat = append(chat, "")
1114+ }
1115+
1065 // Extension notes (notify / display) live just under the1116 // Extension notes (notify / display) live just under the
1066 // transcript, above the dialog/editor band. Cleared by /clear.1117 // transcript, above the dialog/editor band. Cleared by /clear.
1067 if len(i.extNotes) > 0 {1118 if len(i.extNotes) > 0 {
@@ -2942,6 +2993,45 @@ func (i *Interactive) Submit(text string) {
2942 i.startTurn(i.runCtx, text)2993 i.startTurn(i.runCtx, text)
2943 }2994 }
2944 2995
2996+// completeStartupPre applies deferred InitialInput after entry.pre finishes.
2997+// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
2998+// it only pre-fills the editor (CLI-supplied prompts).
2999+func (i *Interactive) completeStartupPre() {
3000+ i.mu.Lock()
3001+ if !i.awaitingStartupPre {
3002+ i.mu.Unlock()
3003+ return
3004+ }
3005+ i.awaitingStartupPre = false
3006+ deferred := i.deferredInitialInput
3007+ auto := i.autoSubmitDeferred
3008+ i.deferredInitialInput = ""
3009+ i.autoSubmitDeferred = false
3010+ onDone := i.cfg.OnStartupPreDone
3011+ i.mu.Unlock()
3012+ if onDone != nil {
3013+ onDone()
3014+ }
3015+ i.startupPreDone <- startupPreResult{deferred: deferred, autoSubmit: auto}
3016+ i.invalidate()
3017+}
3018+
3019+// applyStartupPreResult runs on the TUI event loop so the editor remains
3020+// single-threaded. Input entered while resources were reloading wins over the
3021+// deferred prefill rather than being overwritten.
3022+func (i *Interactive) applyStartupPreResult(result startupPreResult) {
3023+ if result.deferred == "" {
3024+ return
3025+ }
3026+ if result.autoSubmit {
3027+ i.Submit(result.deferred)
3028+ return
3029+ }
3030+ if i.ed.IsEmpty() {
3031+ i.ed.SetValue(result.deferred)
3032+ }
3033+}
3034+
2945 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3035 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
2946 // not provide startup context metadata.3036 // not provide startup context metadata.
2947 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {3037 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -5366,6 +5456,15 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5366 // treated as not an escape so it falls through to the normal prompt5456 // treated as not an escape so it falls through to the normal prompt
5367 // path rather than running an empty shell.5457 // path rather than running an empty shell.
5368 func shellEscapeCommand(text string) (string, bool) {5458 func shellEscapeCommand(text string) (string, bool) {
5459+ return ShellEscapeCommand(text)
5460+}
5461+
5462+// ShellEscapeCommand reports whether text is a "!command" shell
5463+// escape and, if so, returns the command with the leading '!' (and
5464+// surrounding whitespace) stripped. A bare "!" with no command is
5465+// treated as not an escape so it falls through to the normal prompt
5466+// path rather than running an empty shell.
5467+func ShellEscapeCommand(text string) (string, bool) {
5369 trimmed := strings.TrimLeft(text, " \t")5468 trimmed := strings.TrimLeft(text, " \t")
5370 if !strings.HasPrefix(trimmed, "!") {5469 if !strings.HasPrefix(trimmed, "!") {
5371 return "", false5470 return "", false
@@ -5401,6 +5500,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5401 ctx, cancel := context.WithCancel(parent)5500 ctx, cancel := context.WithCancel(parent)
5402 i.busy = true5501 i.busy = true
5403 i.shellRunning = true5502 i.shellRunning = true
5503+ i.shellLive = "$ " + cmd + "\n\n"
5404 i.cancelTurn = cancel5504 i.cancelTurn = cancel
5405 i.statusErr = ""5505 i.statusErr = ""
5406 i.statusOK = ""5506 i.statusOK = ""
@@ -5421,7 +5521,13 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5421 defer cancel()5521 defer cancel()
5422 raw, _ := json.Marshal(map[string]any{"command": cmd})5522 raw, _ := json.Marshal(map[string]any{"command": cmd})
5423 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}5523 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5424- res, err := bash.Execute(ctx, raw, nil)5524+ progress := func(chunk string) {
5525+ i.mu.Lock()
5526+ i.shellLive += chunk
5527+ i.mu.Unlock()
5528+ i.invalidate()
5529+ }
5530+ res, err := bash.Execute(ctx, raw, progress)
5425 5531
5426 var out string5532 var out string
5427 if err != nil {5533 if err != nil {
@@ -5439,12 +5545,16 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5439 out += "\n\n[cancelled]"5545 out += "\n\n[cancelled]"
5440 }5546 }
5441 5547
5442- i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})5548+ if i.agent != nil {
5549+ i.agent.AppendUserContext(out, map[string]string{shellEscapeMetaKey: "true"})
5550+ }
5443 5551
5444 i.mu.Lock()5552 i.mu.Lock()
5445 i.shellRunning = false5553 i.shellRunning = false
5554+ i.shellLive = ""
5446 i.busy = false5555 i.busy = false
5447 i.cancelTurn = nil5556 i.cancelTurn = nil
5557+ awaitingPre := i.awaitingStartupPre
5448 if failed {5558 if failed {
5449 if cancelled {5559 if cancelled {
5450 i.statusErr = "shell command cancelled"5560 i.statusErr = "shell command cancelled"
@@ -5458,6 +5568,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5458 }5568 }
5459 i.mu.Unlock()5569 i.mu.Unlock()
5460 i.invalidate()5570 i.invalidate()
5571+ if awaitingPre {
5572+ i.completeStartupPre()
5573+ }
5461 }()5574 }()
5462 }5575 }
5463 5576
@@ -5471,6 +5584,14 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5471 5584
5472 func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {5585 func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5473 if i.agent == nil {5586 if i.agent == nil {
5587+ // Text startup pre cannot run without credentials; continue so
5588+ // deferred InitialInput (pre-fill or auto-submit) still applies.
5589+ i.mu.Lock()
5590+ awaitingPre := i.awaitingStartupPre
5591+ i.mu.Unlock()
5592+ if awaitingPre {
5593+ i.completeStartupPre()
5594+ }
5474 return5595 return
5475 }5596 }
5476 // Pre-turn safety: if the most recent context measurement is5597 // Pre-turn safety: if the most recent context measurement is
@@ -5612,10 +5733,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5612 flush()5733 flush()
5613 }5734 }
5614 i.mu.Lock()5735 i.mu.Lock()
5736+ awaitingPre := i.awaitingStartupPre
5615 // Pop the next queued message, if any, and relaunch.5737 // Pop the next queued message, if any, and relaunch.
5616 var next string5738 var next string
5617 var hasNext bool5739 var hasNext bool
5618- if len(i.queued) > 0 && ctx.Err() == nil && err == nil {5740+ if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5619 next, i.queued = i.queued[0], i.queued[1:]5741 next, i.queued = i.queued[0], i.queued[1:]
5620 hasNext = true5742 hasNext = true
5621 }5743 }
@@ -5635,13 +5757,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5635 if i.agent != nil {5757 if i.agent != nil {
5636 agentQueued = i.agent.QueuedMessageCount()5758 agentQueued = i.agent.QueuedMessageCount()
5637 }5759 }
5638- shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()5760+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5639 i.mu.Unlock()5761 i.mu.Unlock()
5640 i.invalidate()5762 i.invalidate()
5641 parent := i.runCtx5763 parent := i.runCtx
5642 if parent == nil {5764 if parent == nil {
5643 parent = context.Background()5765 parent = context.Background()
5644 }5766 }
5767+ if awaitingPre {
5768+ i.completeStartupPre()
5769+ return
5770+ }
5645 switch {5771 switch {
5646 case hasNext:5772 case hasNext:
5647 i.startTurn(parent, next)5773 i.startTurn(parent, next)
A packages/agent/modes/startup_pre_test.go +136 -0
@@ -0,0 +1,136 @@
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 applyStartupPreResultForTest(t *testing.T, i *Interactive) {
14+ t.Helper()
15+ select {
16+ case result := <-i.startupPreDone:
17+ i.applyStartupPreResult(result)
18+ default:
19+ t.Fatal("startup pre result was not queued")
20+ }
21+}
22+
23+func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
24+ i := NewInteractive(InteractiveConfig{})
25+ i.awaitingStartupPre = true
26+ i.deferredInitialInput = "Bom dia!"
27+ i.autoSubmitDeferred = false
28+ i.completeStartupPre()
29+ applyStartupPreResultForTest(t, i)
30+ if got := i.ed.Value(); got != "Bom dia!" {
31+ t.Fatalf("editor = %q, want Bom dia!", got)
32+ }
33+ i.mu.Lock()
34+ awaiting := i.awaitingStartupPre
35+ i.mu.Unlock()
36+ if awaiting {
37+ t.Fatal("awaitingStartupPre still set after completeStartupPre")
38+ }
39+}
40+
41+func TestStartupPrePrefillPreservesTypedInput(t *testing.T) {
42+ i := NewInteractive(InteractiveConfig{})
43+ i.ed.SetValue("typed while reloading")
44+ i.applyStartupPreResult(startupPreResult{deferred: "default prompt"})
45+ if got := i.ed.Value(); got != "typed while reloading" {
46+ t.Fatalf("editor = %q, want typed input to be preserved", got)
47+ }
48+}
49+
50+func TestCompleteStartupPreCallsOnDone(t *testing.T) {
51+ called := false
52+ i := NewInteractive(InteractiveConfig{
53+ OnStartupPreDone: func() { called = true },
54+ })
55+ i.awaitingStartupPre = true
56+ i.completeStartupPre()
57+ if !called {
58+ t.Fatal("OnStartupPreDone was not called")
59+ }
60+}
61+
62+func TestStartupPreShellWithoutAgentThenPrefill(t *testing.T) {
63+ i := NewInteractive(InteractiveConfig{CWD: t.TempDir()})
64+ i.runCtx = context.Background()
65+ i.awaitingStartupPre = true
66+ i.deferredInitialInput = "after-pre"
67+ i.autoSubmitDeferred = false
68+
69+ cmd := "printf zot-pre"
70+ if runtime.GOOS == "windows" {
71+ cmd = "echo zot-pre"
72+ }
73+ i.startShellEscape(context.Background(), cmd)
74+
75+ deadline := time.Now().Add(5 * time.Second)
76+ for time.Now().Before(deadline) {
77+ select {
78+ case result := <-i.startupPreDone:
79+ i.applyStartupPreResult(result)
80+ default:
81+ }
82+ i.mu.Lock()
83+ running := i.shellRunning
84+ awaiting := i.awaitingStartupPre
85+ i.mu.Unlock()
86+ if !running && !awaiting {
87+ if got := i.ed.Value(); got != "after-pre" {
88+ t.Fatalf("editor = %q, want after-pre", got)
89+ }
90+ return
91+ }
92+ time.Sleep(10 * time.Millisecond)
93+ }
94+ t.Fatal("startup pre did not complete and prefill the editor")
95+}
96+
97+func TestStartupPreShellThenAutoSubmitShell(t *testing.T) {
98+ agent := core.NewAgent(nil, "", "", nil)
99+ i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
100+ i.runCtx = context.Background()
101+ i.awaitingStartupPre = true
102+ i.deferredInitialInput = "!printf zot-default"
103+ if runtime.GOOS == "windows" {
104+ i.deferredInitialInput = "!echo zot-default"
105+ }
106+ i.autoSubmitDeferred = true
107+
108+ cmd := "printf zot-pre"
109+ if runtime.GOOS == "windows" {
110+ cmd = "echo zot-pre"
111+ }
112+ i.startShellEscape(context.Background(), cmd)
113+
114+ deadline := time.Now().Add(5 * time.Second)
115+ for time.Now().Before(deadline) {
116+ select {
117+ case result := <-i.startupPreDone:
118+ i.applyStartupPreResult(result)
119+ default:
120+ }
121+ messages := agent.Messages()
122+ i.mu.Lock()
123+ running := i.shellRunning || i.busy
124+ awaiting := i.awaitingStartupPre
125+ i.mu.Unlock()
126+ if len(messages) >= 2 && !running && !awaiting {
127+ joined := userMessageText(messages[0]) + "\n" + userMessageText(messages[1])
128+ if !strings.Contains(joined, "zot-pre") || !strings.Contains(joined, "zot-default") {
129+ t.Fatalf("context = %q, want both pre and default shell output", joined)
130+ }
131+ return
132+ }
133+ time.Sleep(10 * time.Millisecond)
134+ }
135+ t.Fatal("startup pre did not auto-submit the deferred prompt")
136+}
@@ -0,0 +1,136 @@
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 applyStartupPreResultForTest(t *testing.T, i *Interactive) {
14+ t.Helper()
15+ select {
16+ case result := <-i.startupPreDone:
17+ i.applyStartupPreResult(result)
18+ default:
19+ t.Fatal("startup pre result was not queued")
20+ }
21+}
22+
23+func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
24+ i := NewInteractive(InteractiveConfig{})
25+ i.awaitingStartupPre = true
26+ i.deferredInitialInput = "Bom dia!"
27+ i.autoSubmitDeferred = false
28+ i.completeStartupPre()
29+ applyStartupPreResultForTest(t, i)
30+ if got := i.ed.Value(); got != "Bom dia!" {
31+ t.Fatalf("editor = %q, want Bom dia!", got)
32+ }
33+ i.mu.Lock()
34+ awaiting := i.awaitingStartupPre
35+ i.mu.Unlock()
36+ if awaiting {
37+ t.Fatal("awaitingStartupPre still set after completeStartupPre")
38+ }
39+}
40+
41+func TestStartupPrePrefillPreservesTypedInput(t *testing.T) {
42+ i := NewInteractive(InteractiveConfig{})
43+ i.ed.SetValue("typed while reloading")
44+ i.applyStartupPreResult(startupPreResult{deferred: "default prompt"})
45+ if got := i.ed.Value(); got != "typed while reloading" {
46+ t.Fatalf("editor = %q, want typed input to be preserved", got)
47+ }
48+}
49+
50+func TestCompleteStartupPreCallsOnDone(t *testing.T) {
51+ called := false
52+ i := NewInteractive(InteractiveConfig{
53+ OnStartupPreDone: func() { called = true },
54+ })
55+ i.awaitingStartupPre = true
56+ i.completeStartupPre()
57+ if !called {
58+ t.Fatal("OnStartupPreDone was not called")
59+ }
60+}
61+
62+func TestStartupPreShellWithoutAgentThenPrefill(t *testing.T) {
63+ i := NewInteractive(InteractiveConfig{CWD: t.TempDir()})
64+ i.runCtx = context.Background()
65+ i.awaitingStartupPre = true
66+ i.deferredInitialInput = "after-pre"
67+ i.autoSubmitDeferred = false
68+
69+ cmd := "printf zot-pre"
70+ if runtime.GOOS == "windows" {
71+ cmd = "echo zot-pre"
72+ }
73+ i.startShellEscape(context.Background(), cmd)
74+
75+ deadline := time.Now().Add(5 * time.Second)
76+ for time.Now().Before(deadline) {
77+ select {
78+ case result := <-i.startupPreDone:
79+ i.applyStartupPreResult(result)
80+ default:
81+ }
82+ i.mu.Lock()
83+ running := i.shellRunning
84+ awaiting := i.awaitingStartupPre
85+ i.mu.Unlock()
86+ if !running && !awaiting {
87+ if got := i.ed.Value(); got != "after-pre" {
88+ t.Fatalf("editor = %q, want after-pre", got)
89+ }
90+ return
91+ }
92+ time.Sleep(10 * time.Millisecond)
93+ }
94+ t.Fatal("startup pre did not complete and prefill the editor")
95+}
96+
97+func TestStartupPreShellThenAutoSubmitShell(t *testing.T) {
98+ agent := core.NewAgent(nil, "", "", nil)
99+ i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
100+ i.runCtx = context.Background()
101+ i.awaitingStartupPre = true
102+ i.deferredInitialInput = "!printf zot-default"
103+ if runtime.GOOS == "windows" {
104+ i.deferredInitialInput = "!echo zot-default"
105+ }
106+ i.autoSubmitDeferred = true
107+
108+ cmd := "printf zot-pre"
109+ if runtime.GOOS == "windows" {
110+ cmd = "echo zot-pre"
111+ }
112+ i.startShellEscape(context.Background(), cmd)
113+
114+ deadline := time.Now().Add(5 * time.Second)
115+ for time.Now().Before(deadline) {
116+ select {
117+ case result := <-i.startupPreDone:
118+ i.applyStartupPreResult(result)
119+ default:
120+ }
121+ messages := agent.Messages()
122+ i.mu.Lock()
123+ running := i.shellRunning || i.busy
124+ awaiting := i.awaitingStartupPre
125+ i.mu.Unlock()
126+ if len(messages) >= 2 && !running && !awaiting {
127+ joined := userMessageText(messages[0]) + "\n" + userMessageText(messages[1])
128+ if !strings.Contains(joined, "zot-pre") || !strings.Contains(joined, "zot-default") {
129+ t.Fatalf("context = %q, want both pre and default shell output", joined)
130+ }
131+ return
132+ }
133+ time.Sleep(10 * time.Millisecond)
134+ }
135+ t.Fatal("startup pre did not auto-submit the deferred prompt")
136+}
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) {
6363 t.Fatal("unlisted command was allowed")
6464 }
6565 }
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 +69 -20
@@ -50,6 +50,7 @@ type ZotfileManifest struct {
5050 } `json:"requirements"`
5151 Entry struct {
5252 Greeting string `json:"greeting"`
53+ Pre string `json:"pre"`
5354 DefaultPrompt *string `json:"default_prompt"`
5455 } `json:"entry"`
5556 ReplaceSystemPrompt bool `json:"replace_system_prompt"`
@@ -99,13 +100,20 @@ func runZotfileCommand(rawArgs []string, version string) (bool, error) {
99100 if len(rawArgs) < 2 {
100101 return true, fmt.Errorf("zot run requires a name, .zot file, directory, or GitHub URL")
101102 }
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:]
104109 args, err := ParseArgs(rest)
105110 if err != nil {
106111 PrintHelp(version)
107112 return true, err
108113 }
114+ if yes {
115+ args.Yes = true
116+ }
109117 return true, runLocalZotfile(ref, args, version)
110118 default:
111119 return false, nil
@@ -129,26 +137,32 @@ func runLocalZotfile(ref string, args Args, version string) error {
129137 return err
130138 }
131139 perms := zf.Manifest.Permissions.Expand(args.CWD, agentData)
132- allowed, err := consentZotfile(zf, perms)
140+ allowed, err := consentZotfile(zf, perms, args.Yes)
133141 if err != nil {
134142 return err
135143 }
136144 if !allowed {
137145 return nil
138146 }
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- }
144147 if err := applyZotfileModelRequirements(&args, zf.Manifest); err != nil {
145148 return err
146149 }
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)
151164 }
165+ args.StartupPre = strings.TrimSpace(zf.Manifest.Entry.Pre)
152166 if args.Prompt == "" && zf.Manifest.Entry.DefaultPrompt != nil {
153167 args.Prompt = *zf.Manifest.Entry.DefaultPrompt
154168 }
@@ -397,7 +411,12 @@ func resolveZotfileRef(ref string) (string, error) {
397411 return officialZotfileCollection + "/" + parts[0], nil
398412 }
399413 if len(parts) == 2 && validZotfileCollectionSegment(parts[0]) && validZotfileCollectionSegment(parts[1]) {
400- return "https://github.com/patriceckhart/" + parts[0] + "/" + parts[1], nil
414+ // Preserve the original official-collection shorthand, where
415+ // agents/<name> selects a subdirectory of patriceckhart/agents.
416+ if parts[0] == "agents" {
417+ return officialZotfileCollection + "/" + parts[1], nil
418+ }
419+ return "https://github.com/" + parts[0] + "/" + parts[1], nil
401420 }
402421 return ref, nil
403422 }
@@ -566,9 +585,17 @@ func readZotManifest(dir string) (ZotfileManifest, error) {
566585 }
567586
568587 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")
588+ agentPath := filepath.Join(dir, "AGENT.md")
589+ st, err := os.Stat(agentPath)
590+ switch {
591+ case err == nil:
592+ if !st.Mode().IsRegular() {
593+ return fmt.Errorf("AGENT.md must be a regular file")
594+ }
595+ case os.IsNotExist(err):
596+ // AGENT.md is optional.
597+ default:
598+ return fmt.Errorf("stat AGENT.md: %w", err)
572599 }
573600 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
574601 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
@@ -755,9 +782,23 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error {
755782
756783 var execLookPath = exec.LookPath
757784
758-func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
759- if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
760- return true, nil
785+func peelLeadingYes(args []string) (yes bool, rest []string) {
786+ rest = args
787+ for len(rest) > 0 {
788+ switch rest[0] {
789+ case "-y", "--yes":
790+ yes = true
791+ rest = rest[1:]
792+ default:
793+ return yes, rest
794+ }
795+ }
796+ return yes, rest
797+}
798+
799+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
800+ if autoYes || os.Getenv("ZOT_AGENT_CONSENT") == "1" {
801+ return acceptZotfileConsent(zf, perms)
761802 }
762803 // "ask" deliberately requires approval on every launch. Other consent
763804 // is durable only for this exact artifact digest, so any package change
@@ -776,15 +817,23 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
776817 }
777818 fmt.Print(formatZotfileConsent(zf, perms, th, interactive))
778819 if !interactive {
779- return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
820+ return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZOT_AGENT_CONSENT=1 to allow")
780821 }
781822 allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)
782823 if err != nil || !allowed {
783824 return allowed, err
784825 }
826+ return acceptZotfileConsent(zf, perms)
827+}
828+
829+// acceptZotfileConsent records durable consent when the bash mode is not
830+// "ask". Ask mode never caches receipts so every launch re-prompts unless
831+// -y / ZOT_AGENT_CONSENT is used for that run.
832+func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
785833 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
786834 return true, nil
787835 }
836+ consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
788837 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
789838 return false, fmt.Errorf("save agent consent: %w", err)
790839 }
@@ -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, err112 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, nil119 return false, nil
@@ -129,26 +137,32 @@ func runLocalZotfile(ref string, args Args, version string) error {
129 return err137 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 err142 return err
135 }143 }
136 if !allowed {144 if !allowed {
137 return nil145 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 err148 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.DefaultPrompt167 args.Prompt = *zf.Manifest.Entry.DefaultPrompt
154 }168 }
@@ -397,7 +411,12 @@ func resolveZotfileRef(ref string) (string, error) {
397 return officialZotfileCollection + "/" + parts[0], nil411 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], nil414+ // Preserve the original official-collection shorthand, where
415+ // agents/<name> selects a subdirectory of patriceckhart/agents.
416+ if parts[0] == "agents" {
417+ return officialZotfileCollection + "/" + parts[1], nil
418+ }
419+ return "https://github.com/" + parts[0] + "/" + parts[1], nil
401 }420 }
402 return ref, nil421 return ref, nil
403 }422 }
@@ -566,9 +585,17 @@ func readZotManifest(dir string) (ZotfileManifest, error) {
566 }585 }
567 586
568 func validateZotfileDir(dir string) error {587 func validateZotfileDir(dir string) error {
569- st, err := os.Stat(filepath.Join(dir, "AGENT.md"))588+ agentPath := filepath.Join(dir, "AGENT.md")
570- if err != nil || !st.Mode().IsRegular() {589+ st, err := os.Stat(agentPath)
571- return fmt.Errorf("AGENT.md is required")590+ switch {
591+ case err == nil:
592+ if !st.Mode().IsRegular() {
593+ return fmt.Errorf("AGENT.md must be a regular file")
594+ }
595+ case os.IsNotExist(err):
596+ // AGENT.md is optional.
597+ default:
598+ return fmt.Errorf("stat AGENT.md: %w", err)
572 }599 }
573 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {600 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")601 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
@@ -755,9 +782,23 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error {
755 782
756 var execLookPath = exec.LookPath783 var execLookPath = exec.LookPath
757 784
758-func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {785+func peelLeadingYes(args []string) (yes bool, rest []string) {
759- if os.Getenv("ZOT_AGENT_CONSENT") == "1" {786+ rest = args
760- return true, nil787+ for len(rest) > 0 {
788+ switch rest[0] {
789+ case "-y", "--yes":
790+ yes = true
791+ rest = rest[1:]
792+ default:
793+ return yes, rest
794+ }
795+ }
796+ return yes, rest
797+}
798+
799+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
800+ if autoYes || os.Getenv("ZOT_AGENT_CONSENT") == "1" {
801+ return acceptZotfileConsent(zf, perms)
761 }802 }
762 // "ask" deliberately requires approval on every launch. Other consent803 // "ask" deliberately requires approval on every launch. Other consent
763 // is durable only for this exact artifact digest, so any package change804 // is durable only for this exact artifact digest, so any package change
@@ -776,15 +817,23 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
776 }817 }
777 fmt.Print(formatZotfileConsent(zf, perms, th, interactive))818 fmt.Print(formatZotfileConsent(zf, perms, th, interactive))
778 if !interactive {819 if !interactive {
779- return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")820+ return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZOT_AGENT_CONSENT=1 to allow")
780 }821 }
781 allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)822 allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)
782 if err != nil || !allowed {823 if err != nil || !allowed {
783 return allowed, err824 return allowed, err
784 }825 }
826+ return acceptZotfileConsent(zf, perms)
827+}
828+
829+// acceptZotfileConsent records durable consent when the bash mode is not
830+// "ask". Ask mode never caches receipts so every launch re-prompts unless
831+// -y / ZOT_AGENT_CONSENT is used for that run.
832+func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
785 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {833 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
786 return true, nil834 return true, nil
787 }835 }
836+ consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
788 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {837 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
789 return false, fmt.Errorf("save agent consent: %w", err)838 return false, fmt.Errorf("save agent consent: %w", err)
790 }839 }
A packages/agent/zotfile_pre_test.go +61 -0
@@ -0,0 +1,61 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "runtime"
7+ "strings"
8+ "testing"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestStreamTextSinkDoesNotRepeatCompletedMessage(t *testing.T) {
15+ var out bytes.Buffer
16+ sink, finish := newStreamTextSink(&out)
17+ sink(core.EvAssistantStart{})
18+ sink(core.EvTextDelta{Delta: "streamed"})
19+ sink(core.EvAssistantMessage{Message: provider.Message{
20+ Role: provider.RoleAssistant,
21+ Content: []provider.Content{provider.TextBlock{Text: "streamed"}},
22+ }})
23+ finish()
24+ if got := out.String(); got != "streamed\n" {
25+ t.Fatalf("output = %q, want streamed\\n", got)
26+ }
27+}
28+
29+func TestRunZotfileStartupPreShellFailure(t *testing.T) {
30+ pre := "!false"
31+ if runtime.GOOS == "windows" {
32+ pre = "!exit 1"
33+ }
34+ err := runZotfileStartupPre(context.Background(), pre, t.TempDir(), nil, nil, nil, nil)
35+ if err == nil {
36+ t.Fatal("expected entry.pre shell failure")
37+ }
38+ if !strings.Contains(err.Error(), "entry.pre") {
39+ t.Fatalf("error = %v, want entry.pre context", err)
40+ }
41+}
42+
43+func TestRunZotfileStartupPreEmpty(t *testing.T) {
44+ if err := runZotfileStartupPre(context.Background(), " ", t.TempDir(), nil, nil, nil, nil); err != nil {
45+ t.Fatalf("empty pre: %v", err)
46+ }
47+}
48+
49+func TestRunZotfileStartupPreShellStreams(t *testing.T) {
50+ cmd := "!printf streamed-pre"
51+ if runtime.GOOS == "windows" {
52+ cmd = "!echo streamed-pre"
53+ }
54+ var out strings.Builder
55+ if err := runZotfileStartupPre(context.Background(), cmd, t.TempDir(), nil, nil, nil, &out); err != nil {
56+ t.Fatal(err)
57+ }
58+ if !strings.Contains(out.String(), "streamed-pre") {
59+ t.Fatalf("shell output not streamed: %q", out.String())
60+ }
61+}
@@ -0,0 +1,61 @@
1+package agent
2+
3+import (
4+ "bytes"
5+ "context"
6+ "runtime"
7+ "strings"
8+ "testing"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+func TestStreamTextSinkDoesNotRepeatCompletedMessage(t *testing.T) {
15+ var out bytes.Buffer
16+ sink, finish := newStreamTextSink(&out)
17+ sink(core.EvAssistantStart{})
18+ sink(core.EvTextDelta{Delta: "streamed"})
19+ sink(core.EvAssistantMessage{Message: provider.Message{
20+ Role: provider.RoleAssistant,
21+ Content: []provider.Content{provider.TextBlock{Text: "streamed"}},
22+ }})
23+ finish()
24+ if got := out.String(); got != "streamed\n" {
25+ t.Fatalf("output = %q, want streamed\\n", got)
26+ }
27+}
28+
29+func TestRunZotfileStartupPreShellFailure(t *testing.T) {
30+ pre := "!false"
31+ if runtime.GOOS == "windows" {
32+ pre = "!exit 1"
33+ }
34+ err := runZotfileStartupPre(context.Background(), pre, t.TempDir(), nil, nil, nil, nil)
35+ if err == nil {
36+ t.Fatal("expected entry.pre shell failure")
37+ }
38+ if !strings.Contains(err.Error(), "entry.pre") {
39+ t.Fatalf("error = %v, want entry.pre context", err)
40+ }
41+}
42+
43+func TestRunZotfileStartupPreEmpty(t *testing.T) {
44+ if err := runZotfileStartupPre(context.Background(), " ", t.TempDir(), nil, nil, nil, nil); err != nil {
45+ t.Fatalf("empty pre: %v", err)
46+ }
47+}
48+
49+func TestRunZotfileStartupPreShellStreams(t *testing.T) {
50+ cmd := "!printf streamed-pre"
51+ if runtime.GOOS == "windows" {
52+ cmd = "!echo streamed-pre"
53+ }
54+ var out strings.Builder
55+ if err := runZotfileStartupPre(context.Background(), cmd, t.TempDir(), nil, nil, nil, &out); err != nil {
56+ t.Fatal(err)
57+ }
58+ if !strings.Contains(out.String(), "streamed-pre") {
59+ t.Fatalf("shell output not streamed: %q", out.String())
60+ }
61+}
M packages/agent/zotfile_test.go +101 -2
@@ -93,6 +93,33 @@ func writeTestZotfile(t *testing.T, manifest string) string {
9393 return dir
9494 }
9595
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+
96123 func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
97124 for _, field := range []string{
98125 `"net":{"allow":["example.com"]}`,
@@ -105,6 +132,27 @@ func TestLoadZotfileRejectsUnenforcedPermissions(t *testing.T) {
105132 }
106133 }
107134
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+
108156 func TestLoadZotfileRejectsUnsafeOrCollidingNames(t *testing.T) {
109157 for _, name := range []string{"...", "Name", "two words", "a/b"} {
110158 dir := writeTestZotfile(t, `{"zotfile":1,"name":"`+name+`"}`)
@@ -132,8 +180,9 @@ func TestResolveZotfileRefUsesLocalFirstThenOfficialCollection(t *testing.T) {
132180 {"packed-agent", "packed-agent.zot"},
133181 {"remote-agent", officialZotfileCollection + "/remote-agent"},
134182 {"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", officialZotfileCollection + "/remote-agent"},
184+ {`agents\remote-agent`, officialZotfileCollection + "/remote-agent"},
185+ {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
137186 {"./missing-agent", "./missing-agent"},
138187 {"Remote-Agent", "Remote-Agent"},
139188 {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},
@@ -307,3 +356,53 @@ func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
307356 t.Fatalf("summary did not show denied scopes:\n%s", got)
308357 }
309358 }
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 dir93 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", officialZotfileCollection + "/remote-agent"},
136- {`agents\remote-agent`, "https://github.com/patriceckhart/agents/remote-agent"},184+ {`agents\remote-agent`, officialZotfileCollection + "/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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close