detached 189298b

Zot files

- AGENT.md optional
- entry.pre - Pre turn or shell on zot files
- entry.pre - Autoload skills and extentions after run
- consent argument
- third party repo resolution
- --stream for non-interactive
commit
189298b1293724e28655ad34b63f3e75f318f273
author
DaviSM · · 2026-08-01 18:16 UTC
committer
DaviSM · · 2026-08-01 18:18 UTC
parent
f3d8eb6
signature
unsigned
13 files · +930 -83
M README.md +1 -0
@@ -234,6 +234,7 @@ When the sandbox is on (see `/jail`), all four tools refuse paths outside the se
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 +27 -7
@@ -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,7 +521,7 @@ 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:
506525
507526 ```bash
508527 zot run agents/zot-maintenance
@@ -525,7 +544,7 @@ zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
525544
526545 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`.
527546
528-Short-name fallback is a fixed GitHub mapping, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
547+Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
529548
530549 ### `zot pack`
531550
@@ -596,6 +615,7 @@ Implemented now:
596615
597616 - local directories and `.zot` archives
598617 - local-first short-name resolution to the official GitHub agent collection
618+- local-first `owner/repo` resolution to `https://github.com/owner/repo`
599619 - temporary execution of agent directories from public GitHub repositories
600620 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`
601621 - 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,7 +521,7 @@ zot run zot-maintenance
502 521
503 Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back to `https://github.com/patriceckhart/agents/zot-maintenance`. This makes local development override the remote collection without an install or cache step.522 Zot checks `./zot-maintenance` and `./zot-maintenance.zot` before falling back to `https://github.com/patriceckhart/agents/zot-maintenance`. This makes local development override the remote collection without an install or cache step.
504 523
505-A two-part name selects a GitHub repository under the `patriceckhart` organization:524+A two-part name is treated as a GitHub `owner/repo` reference. Resolution is local-first, then the repository root:
506 525
507 ```bash526 ```bash
508 zot run agents/zot-maintenance527 zot run agents/zot-maintenance
@@ -525,7 +544,7 @@ zot run https://github.com/patriceckhart/agents/tree/main/zot-maintenance
525 544
526 The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.545 The short form reads the repository's default branch through GitHub's `HEAD` archive. A tree URL uses the branch or tag in the URL. Private repositories and GitHub references containing `/` are not currently supported. The downloaded source is temporary, but normal agent data, consent receipts, and session transcripts remain under `$ZOT_HOME`.
527 546
528-Short-name fallback is a fixed GitHub mapping, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.547+Short-name and `owner/repo` fallbacks are fixed GitHub mappings, not an indexed or signed registry. Installed names, arbitrary URLs, OCI references, and third-party registry configuration are not resolved yet.
529 548
530 ### `zot pack`549 ### `zot pack`
531 550
@@ -596,6 +615,7 @@ Implemented now:
596 615
597 - local directories and `.zot` archives616 - local directories and `.zot` archives
598 - local-first short-name resolution to the official GitHub agent collection617 - local-first short-name resolution to the official GitHub agent collection
618+- local-first `owner/repo` resolution to `https://github.com/owner/repo`
599 - temporary execution of agent directories from public GitHub repositories619 - temporary execution of agent directories from public GitHub repositories
600 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`620 - `zot pack`, `zot inspect`, `zot verify`, and `zot run`
601 - canonical tar creation with zstd compression621 - 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 +226 -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,72 @@ 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 := func(ev core.AgentEvent) {
395+ switch e := ev.(type) {
396+ case core.EvTextDelta:
397+ fmt.Fprint(os.Stdout, e.Delta)
398+ _ = os.Stdout.Sync()
399+ case core.EvAssistantMessage:
400+ // Fallback when the provider does not emit deltas.
401+ var sb strings.Builder
402+ for _, c := range e.Message.Content {
403+ if tb, ok := c.(provider.TextBlock); ok {
404+ if sb.Len() > 0 {
405+ sb.WriteString("\n")
406+ }
407+ sb.WriteString(tb.Text)
408+ }
409+ }
410+ if sb.Len() > 0 {
411+ fmt.Fprint(os.Stdout, sb.String())
412+ _ = os.Stdout.Sync()
413+ }
414+ }
415+ }
416+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
417+ return err
418+ }
419+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
420+ err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
421+ WriteNewTranscript(ag, sess, start)
422+ return err
423+}
424+
362425 func runJSONMode(ctx context.Context, args Args, version string) error {
363426 if args.NoYolo {
364427 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
@@ -385,11 +448,119 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
385448 }
386449
387450 start := len(ag.Messages())
451+ enc := json.NewEncoder(os.Stdout)
452+ preSink := func(ev core.AgentEvent) {
453+ _ = enc.Encode(modes.EventToJSON(ev))
454+ }
455+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
456+ _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
457+ return err
458+ }
459+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
388460 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
389461 WriteNewTranscript(ag, sess, start)
390462 return err
391463 }
392464
465+// runZotfileStartupPre runs entry.pre before the main non-interactive prompt.
466+// "!command" values execute via BashTool; other values are sent as an agent turn.
467+// shellOut receives live shell chunks (typically os.Stderr); sink receives
468+// agent events for plain-text pre (stream mode wires a live text sink).
469+func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
470+ pre = strings.TrimSpace(pre)
471+ if pre == "" {
472+ return nil
473+ }
474+ if cmd, ok := modes.ShellEscapeCommand(pre); ok {
475+ raw, err := json.Marshal(map[string]any{"command": cmd})
476+ if err != nil {
477+ return err
478+ }
479+ if shellOut != nil {
480+ fmt.Fprintf(shellOut, "$ %s\n", cmd)
481+ if f, ok := shellOut.(interface{ Sync() error }); ok {
482+ _ = f.Sync()
483+ }
484+ }
485+ progress := func(chunk string) {
486+ if shellOut == nil || chunk == "" {
487+ return
488+ }
489+ fmt.Fprint(shellOut, chunk)
490+ if f, ok := shellOut.(interface{ Sync() error }); ok {
491+ _ = f.Sync()
492+ }
493+ }
494+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
495+ res, err := bash.Execute(ctx, raw, progress)
496+ if err != nil {
497+ return fmt.Errorf("zotfile entry.pre: %w", err)
498+ }
499+ if res.IsError {
500+ var sb strings.Builder
501+ for _, c := range res.Content {
502+ if tb, ok := c.(provider.TextBlock); ok {
503+ sb.WriteString(tb.Text)
504+ }
505+ }
506+ msg := strings.TrimSpace(sb.String())
507+ if msg == "" {
508+ msg = "command failed"
509+ }
510+ return fmt.Errorf("zotfile entry.pre: %s", msg)
511+ }
512+ return nil
513+ }
514+ if ag == nil {
515+ return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts")
516+ }
517+ if sink == nil {
518+ sink = func(core.AgentEvent) {}
519+ }
520+ return ag.Prompt(ctx, pre, nil, sink)
521+}
522+
523+// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
524+// skills and currently loaded extension tools) and updates the live
525+// agent's registry and system prompt. Used after /reload-ext and after
526+// zotfile entry.pre installs new skills or extensions.
527+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn).
528+func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
529+ if ag == nil {
530+ return
531+ }
532+ resolved, err := Resolve(args, true)
533+ if err != nil {
534+ return
535+ }
536+ if sharedSandbox != nil {
537+ resolved.UseSandbox(sharedSandbox)
538+ }
539+ if extToolAdapter != nil {
540+ resolved.MergeExtensionTools(extToolAdapter)
541+ }
542+ reg := resolved.ToolRegistry
543+ if mutateRegistry != nil {
544+ reg = mutateRegistry(reg)
545+ }
546+ ag.SetTools(reg)
547+ ag.System = resolved.SystemPrompt
548+}
549+
550+// reloadResourcesAfterStartupPre reloads extensions (if any) and
551+// refreshes the agent tool registry + system prompt so skills/extensions
552+// installed by entry.pre are visible to the following turn.
553+func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {
554+ if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
555+ return
556+ }
557+ adapter := &extToolAdapter{mgr: extMgr}
558+ if extMgr != nil {
559+ _ = extMgr.Reload(ctx, 2*time.Second)
560+ }
561+ refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)
562+}
563+
393564 // ---- interactive mode: opens the TUI even without credentials ----
394565
395566 func runInteractive(ctx context.Context, args Args, version string) error {
@@ -653,18 +824,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
653824 // /model swap since spawn, so re-read the live `ag` on each
654825 // invocation.
655826 extMgr.SetOnReload(func() {
656- current := ag
827+ current := liveInteractiveAgent(iv, ag)
657828 if current == nil {
658829 return
659830 }
660- resolved, err := Resolve(args, true)
661- if err != nil {
662- return
663- }
664- resolved.UseSandbox(sharedSandbox)
665- resolved.MergeExtensionTools(extToolAdapter)
666- injectSwarmSpawn(resolved.ToolRegistry)
667- current.SetTools(resolved.ToolRegistry)
831+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
668832 })
669833
670834 // Fire session_start once we know the manager's running.
@@ -1013,47 +1177,59 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10131177 }
10141178
10151179 iv = modes.NewInteractive(modes.InteractiveConfig{
1016- Terminal: term,
1017- Theme: theme,
1018- InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1019- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1020- AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1021- JailByDefault: initialCfg.JailByDefault,
1022- QuickModelShortcuts: quickModelShortcuts,
1023- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1024- RespectGitignore: initialCfg.RespectGitignore,
1025- CompactMode: initialCfg.CompactMode,
1026- TUIInputStyle: initialCfg.TUIInputStyle,
1027- TUIStatusPosition: initialCfg.TUIStatusPosition,
1028- TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1029- ThemeName: initialCfg.Theme,
1030- FlatTools: initialCfg.FlatToolRender(),
1031- CompactUser: initialCfg.CompactUserInput(),
1032- ExtensionThemes: extMgr.ThemeOptions,
1033- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1034- SettingsStore: configSettingsStore{},
1035- Model: r.Model,
1036- Provider: r.Provider,
1037- AuthMethod: r.AuthMethod,
1038- BaseURL: r.BaseURL,
1039- Reasoning: r.Reasoning,
1040- SystemPrompt: r.SystemPrompt,
1041- Tools: r.ToolRegistry,
1042- MaxSteps: r.MaxSteps,
1043- CWD: r.CWD,
1044- StartupAgentName: args.AgentName,
1045- StartupContextPaths: instructionContextPaths(r.ContextFiles),
1046- StartupExtensionNames: startupExtensionNames(extMgr.All()),
1047- StartupExtensionErrors: startupExtensionErrors,
1048- StartupSkillNames: startupSkillNames(startupSkills),
1049- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1050- ZotHome: ZotHome(),
1051- SessionsRoot: agentSessionsRoot(ZotHome(), args),
1052- Version: version,
1053- UpdateInfoChan: updateCh,
1054- Sandbox: sharedSandbox,
1055- Agent: ag,
1056- InitialInput: args.Prompt,
1180+ Terminal: term,
1181+ Theme: theme,
1182+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1183+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1184+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1185+ JailByDefault: initialCfg.JailByDefault,
1186+ QuickModelShortcuts: quickModelShortcuts,
1187+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1188+ RespectGitignore: initialCfg.RespectGitignore,
1189+ CompactMode: initialCfg.CompactMode,
1190+ TUIInputStyle: initialCfg.TUIInputStyle,
1191+ TUIStatusPosition: initialCfg.TUIStatusPosition,
1192+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1193+ ThemeName: initialCfg.Theme,
1194+ FlatTools: initialCfg.FlatToolRender(),
1195+ CompactUser: initialCfg.CompactUserInput(),
1196+ ExtensionThemes: extMgr.ThemeOptions,
1197+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1198+ SettingsStore: configSettingsStore{},
1199+ Model: r.Model,
1200+ Provider: r.Provider,
1201+ AuthMethod: r.AuthMethod,
1202+ BaseURL: r.BaseURL,
1203+ Reasoning: r.Reasoning,
1204+ SystemPrompt: r.SystemPrompt,
1205+ Tools: r.ToolRegistry,
1206+ MaxSteps: r.MaxSteps,
1207+ CWD: r.CWD,
1208+ StartupAgentName: args.AgentName,
1209+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
1210+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
1211+ StartupExtensionErrors: startupExtensionErrors,
1212+ StartupSkillNames: startupSkillNames(startupSkills),
1213+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1214+ ZotHome: ZotHome(),
1215+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1216+ Version: version,
1217+ UpdateInfoChan: updateCh,
1218+ Sandbox: sharedSandbox,
1219+ Agent: ag,
1220+ InitialInput: args.Prompt,
1221+ StartupPre: args.StartupPre,
1222+ OnStartupPreDone: func() {
1223+ // entry.pre often installs skills/extensions; rediscover them
1224+ // before the user starts a model turn.
1225+ current := liveInteractiveAgent(iv, ag)
1226+ if extMgr != nil {
1227+ _ = extMgr.Reload(context.Background(), 2*time.Second)
1228+ }
1229+ if current != nil {
1230+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1231+ }
1232+ },
10571233 AuthManager: mgr,
10581234 LlamaCPPConfig: ResolveLlamaCPPConfig,
10591235 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
@@ -248,6 +248,8 @@ func runWithArgs(args Args, version string) error {
248 switch args.Mode {248 switch args.Mode {
249 case ModePrint:249 case ModePrint:
250 return runPrintMode(ctx, args, version)250 return runPrintMode(ctx, args, version)
251+ case ModeStream:
252+ return runStreamMode(ctx, args, version)
251 case ModeJSON:253 case ModeJSON:
252 return runJSONMode(ctx, args, version)254 return runJSONMode(ctx, args, version)
253 case ModeRPC:255 case ModeRPC:
@@ -354,11 +356,72 @@ func runPrintMode(ctx context.Context, args Args, version string) error {
354 }356 }
355 357
356 start := len(ag.Messages())358 start := len(ag.Messages())
359+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, nil, os.Stderr); err != nil {
360+ return err
361+ }
362+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
357 err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)363 err = modes.RunPrint(ctx, ag, prompt, nil, os.Stdout)
358 WriteNewTranscript(ag, sess, start)364 WriteNewTranscript(ag, sess, start)
359 return 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 := func(ev core.AgentEvent) {
395+ switch e := ev.(type) {
396+ case core.EvTextDelta:
397+ fmt.Fprint(os.Stdout, e.Delta)
398+ _ = os.Stdout.Sync()
399+ case core.EvAssistantMessage:
400+ // Fallback when the provider does not emit deltas.
401+ var sb strings.Builder
402+ for _, c := range e.Message.Content {
403+ if tb, ok := c.(provider.TextBlock); ok {
404+ if sb.Len() > 0 {
405+ sb.WriteString("\n")
406+ }
407+ sb.WriteString(tb.Text)
408+ }
409+ }
410+ if sb.Len() > 0 {
411+ fmt.Fprint(os.Stdout, sb.String())
412+ _ = os.Stdout.Sync()
413+ }
414+ }
415+ }
416+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
417+ return err
418+ }
419+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
420+ err = modes.RunStream(ctx, ag, prompt, nil, os.Stdout)
421+ WriteNewTranscript(ag, sess, start)
422+ return err
423+}
424+
362 func runJSONMode(ctx context.Context, args Args, version string) error {425 func runJSONMode(ctx context.Context, args Args, version string) error {
363 if args.NoYolo {426 if args.NoYolo {
364 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")427 fmt.Fprintln(os.Stderr, "warning: --no-yolo has no effect in json mode (no interactive prompt available); tools will run without confirmation")
@@ -385,11 +448,119 @@ func runJSONMode(ctx context.Context, args Args, version string) error {
385 }448 }
386 449
387 start := len(ag.Messages())450 start := len(ag.Messages())
451+ enc := json.NewEncoder(os.Stdout)
452+ preSink := func(ev core.AgentEvent) {
453+ _ = enc.Encode(modes.EventToJSON(ev))
454+ }
455+ if err := runZotfileStartupPre(ctx, args.StartupPre, r.CWD, r.Sandbox, ag, preSink, os.Stderr); err != nil {
456+ _ = enc.Encode(map[string]any{"type": "error", "message": err.Error()})
457+ return err
458+ }
459+ reloadResourcesAfterStartupPre(ctx, args, extMgr, r.Sandbox, ag)
388 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)460 err = modes.RunJSON(ctx, ag, prompt, nil, os.Stdout)
389 WriteNewTranscript(ag, sess, start)461 WriteNewTranscript(ag, sess, start)
390 return err462 return err
391 }463 }
392 464
465+// runZotfileStartupPre runs entry.pre before the main non-interactive prompt.
466+// "!command" values execute via BashTool; other values are sent as an agent turn.
467+// shellOut receives live shell chunks (typically os.Stderr); sink receives
468+// agent events for plain-text pre (stream mode wires a live text sink).
469+func runZotfileStartupPre(ctx context.Context, pre, cwd string, sandbox *tools.Sandbox, ag *core.Agent, sink func(core.AgentEvent), shellOut io.Writer) error {
470+ pre = strings.TrimSpace(pre)
471+ if pre == "" {
472+ return nil
473+ }
474+ if cmd, ok := modes.ShellEscapeCommand(pre); ok {
475+ raw, err := json.Marshal(map[string]any{"command": cmd})
476+ if err != nil {
477+ return err
478+ }
479+ if shellOut != nil {
480+ fmt.Fprintf(shellOut, "$ %s\n", cmd)
481+ if f, ok := shellOut.(interface{ Sync() error }); ok {
482+ _ = f.Sync()
483+ }
484+ }
485+ progress := func(chunk string) {
486+ if shellOut == nil || chunk == "" {
487+ return
488+ }
489+ fmt.Fprint(shellOut, chunk)
490+ if f, ok := shellOut.(interface{ Sync() error }); ok {
491+ _ = f.Sync()
492+ }
493+ }
494+ bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
495+ res, err := bash.Execute(ctx, raw, progress)
496+ if err != nil {
497+ return fmt.Errorf("zotfile entry.pre: %w", err)
498+ }
499+ if res.IsError {
500+ var sb strings.Builder
501+ for _, c := range res.Content {
502+ if tb, ok := c.(provider.TextBlock); ok {
503+ sb.WriteString(tb.Text)
504+ }
505+ }
506+ msg := strings.TrimSpace(sb.String())
507+ if msg == "" {
508+ msg = "command failed"
509+ }
510+ return fmt.Errorf("zotfile entry.pre: %s", msg)
511+ }
512+ return nil
513+ }
514+ if ag == nil {
515+ return fmt.Errorf("zotfile entry.pre requires an agent for non-shell prompts")
516+ }
517+ if sink == nil {
518+ sink = func(core.AgentEvent) {}
519+ }
520+ return ag.Prompt(ctx, pre, nil, sink)
521+}
522+
523+// refreshAgentToolsAndPrompt re-resolves tools (including rediscovered
524+// skills and currently loaded extension tools) and updates the live
525+// agent's registry and system prompt. Used after /reload-ext and after
526+// zotfile entry.pre installs new skills or extensions.
527+// mutateRegistry, if non-nil, can inject session-specific tools (e.g. swarm_spawn).
528+func refreshAgentToolsAndPrompt(args Args, sharedSandbox *tools.Sandbox, extToolAdapter ExtensionToolSource, ag *core.Agent, mutateRegistry func(core.Registry) core.Registry) {
529+ if ag == nil {
530+ return
531+ }
532+ resolved, err := Resolve(args, true)
533+ if err != nil {
534+ return
535+ }
536+ if sharedSandbox != nil {
537+ resolved.UseSandbox(sharedSandbox)
538+ }
539+ if extToolAdapter != nil {
540+ resolved.MergeExtensionTools(extToolAdapter)
541+ }
542+ reg := resolved.ToolRegistry
543+ if mutateRegistry != nil {
544+ reg = mutateRegistry(reg)
545+ }
546+ ag.SetTools(reg)
547+ ag.System = resolved.SystemPrompt
548+}
549+
550+// reloadResourcesAfterStartupPre reloads extensions (if any) and
551+// refreshes the agent tool registry + system prompt so skills/extensions
552+// installed by entry.pre are visible to the following turn.
553+func reloadResourcesAfterStartupPre(ctx context.Context, args Args, extMgr *extensions.Manager, sharedSandbox *tools.Sandbox, ag *core.Agent) {
554+ if strings.TrimSpace(args.StartupPre) == "" || ag == nil {
555+ return
556+ }
557+ adapter := &extToolAdapter{mgr: extMgr}
558+ if extMgr != nil {
559+ _ = extMgr.Reload(ctx, 2*time.Second)
560+ }
561+ refreshAgentToolsAndPrompt(args, sharedSandbox, adapter, ag, nil)
562+}
563+
393 // ---- interactive mode: opens the TUI even without credentials ----564 // ---- interactive mode: opens the TUI even without credentials ----
394 565
395 func runInteractive(ctx context.Context, args Args, version string) error {566 func runInteractive(ctx context.Context, args Args, version string) error {
@@ -653,18 +824,11 @@ func runInteractive(ctx context.Context, args Args, version string) error {
653 // /model swap since spawn, so re-read the live `ag` on each824 // /model swap since spawn, so re-read the live `ag` on each
654 // invocation.825 // invocation.
655 extMgr.SetOnReload(func() {826 extMgr.SetOnReload(func() {
656- current := ag827+ current := liveInteractiveAgent(iv, ag)
657 if current == nil {828 if current == nil {
658 return829 return
659 }830 }
660- resolved, err := Resolve(args, true)831+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
661- if err != nil {
662- return
663- }
664- resolved.UseSandbox(sharedSandbox)
665- resolved.MergeExtensionTools(extToolAdapter)
666- injectSwarmSpawn(resolved.ToolRegistry)
667- current.SetTools(resolved.ToolRegistry)
668 })832 })
669 833
670 // Fire session_start once we know the manager's running.834 // Fire session_start once we know the manager's running.
@@ -1013,47 +1177,59 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1013 }1177 }
1014 1178
1015 iv = modes.NewInteractive(modes.InteractiveConfig{1179 iv = modes.NewInteractive(modes.InteractiveConfig{
1016- Terminal: term,1180+ Terminal: term,
1017- Theme: theme,1181+ Theme: theme,
1018- InlineImagesEnabled: initialCfg.InlineImagesEnabled,1182+ InlineImagesEnabled: initialCfg.InlineImagesEnabled,
1019- AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,1183+ AutoSwarmEnabled: initialCfg.AutoSwarmEnabled,
1020- AutoCompactThreshold: initialCfg.AutoCompactThreshold,1184+ AutoCompactThreshold: initialCfg.AutoCompactThreshold,
1021- JailByDefault: initialCfg.JailByDefault,1185+ JailByDefault: initialCfg.JailByDefault,
1022- QuickModelShortcuts: quickModelShortcuts,1186+ QuickModelShortcuts: quickModelShortcuts,
1023- RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,1187+ RecursiveFileSuggest: initialCfg.RecursiveFileSuggest,
1024- RespectGitignore: initialCfg.RespectGitignore,1188+ RespectGitignore: initialCfg.RespectGitignore,
1025- CompactMode: initialCfg.CompactMode,1189+ CompactMode: initialCfg.CompactMode,
1026- TUIInputStyle: initialCfg.TUIInputStyle,1190+ TUIInputStyle: initialCfg.TUIInputStyle,
1027- TUIStatusPosition: initialCfg.TUIStatusPosition,1191+ TUIStatusPosition: initialCfg.TUIStatusPosition,
1028- TUIWorkingPosition: initialCfg.TUIWorkingPosition,1192+ TUIWorkingPosition: initialCfg.TUIWorkingPosition,
1029- ThemeName: initialCfg.Theme,1193+ ThemeName: initialCfg.Theme,
1030- FlatTools: initialCfg.FlatToolRender(),1194+ FlatTools: initialCfg.FlatToolRender(),
1031- CompactUser: initialCfg.CompactUserInput(),1195+ CompactUser: initialCfg.CompactUserInput(),
1032- ExtensionThemes: extMgr.ThemeOptions,1196+ ExtensionThemes: extMgr.ThemeOptions,
1033- AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,1197+ AutoSwarmSystemAddendum: AutoSwarmSystemAddendum,
1034- SettingsStore: configSettingsStore{},1198+ SettingsStore: configSettingsStore{},
1035- Model: r.Model,1199+ Model: r.Model,
1036- Provider: r.Provider,1200+ Provider: r.Provider,
1037- AuthMethod: r.AuthMethod,1201+ AuthMethod: r.AuthMethod,
1038- BaseURL: r.BaseURL,1202+ BaseURL: r.BaseURL,
1039- Reasoning: r.Reasoning,1203+ Reasoning: r.Reasoning,
1040- SystemPrompt: r.SystemPrompt,1204+ SystemPrompt: r.SystemPrompt,
1041- Tools: r.ToolRegistry,1205+ Tools: r.ToolRegistry,
1042- MaxSteps: r.MaxSteps,1206+ MaxSteps: r.MaxSteps,
1043- CWD: r.CWD,1207+ CWD: r.CWD,
1044- StartupAgentName: args.AgentName,1208+ StartupAgentName: args.AgentName,
1045- StartupContextPaths: instructionContextPaths(r.ContextFiles),1209+ StartupContextPaths: instructionContextPaths(r.ContextFiles),
1046- StartupExtensionNames: startupExtensionNames(extMgr.All()),1210+ StartupExtensionNames: startupExtensionNames(extMgr.All()),
1047- StartupExtensionErrors: startupExtensionErrors,1211+ StartupExtensionErrors: startupExtensionErrors,
1048- StartupSkillNames: startupSkillNames(startupSkills),1212+ StartupSkillNames: startupSkillNames(startupSkills),
1049- ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,1213+ ShowInstructionsAtStartup: initialCfg.ShowInstructionsAtStartup,
1050- ZotHome: ZotHome(),1214+ ZotHome: ZotHome(),
1051- SessionsRoot: agentSessionsRoot(ZotHome(), args),1215+ SessionsRoot: agentSessionsRoot(ZotHome(), args),
1052- Version: version,1216+ Version: version,
1053- UpdateInfoChan: updateCh,1217+ UpdateInfoChan: updateCh,
1054- Sandbox: sharedSandbox,1218+ Sandbox: sharedSandbox,
1055- Agent: ag,1219+ Agent: ag,
1056- InitialInput: args.Prompt,1220+ InitialInput: args.Prompt,
1221+ StartupPre: args.StartupPre,
1222+ OnStartupPreDone: func() {
1223+ // entry.pre often installs skills/extensions; rediscover them
1224+ // before the user starts a model turn.
1225+ current := liveInteractiveAgent(iv, ag)
1226+ if extMgr != nil {
1227+ _ = extMgr.Reload(context.Background(), 2*time.Second)
1228+ }
1229+ if current != nil {
1230+ refreshAgentToolsAndPrompt(args, sharedSandbox, extToolAdapter, current, injectSwarmSpawn)
1231+ }
1232+ },
1057 AuthManager: mgr,1233 AuthManager: mgr,
1058 LlamaCPPConfig: ResolveLlamaCPPConfig,1234 LlamaCPPConfig: ResolveLlamaCPPConfig,
1059 RefreshLlamaCPPModels: RefreshLlamaCPPModels,1235 RefreshLlamaCPPModels: RefreshLlamaCPPModels,
M packages/agent/modes/interactive.go +109 -4
@@ -131,6 +131,21 @@ type InteractiveConfig struct {
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
@@ -522,6 +537,15 @@ type Interactive struct {
522537 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
523538 // escape can start while one is in flight.
524539 shellRunning bool
540+ // shellLive is the accumulating stdout/stderr of the in-flight
541+ // shell escape, updated via BashTool progress for live rendering.
542+ shellLive string
543+
544+ // awaitingStartupPre is true while the zotfile entry.pre auto-submit
545+ // is in flight. When it clears, deferredInitialInput is applied.
546+ awaitingStartupPre bool
547+ deferredInitialInput string
548+ autoSubmitDeferred bool
525549
526550 // sessionLoading is true while a /sessions selection is being read
527551 // on a background goroutine. Keeping this off the input goroutine
@@ -703,7 +727,14 @@ func (i *Interactive) Run(ctx context.Context) error {
703727 i.redraw()
704728 })
705729
706- if i.cfg.InitialInput != "" {
730+ if i.cfg.StartupPre != "" {
731+ i.deferredInitialInput = i.cfg.InitialInput
732+ i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
733+ i.awaitingStartupPre = true
734+ i.Submit(i.cfg.StartupPre)
735+ } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
736+ i.Submit(i.cfg.InitialInput)
737+ } else if i.cfg.InitialInput != "" {
707738 i.ed.SetValue(i.cfg.InitialInput)
708739 }
709740
@@ -1062,6 +1093,16 @@ func (i *Interactive) buildChatLocked(cols int) []string {
10621093 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
10631094 }
10641095
1096+ // Live shell-escape output (!command / entry.pre) streams into the
1097+ // transcript area while the command runs, then is replaced by the
1098+ // final user-context message when it finishes.
1099+ if i.shellRunning && i.shellLive != "" {
1100+ for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") {
1101+ chat = append(chat, line)
1102+ }
1103+ chat = append(chat, "")
1104+ }
1105+
10651106 // Extension notes (notify / display) live just under the
10661107 // transcript, above the dialog/editor band. Cleared by /clear.
10671108 if len(i.extNotes) > 0 {
@@ -2942,6 +2983,36 @@ func (i *Interactive) Submit(text string) {
29422983 i.startTurn(i.runCtx, text)
29432984 }
29442985
2986+// completeStartupPre applies deferred InitialInput after entry.pre finishes.
2987+// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
2988+// it only pre-fills the editor (CLI-supplied prompts).
2989+func (i *Interactive) completeStartupPre() {
2990+ i.mu.Lock()
2991+ if !i.awaitingStartupPre {
2992+ i.mu.Unlock()
2993+ return
2994+ }
2995+ i.awaitingStartupPre = false
2996+ deferred := i.deferredInitialInput
2997+ auto := i.autoSubmitDeferred
2998+ i.deferredInitialInput = ""
2999+ i.autoSubmitDeferred = false
3000+ onDone := i.cfg.OnStartupPreDone
3001+ i.mu.Unlock()
3002+ if onDone != nil {
3003+ onDone()
3004+ }
3005+ if deferred == "" {
3006+ return
3007+ }
3008+ if auto {
3009+ i.Submit(deferred)
3010+ return
3011+ }
3012+ i.ed.SetValue(deferred)
3013+ i.invalidate()
3014+}
3015+
29453016 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
29463017 // not provide startup context metadata.
29473018 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -5366,6 +5437,15 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
53665437 // treated as not an escape so it falls through to the normal prompt
53675438 // path rather than running an empty shell.
53685439 func shellEscapeCommand(text string) (string, bool) {
5440+ return ShellEscapeCommand(text)
5441+}
5442+
5443+// ShellEscapeCommand reports whether text is a "!command" shell
5444+// escape and, if so, returns the command with the leading '!' (and
5445+// surrounding whitespace) stripped. A bare "!" with no command is
5446+// treated as not an escape so it falls through to the normal prompt
5447+// path rather than running an empty shell.
5448+func ShellEscapeCommand(text string) (string, bool) {
53695449 trimmed := strings.TrimLeft(text, " \t")
53705450 if !strings.HasPrefix(trimmed, "!") {
53715451 return "", false
@@ -5401,6 +5481,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54015481 ctx, cancel := context.WithCancel(parent)
54025482 i.busy = true
54035483 i.shellRunning = true
5484+ i.shellLive = "$ " + cmd + "\n\n"
54045485 i.cancelTurn = cancel
54055486 i.statusErr = ""
54065487 i.statusOK = ""
@@ -5421,7 +5502,13 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54215502 defer cancel()
54225503 raw, _ := json.Marshal(map[string]any{"command": cmd})
54235504 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5424- res, err := bash.Execute(ctx, raw, nil)
5505+ progress := func(chunk string) {
5506+ i.mu.Lock()
5507+ i.shellLive += chunk
5508+ i.mu.Unlock()
5509+ i.invalidate()
5510+ }
5511+ res, err := bash.Execute(ctx, raw, progress)
54255512
54265513 var out string
54275514 if err != nil {
@@ -5443,8 +5530,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54435530
54445531 i.mu.Lock()
54455532 i.shellRunning = false
5533+ i.shellLive = ""
54465534 i.busy = false
54475535 i.cancelTurn = nil
5536+ awaitingPre := i.awaitingStartupPre
54485537 if failed {
54495538 if cancelled {
54505539 i.statusErr = "shell command cancelled"
@@ -5458,6 +5547,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
54585547 }
54595548 i.mu.Unlock()
54605549 i.invalidate()
5550+ if awaitingPre {
5551+ i.completeStartupPre()
5552+ }
54615553 }()
54625554 }
54635555
@@ -5471,6 +5563,14 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
54715563
54725564 func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
54735565 if i.agent == nil {
5566+ // Text startup pre cannot run without credentials; continue so
5567+ // deferred InitialInput (pre-fill or auto-submit) still applies.
5568+ i.mu.Lock()
5569+ awaitingPre := i.awaitingStartupPre
5570+ i.mu.Unlock()
5571+ if awaitingPre {
5572+ i.completeStartupPre()
5573+ }
54745574 return
54755575 }
54765576 // Pre-turn safety: if the most recent context measurement is
@@ -5612,10 +5712,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
56125712 flush()
56135713 }
56145714 i.mu.Lock()
5715+ awaitingPre := i.awaitingStartupPre
56155716 // Pop the next queued message, if any, and relaunch.
56165717 var next string
56175718 var hasNext bool
5618- if len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5719+ if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
56195720 next, i.queued = i.queued[0], i.queued[1:]
56205721 hasNext = true
56215722 }
@@ -5635,13 +5736,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
56355736 if i.agent != nil {
56365737 agentQueued = i.agent.QueuedMessageCount()
56375738 }
5638- shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5739+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
56395740 i.mu.Unlock()
56405741 i.invalidate()
56415742 parent := i.runCtx
56425743 if parent == nil {
56435744 parent = context.Background()
56445745 }
5746+ if awaitingPre {
5747+ i.completeStartupPre()
5748+ return
5749+ }
56455750 switch {
56465751 case hasNext:
56475752 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
@@ -522,6 +537,15 @@ type Interactive struct {
522 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell537 // i.busy/i.cancelTurn so esc cancels it and no turn or other shell
523 // escape can start while one is in flight.538 // escape can start while one is in flight.
524 shellRunning bool539 shellRunning bool
540+ // shellLive is the accumulating stdout/stderr of the in-flight
541+ // shell escape, updated via BashTool progress for live rendering.
542+ shellLive string
543+
544+ // awaitingStartupPre is true while the zotfile entry.pre auto-submit
545+ // is in flight. When it clears, deferredInitialInput is applied.
546+ awaitingStartupPre bool
547+ deferredInitialInput string
548+ autoSubmitDeferred bool
525 549
526 // sessionLoading is true while a /sessions selection is being read550 // sessionLoading is true while a /sessions selection is being read
527 // on a background goroutine. Keeping this off the input goroutine551 // on a background goroutine. Keeping this off the input goroutine
@@ -703,7 +727,14 @@ func (i *Interactive) Run(ctx context.Context) error {
703 i.redraw()727 i.redraw()
704 })728 })
705 729
706- if i.cfg.InitialInput != "" {730+ if i.cfg.StartupPre != "" {
731+ i.deferredInitialInput = i.cfg.InitialInput
732+ i.autoSubmitDeferred = i.cfg.AutoSubmitInitial
733+ i.awaitingStartupPre = true
734+ i.Submit(i.cfg.StartupPre)
735+ } else if i.cfg.AutoSubmitInitial && i.cfg.InitialInput != "" {
736+ i.Submit(i.cfg.InitialInput)
737+ } else if i.cfg.InitialInput != "" {
707 i.ed.SetValue(i.cfg.InitialInput)738 i.ed.SetValue(i.cfg.InitialInput)
708 }739 }
709 740
@@ -1062,6 +1093,16 @@ func (i *Interactive) buildChatLocked(cols int) []string {
1062 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")1093 chat = append(chat, i.cfg.Theme.FG256(i.cfg.Theme.Tool, line), "")
1063 }1094 }
1064 1095
1096+ // Live shell-escape output (!command / entry.pre) streams into the
1097+ // transcript area while the command runs, then is replaced by the
1098+ // final user-context message when it finishes.
1099+ if i.shellRunning && i.shellLive != "" {
1100+ for _, line := range strings.Split(strings.TrimRight(i.shellLive, "\n"), "\n") {
1101+ chat = append(chat, line)
1102+ }
1103+ chat = append(chat, "")
1104+ }
1105+
1065 // Extension notes (notify / display) live just under the1106 // Extension notes (notify / display) live just under the
1066 // transcript, above the dialog/editor band. Cleared by /clear.1107 // transcript, above the dialog/editor band. Cleared by /clear.
1067 if len(i.extNotes) > 0 {1108 if len(i.extNotes) > 0 {
@@ -2942,6 +2983,36 @@ func (i *Interactive) Submit(text string) {
2942 i.startTurn(i.runCtx, text)2983 i.startTurn(i.runCtx, text)
2943 }2984 }
2944 2985
2986+// completeStartupPre applies deferred InitialInput after entry.pre finishes.
2987+// When AutoSubmitInitial was set, the deferred prompt is submitted; otherwise
2988+// it only pre-fills the editor (CLI-supplied prompts).
2989+func (i *Interactive) completeStartupPre() {
2990+ i.mu.Lock()
2991+ if !i.awaitingStartupPre {
2992+ i.mu.Unlock()
2993+ return
2994+ }
2995+ i.awaitingStartupPre = false
2996+ deferred := i.deferredInitialInput
2997+ auto := i.autoSubmitDeferred
2998+ i.deferredInitialInput = ""
2999+ i.autoSubmitDeferred = false
3000+ onDone := i.cfg.OnStartupPreDone
3001+ i.mu.Unlock()
3002+ if onDone != nil {
3003+ onDone()
3004+ }
3005+ if deferred == "" {
3006+ return
3007+ }
3008+ if auto {
3009+ i.Submit(deferred)
3010+ return
3011+ }
3012+ i.ed.SetValue(deferred)
3013+ i.invalidate()
3014+}
3015+
2945 // ApplyChangedCWD is called by hosts after a successful /cd hook that do3016 // ApplyChangedCWD is called by hosts after a successful /cd hook that do
2946 // not provide startup context metadata.3017 // not provide startup context metadata.
2947 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {3018 func (i *Interactive) ApplyChangedCWD(ag *core.Agent, provider, model, cwd string) {
@@ -5366,6 +5437,15 @@ func (i *Interactive) runCompact(parent context.Context, auto bool) {
5366 // treated as not an escape so it falls through to the normal prompt5437 // treated as not an escape so it falls through to the normal prompt
5367 // path rather than running an empty shell.5438 // path rather than running an empty shell.
5368 func shellEscapeCommand(text string) (string, bool) {5439 func shellEscapeCommand(text string) (string, bool) {
5440+ return ShellEscapeCommand(text)
5441+}
5442+
5443+// ShellEscapeCommand reports whether text is a "!command" shell
5444+// escape and, if so, returns the command with the leading '!' (and
5445+// surrounding whitespace) stripped. A bare "!" with no command is
5446+// treated as not an escape so it falls through to the normal prompt
5447+// path rather than running an empty shell.
5448+func ShellEscapeCommand(text string) (string, bool) {
5369 trimmed := strings.TrimLeft(text, " \t")5449 trimmed := strings.TrimLeft(text, " \t")
5370 if !strings.HasPrefix(trimmed, "!") {5450 if !strings.HasPrefix(trimmed, "!") {
5371 return "", false5451 return "", false
@@ -5401,6 +5481,7 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5401 ctx, cancel := context.WithCancel(parent)5481 ctx, cancel := context.WithCancel(parent)
5402 i.busy = true5482 i.busy = true
5403 i.shellRunning = true5483 i.shellRunning = true
5484+ i.shellLive = "$ " + cmd + "\n\n"
5404 i.cancelTurn = cancel5485 i.cancelTurn = cancel
5405 i.statusErr = ""5486 i.statusErr = ""
5406 i.statusOK = ""5487 i.statusOK = ""
@@ -5421,7 +5502,13 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5421 defer cancel()5502 defer cancel()
5422 raw, _ := json.Marshal(map[string]any{"command": cmd})5503 raw, _ := json.Marshal(map[string]any{"command": cmd})
5423 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}5504 bash := &tools.BashTool{CWD: cwd, Sandbox: sandbox}
5424- res, err := bash.Execute(ctx, raw, nil)5505+ progress := func(chunk string) {
5506+ i.mu.Lock()
5507+ i.shellLive += chunk
5508+ i.mu.Unlock()
5509+ i.invalidate()
5510+ }
5511+ res, err := bash.Execute(ctx, raw, progress)
5425 5512
5426 var out string5513 var out string
5427 if err != nil {5514 if err != nil {
@@ -5443,8 +5530,10 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5443 5530
5444 i.mu.Lock()5531 i.mu.Lock()
5445 i.shellRunning = false5532 i.shellRunning = false
5533+ i.shellLive = ""
5446 i.busy = false5534 i.busy = false
5447 i.cancelTurn = nil5535 i.cancelTurn = nil
5536+ awaitingPre := i.awaitingStartupPre
5448 if failed {5537 if failed {
5449 if cancelled {5538 if cancelled {
5450 i.statusErr = "shell command cancelled"5539 i.statusErr = "shell command cancelled"
@@ -5458,6 +5547,9 @@ func (i *Interactive) startShellEscape(parent context.Context, cmd string) {
5458 }5547 }
5459 i.mu.Unlock()5548 i.mu.Unlock()
5460 i.invalidate()5549 i.invalidate()
5550+ if awaitingPre {
5551+ i.completeStartupPre()
5552+ }
5461 }()5553 }()
5462 }5554 }
5463 5555
@@ -5471,6 +5563,14 @@ func (i *Interactive) startTurnWithImages(parent context.Context, prompt string,
5471 5563
5472 func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {5564 func (i *Interactive) startTurnRequest(parent context.Context, prompt string, images []provider.ImageBlock, overflowRecoveryAttempted bool) {
5473 if i.agent == nil {5565 if i.agent == nil {
5566+ // Text startup pre cannot run without credentials; continue so
5567+ // deferred InitialInput (pre-fill or auto-submit) still applies.
5568+ i.mu.Lock()
5569+ awaitingPre := i.awaitingStartupPre
5570+ i.mu.Unlock()
5571+ if awaitingPre {
5572+ i.completeStartupPre()
5573+ }
5474 return5574 return
5475 }5575 }
5476 // Pre-turn safety: if the most recent context measurement is5576 // Pre-turn safety: if the most recent context measurement is
@@ -5612,10 +5712,11 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5612 flush()5712 flush()
5613 }5713 }
5614 i.mu.Lock()5714 i.mu.Lock()
5715+ awaitingPre := i.awaitingStartupPre
5615 // Pop the next queued message, if any, and relaunch.5716 // Pop the next queued message, if any, and relaunch.
5616 var next string5717 var next string
5617 var hasNext bool5718 var hasNext bool
5618- if len(i.queued) > 0 && ctx.Err() == nil && err == nil {5719+ if !awaitingPre && len(i.queued) > 0 && ctx.Err() == nil && err == nil {
5619 next, i.queued = i.queued[0], i.queued[1:]5720 next, i.queued = i.queued[0], i.queued[1:]
5620 hasNext = true5721 hasNext = true
5621 }5722 }
@@ -5635,13 +5736,17 @@ func (i *Interactive) startTurnRequest(parent context.Context, prompt string, im
5635 if i.agent != nil {5736 if i.agent != nil {
5636 agentQueued = i.agent.QueuedMessageCount()5737 agentQueued = i.agent.QueuedMessageCount()
5637 }5738 }
5638- shouldAutoCompact := !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()5739+ shouldAutoCompact := !awaitingPre && !hasNext && agentQueued == 0 && err == nil && ctx.Err() == nil && i.shouldAutoCompactLocked()
5639 i.mu.Unlock()5740 i.mu.Unlock()
5640 i.invalidate()5741 i.invalidate()
5641 parent := i.runCtx5742 parent := i.runCtx
5642 if parent == nil {5743 if parent == nil {
5643 parent = context.Background()5744 parent = context.Background()
5644 }5745 }
5746+ if awaitingPre {
5747+ i.completeStartupPre()
5748+ return
5749+ }
5645 switch {5750 switch {
5646 case hasNext:5751 case hasNext:
5647 i.startTurn(parent, next)5752 i.startTurn(parent, next)
A packages/agent/modes/startup_pre_test.go +107 -0
@@ -0,0 +1,107 @@
1+package modes
2+
3+import (
4+ "context"
5+ "runtime"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+)
12+
13+func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
14+ i := NewInteractive(InteractiveConfig{})
15+ i.awaitingStartupPre = true
16+ i.deferredInitialInput = "Bom dia!"
17+ i.autoSubmitDeferred = false
18+ i.completeStartupPre()
19+ if got := i.ed.Value(); got != "Bom dia!" {
20+ t.Fatalf("editor = %q, want Bom dia!", got)
21+ }
22+ i.mu.Lock()
23+ awaiting := i.awaitingStartupPre
24+ i.mu.Unlock()
25+ if awaiting {
26+ t.Fatal("awaitingStartupPre still set after completeStartupPre")
27+ }
28+}
29+
30+func TestCompleteStartupPreCallsOnDone(t *testing.T) {
31+ called := false
32+ i := NewInteractive(InteractiveConfig{
33+ OnStartupPreDone: func() { called = true },
34+ })
35+ i.awaitingStartupPre = true
36+ i.completeStartupPre()
37+ if !called {
38+ t.Fatal("OnStartupPreDone was not called")
39+ }
40+}
41+
42+func TestStartupPreShellThenPrefill(t *testing.T) {
43+ agent := core.NewAgent(nil, "", "", nil)
44+ i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
45+ i.runCtx = context.Background()
46+ i.awaitingStartupPre = true
47+ i.deferredInitialInput = "after-pre"
48+ i.autoSubmitDeferred = false
49+
50+ cmd := "printf zot-pre"
51+ if runtime.GOOS == "windows" {
52+ cmd = "echo zot-pre"
53+ }
54+ i.startShellEscape(context.Background(), cmd)
55+
56+ deadline := time.Now().Add(5 * time.Second)
57+ for time.Now().Before(deadline) {
58+ i.mu.Lock()
59+ running := i.shellRunning
60+ awaiting := i.awaitingStartupPre
61+ i.mu.Unlock()
62+ if !running && !awaiting {
63+ if got := i.ed.Value(); got != "after-pre" {
64+ t.Fatalf("editor = %q, want after-pre", got)
65+ }
66+ return
67+ }
68+ time.Sleep(10 * time.Millisecond)
69+ }
70+ t.Fatal("startup pre did not complete and prefill the editor")
71+}
72+
73+func TestStartupPreShellThenAutoSubmitShell(t *testing.T) {
74+ agent := core.NewAgent(nil, "", "", nil)
75+ i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
76+ i.runCtx = context.Background()
77+ i.awaitingStartupPre = true
78+ i.deferredInitialInput = "!printf zot-default"
79+ if runtime.GOOS == "windows" {
80+ i.deferredInitialInput = "!echo zot-default"
81+ }
82+ i.autoSubmitDeferred = true
83+
84+ cmd := "printf zot-pre"
85+ if runtime.GOOS == "windows" {
86+ cmd = "echo zot-pre"
87+ }
88+ i.startShellEscape(context.Background(), cmd)
89+
90+ deadline := time.Now().Add(5 * time.Second)
91+ for time.Now().Before(deadline) {
92+ messages := agent.Messages()
93+ i.mu.Lock()
94+ running := i.shellRunning || i.busy
95+ awaiting := i.awaitingStartupPre
96+ i.mu.Unlock()
97+ if len(messages) >= 2 && !running && !awaiting {
98+ joined := userMessageText(messages[0]) + "\n" + userMessageText(messages[1])
99+ if !strings.Contains(joined, "zot-pre") || !strings.Contains(joined, "zot-default") {
100+ t.Fatalf("context = %q, want both pre and default shell output", joined)
101+ }
102+ return
103+ }
104+ time.Sleep(10 * time.Millisecond)
105+ }
106+ t.Fatal("startup pre did not auto-submit the deferred prompt")
107+}
@@ -0,0 +1,107 @@
1+package modes
2+
3+import (
4+ "context"
5+ "runtime"
6+ "strings"
7+ "testing"
8+ "time"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+)
12+
13+func TestCompleteStartupPrePrefillsEditor(t *testing.T) {
14+ i := NewInteractive(InteractiveConfig{})
15+ i.awaitingStartupPre = true
16+ i.deferredInitialInput = "Bom dia!"
17+ i.autoSubmitDeferred = false
18+ i.completeStartupPre()
19+ if got := i.ed.Value(); got != "Bom dia!" {
20+ t.Fatalf("editor = %q, want Bom dia!", got)
21+ }
22+ i.mu.Lock()
23+ awaiting := i.awaitingStartupPre
24+ i.mu.Unlock()
25+ if awaiting {
26+ t.Fatal("awaitingStartupPre still set after completeStartupPre")
27+ }
28+}
29+
30+func TestCompleteStartupPreCallsOnDone(t *testing.T) {
31+ called := false
32+ i := NewInteractive(InteractiveConfig{
33+ OnStartupPreDone: func() { called = true },
34+ })
35+ i.awaitingStartupPre = true
36+ i.completeStartupPre()
37+ if !called {
38+ t.Fatal("OnStartupPreDone was not called")
39+ }
40+}
41+
42+func TestStartupPreShellThenPrefill(t *testing.T) {
43+ agent := core.NewAgent(nil, "", "", nil)
44+ i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
45+ i.runCtx = context.Background()
46+ i.awaitingStartupPre = true
47+ i.deferredInitialInput = "after-pre"
48+ i.autoSubmitDeferred = false
49+
50+ cmd := "printf zot-pre"
51+ if runtime.GOOS == "windows" {
52+ cmd = "echo zot-pre"
53+ }
54+ i.startShellEscape(context.Background(), cmd)
55+
56+ deadline := time.Now().Add(5 * time.Second)
57+ for time.Now().Before(deadline) {
58+ i.mu.Lock()
59+ running := i.shellRunning
60+ awaiting := i.awaitingStartupPre
61+ i.mu.Unlock()
62+ if !running && !awaiting {
63+ if got := i.ed.Value(); got != "after-pre" {
64+ t.Fatalf("editor = %q, want after-pre", got)
65+ }
66+ return
67+ }
68+ time.Sleep(10 * time.Millisecond)
69+ }
70+ t.Fatal("startup pre did not complete and prefill the editor")
71+}
72+
73+func TestStartupPreShellThenAutoSubmitShell(t *testing.T) {
74+ agent := core.NewAgent(nil, "", "", nil)
75+ i := NewInteractive(InteractiveConfig{Agent: agent, CWD: t.TempDir()})
76+ i.runCtx = context.Background()
77+ i.awaitingStartupPre = true
78+ i.deferredInitialInput = "!printf zot-default"
79+ if runtime.GOOS == "windows" {
80+ i.deferredInitialInput = "!echo zot-default"
81+ }
82+ i.autoSubmitDeferred = true
83+
84+ cmd := "printf zot-pre"
85+ if runtime.GOOS == "windows" {
86+ cmd = "echo zot-pre"
87+ }
88+ i.startShellEscape(context.Background(), cmd)
89+
90+ deadline := time.Now().Add(5 * time.Second)
91+ for time.Now().Before(deadline) {
92+ messages := agent.Messages()
93+ i.mu.Lock()
94+ running := i.shellRunning || i.busy
95+ awaiting := i.awaitingStartupPre
96+ i.mu.Unlock()
97+ if len(messages) >= 2 && !running && !awaiting {
98+ joined := userMessageText(messages[0]) + "\n" + userMessageText(messages[1])
99+ if !strings.Contains(joined, "zot-pre") || !strings.Contains(joined, "zot-default") {
100+ t.Fatalf("context = %q, want both pre and default shell output", joined)
101+ }
102+ return
103+ }
104+ time.Sleep(10 * time.Millisecond)
105+ }
106+ t.Fatal("startup pre did not auto-submit the deferred prompt")
107+}
A packages/agent/modes/stream.go +115 -0
@@ -0,0 +1,115 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "strings"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+// RunStream runs the agent and writes assistant text to out as it
15+// arrives (EvTextDelta). Tool activity is written to stderr so stdout
16+// stays the live assistant transcript. When a provider does not emit
17+// deltas, the full assistant text is written when the message completes.
18+func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {
19+ return RunStreamWithDiag(ctx, ag, prompt, images, out, os.Stderr)
20+}
21+
22+// RunStreamWithDiag is RunStream with an explicit diagnostics writer
23+// for tool activity (tests pass a buffer).
24+func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error {
25+ if diag == nil {
26+ diag = io.Discard
27+ }
28+ var (
29+ runErr error
30+ streamed bool
31+ wroteText bool
32+ lastWasNL bool
33+ )
34+ flush := func() {
35+ if f, ok := out.(interface{ Sync() error }); ok {
36+ _ = f.Sync()
37+ }
38+ }
39+ writeText := func(s string) {
40+ if s == "" {
41+ return
42+ }
43+ _, _ = fmt.Fprint(out, s)
44+ flush()
45+ wroteText = true
46+ lastWasNL = strings.HasSuffix(s, "\n")
47+ }
48+ assistantText := func(m provider.Message) string {
49+ var sb strings.Builder
50+ for _, c := range m.Content {
51+ if tb, ok := c.(provider.TextBlock); ok {
52+ if sb.Len() > 0 {
53+ sb.WriteString("\n")
54+ }
55+ sb.WriteString(tb.Text)
56+ }
57+ }
58+ return sb.String()
59+ }
60+
61+ sink := func(ev core.AgentEvent) {
62+ switch e := ev.(type) {
63+ case core.EvAssistantStart:
64+ streamed = false
65+ case core.EvTextDelta:
66+ streamed = true
67+ writeText(e.Delta)
68+ case core.EvAssistantMessage:
69+ if !streamed {
70+ writeText(assistantText(e.Message))
71+ }
72+ case core.EvToolCall:
73+ fmt.Fprintf(diag, "\n[tool] %s\n", e.Name)
74+ if len(e.Args) > 0 {
75+ fmt.Fprintf(diag, "%s\n", string(e.Args))
76+ }
77+ case core.EvToolProgress:
78+ if e.Text != "" {
79+ fmt.Fprint(diag, e.Text)
80+ }
81+ case core.EvToolResult:
82+ for _, c := range e.Result.Content {
83+ if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
84+ fmt.Fprint(diag, tb.Text)
85+ if !strings.HasSuffix(tb.Text, "\n") {
86+ fmt.Fprint(diag, "\n")
87+ }
88+ }
89+ }
90+ if e.Result.IsError {
91+ fmt.Fprintln(diag, "[tool error]")
92+ }
93+ case core.EvTurnEnd:
94+ if e.Err != nil {
95+ runErr = e.Err
96+ }
97+ case core.EvError:
98+ if e.Err != nil {
99+ runErr = e.Err
100+ }
101+ }
102+ }
103+
104+ if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
105+ return err
106+ }
107+ if runErr != nil {
108+ return runErr
109+ }
110+ if wroteText && !lastWasNL {
111+ _, _ = fmt.Fprint(out, "\n")
112+ flush()
113+ }
114+ return nil
115+}
@@ -0,0 +1,115 @@
1+package modes
2+
3+import (
4+ "context"
5+ "fmt"
6+ "io"
7+ "os"
8+ "strings"
9+
10+ "github.com/patriceckhart/zot/packages/core"
11+ "github.com/patriceckhart/zot/packages/provider"
12+)
13+
14+// RunStream runs the agent and writes assistant text to out as it
15+// arrives (EvTextDelta). Tool activity is written to stderr so stdout
16+// stays the live assistant transcript. When a provider does not emit
17+// deltas, the full assistant text is written when the message completes.
18+func RunStream(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out io.Writer) error {
19+ return RunStreamWithDiag(ctx, ag, prompt, images, out, os.Stderr)
20+}
21+
22+// RunStreamWithDiag is RunStream with an explicit diagnostics writer
23+// for tool activity (tests pass a buffer).
24+func RunStreamWithDiag(ctx context.Context, ag *core.Agent, prompt string, images []provider.ImageBlock, out, diag io.Writer) error {
25+ if diag == nil {
26+ diag = io.Discard
27+ }
28+ var (
29+ runErr error
30+ streamed bool
31+ wroteText bool
32+ lastWasNL bool
33+ )
34+ flush := func() {
35+ if f, ok := out.(interface{ Sync() error }); ok {
36+ _ = f.Sync()
37+ }
38+ }
39+ writeText := func(s string) {
40+ if s == "" {
41+ return
42+ }
43+ _, _ = fmt.Fprint(out, s)
44+ flush()
45+ wroteText = true
46+ lastWasNL = strings.HasSuffix(s, "\n")
47+ }
48+ assistantText := func(m provider.Message) string {
49+ var sb strings.Builder
50+ for _, c := range m.Content {
51+ if tb, ok := c.(provider.TextBlock); ok {
52+ if sb.Len() > 0 {
53+ sb.WriteString("\n")
54+ }
55+ sb.WriteString(tb.Text)
56+ }
57+ }
58+ return sb.String()
59+ }
60+
61+ sink := func(ev core.AgentEvent) {
62+ switch e := ev.(type) {
63+ case core.EvAssistantStart:
64+ streamed = false
65+ case core.EvTextDelta:
66+ streamed = true
67+ writeText(e.Delta)
68+ case core.EvAssistantMessage:
69+ if !streamed {
70+ writeText(assistantText(e.Message))
71+ }
72+ case core.EvToolCall:
73+ fmt.Fprintf(diag, "\n[tool] %s\n", e.Name)
74+ if len(e.Args) > 0 {
75+ fmt.Fprintf(diag, "%s\n", string(e.Args))
76+ }
77+ case core.EvToolProgress:
78+ if e.Text != "" {
79+ fmt.Fprint(diag, e.Text)
80+ }
81+ case core.EvToolResult:
82+ for _, c := range e.Result.Content {
83+ if tb, ok := c.(provider.TextBlock); ok && tb.Text != "" {
84+ fmt.Fprint(diag, tb.Text)
85+ if !strings.HasSuffix(tb.Text, "\n") {
86+ fmt.Fprint(diag, "\n")
87+ }
88+ }
89+ }
90+ if e.Result.IsError {
91+ fmt.Fprintln(diag, "[tool error]")
92+ }
93+ case core.EvTurnEnd:
94+ if e.Err != nil {
95+ runErr = e.Err
96+ }
97+ case core.EvError:
98+ if e.Err != nil {
99+ runErr = e.Err
100+ }
101+ }
102+ }
103+
104+ if err := ag.Prompt(ctx, prompt, images, sink); err != nil {
105+ return err
106+ }
107+ if runErr != nil {
108+ return runErr
109+ }
110+ if wroteText && !lastWasNL {
111+ _, _ = fmt.Fprint(out, "\n")
112+ flush()
113+ }
114+ return nil
115+}
A packages/agent/modes/stream_test.go +77 -0
@@ -0,0 +1,77 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+type streamTestClient struct {
13+ events []provider.Event
14+}
15+
16+func (c *streamTestClient) Name() string { return "stream-test" }
17+
18+func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
19+ out := make(chan provider.Event, len(c.events))
20+ go func() {
21+ defer close(out)
22+ for _, ev := range c.events {
23+ select {
24+ case <-ctx.Done():
25+ return
26+ case out <- ev:
27+ }
28+ }
29+ }()
30+ return out, nil
31+}
32+
33+func TestRunStreamWritesDeltasLive(t *testing.T) {
34+ client := &streamTestClient{events: []provider.Event{
35+ provider.EventTextDelta{Delta: "Hel"},
36+ provider.EventTextDelta{Delta: "lo"},
37+ provider.EventDone{
38+ Stop: provider.StopEnd,
39+ Message: provider.Message{
40+ Role: provider.RoleAssistant,
41+ Content: []provider.Content{provider.TextBlock{Text: "Hello"}},
42+ },
43+ },
44+ }}
45+ ag := core.NewAgent(client, "test-model", "", nil)
46+ var out, diag bytes.Buffer
47+ if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, &diag); err != nil {
48+ t.Fatal(err)
49+ }
50+ if got := out.String(); got != "Hello\n" {
51+ t.Fatalf("stdout = %q, want Hello\\n", got)
52+ }
53+}
54+
55+func TestRunStreamFallbackWithoutDeltas(t *testing.T) {
56+ client := &streamTestClient{events: []provider.Event{
57+ provider.EventDone{
58+ Stop: provider.StopEnd,
59+ Message: provider.Message{
60+ Role: provider.RoleAssistant,
61+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
62+ },
63+ },
64+ }}
65+ ag := core.NewAgent(client, "test-model", "", nil)
66+ var out bytes.Buffer
67+ if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, ioDiscard{}); err != nil {
68+ t.Fatal(err)
69+ }
70+ if got := out.String(); got != "done\n" {
71+ t.Fatalf("stdout = %q, want done\\n", got)
72+ }
73+}
74+
75+type ioDiscard struct{}
76+
77+func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }
@@ -0,0 +1,77 @@
1+package modes
2+
3+import (
4+ "bytes"
5+ "context"
6+ "testing"
7+
8+ "github.com/patriceckhart/zot/packages/core"
9+ "github.com/patriceckhart/zot/packages/provider"
10+)
11+
12+type streamTestClient struct {
13+ events []provider.Event
14+}
15+
16+func (c *streamTestClient) Name() string { return "stream-test" }
17+
18+func (c *streamTestClient) Stream(ctx context.Context, req provider.Request) (<-chan provider.Event, error) {
19+ out := make(chan provider.Event, len(c.events))
20+ go func() {
21+ defer close(out)
22+ for _, ev := range c.events {
23+ select {
24+ case <-ctx.Done():
25+ return
26+ case out <- ev:
27+ }
28+ }
29+ }()
30+ return out, nil
31+}
32+
33+func TestRunStreamWritesDeltasLive(t *testing.T) {
34+ client := &streamTestClient{events: []provider.Event{
35+ provider.EventTextDelta{Delta: "Hel"},
36+ provider.EventTextDelta{Delta: "lo"},
37+ provider.EventDone{
38+ Stop: provider.StopEnd,
39+ Message: provider.Message{
40+ Role: provider.RoleAssistant,
41+ Content: []provider.Content{provider.TextBlock{Text: "Hello"}},
42+ },
43+ },
44+ }}
45+ ag := core.NewAgent(client, "test-model", "", nil)
46+ var out, diag bytes.Buffer
47+ if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, &diag); err != nil {
48+ t.Fatal(err)
49+ }
50+ if got := out.String(); got != "Hello\n" {
51+ t.Fatalf("stdout = %q, want Hello\\n", got)
52+ }
53+}
54+
55+func TestRunStreamFallbackWithoutDeltas(t *testing.T) {
56+ client := &streamTestClient{events: []provider.Event{
57+ provider.EventDone{
58+ Stop: provider.StopEnd,
59+ Message: provider.Message{
60+ Role: provider.RoleAssistant,
61+ Content: []provider.Content{provider.TextBlock{Text: "done"}},
62+ },
63+ },
64+ }}
65+ ag := core.NewAgent(client, "test-model", "", nil)
66+ var out bytes.Buffer
67+ if err := RunStreamWithDiag(context.Background(), ag, "hi", nil, &out, ioDiscard{}); err != nil {
68+ t.Fatal(err)
69+ }
70+ if got := out.String(); got != "done\n" {
71+ t.Fatalf("stdout = %q, want done\\n", got)
72+ }
73+}
74+
75+type ioDiscard struct{}
76+
77+func (ioDiscard) Write(p []byte) (int, error) { return len(p), nil }
M packages/agent/tools/permissions_test.go +20 -0
@@ -63,3 +63,23 @@ func TestBashAllowlistRejectsShellEscapes(t *testing.T) {
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 +64 -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,7 @@ 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+ return "https://github.com/" + parts[0] + "/" + parts[1], nil
401415 }
402416 return ref, nil
403417 }
@@ -566,9 +580,17 @@ func readZotManifest(dir string) (ZotfileManifest, error) {
566580 }
567581
568582 func validateZotfileDir(dir string) error {
569- st, err := os.Stat(filepath.Join(dir, "AGENT.md"))
570- if err != nil || !st.Mode().IsRegular() {
571- return fmt.Errorf("AGENT.md is required")
583+ agentPath := filepath.Join(dir, "AGENT.md")
584+ st, err := os.Stat(agentPath)
585+ switch {
586+ case err == nil:
587+ if !st.Mode().IsRegular() {
588+ return fmt.Errorf("AGENT.md must be a regular file")
589+ }
590+ case os.IsNotExist(err):
591+ // AGENT.md is optional.
592+ default:
593+ return fmt.Errorf("stat AGENT.md: %w", err)
572594 }
573595 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
574596 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
@@ -755,9 +777,23 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error {
755777
756778 var execLookPath = exec.LookPath
757779
758-func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
759- if os.Getenv("ZOT_AGENT_CONSENT") == "1" {
760- return true, nil
780+func peelLeadingYes(args []string) (yes bool, rest []string) {
781+ rest = args
782+ for len(rest) > 0 {
783+ switch rest[0] {
784+ case "-y", "--yes":
785+ yes = true
786+ rest = rest[1:]
787+ default:
788+ return yes, rest
789+ }
790+ }
791+ return yes, rest
792+}
793+
794+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
795+ if autoYes || os.Getenv("ZOT_AGENT_CONSENT") == "1" {
796+ return acceptZotfileConsent(zf, perms)
761797 }
762798 // "ask" deliberately requires approval on every launch. Other consent
763799 // is durable only for this exact artifact digest, so any package change
@@ -776,15 +812,23 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
776812 }
777813 fmt.Print(formatZotfileConsent(zf, perms, th, interactive))
778814 if !interactive {
779- return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")
815+ return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZOT_AGENT_CONSENT=1 to allow")
780816 }
781817 allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)
782818 if err != nil || !allowed {
783819 return allowed, err
784820 }
821+ return acceptZotfileConsent(zf, perms)
822+}
823+
824+// acceptZotfileConsent records durable consent when the bash mode is not
825+// "ask". Ask mode never caches receipts so every launch re-prompts unless
826+// -y / ZOT_AGENT_CONSENT is used for that run.
827+func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
785828 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
786829 return true, nil
787830 }
831+ consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
788832 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
789833 return false, fmt.Errorf("save agent consent: %w", err)
790834 }
@@ -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,7 @@ 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+ return "https://github.com/" + parts[0] + "/" + parts[1], nil
401 }415 }
402 return ref, nil416 return ref, nil
403 }417 }
@@ -566,9 +580,17 @@ func readZotManifest(dir string) (ZotfileManifest, error) {
566 }580 }
567 581
568 func validateZotfileDir(dir string) error {582 func validateZotfileDir(dir string) error {
569- st, err := os.Stat(filepath.Join(dir, "AGENT.md"))583+ agentPath := filepath.Join(dir, "AGENT.md")
570- if err != nil || !st.Mode().IsRegular() {584+ st, err := os.Stat(agentPath)
571- return fmt.Errorf("AGENT.md is required")585+ switch {
586+ case err == nil:
587+ if !st.Mode().IsRegular() {
588+ return fmt.Errorf("AGENT.md must be a regular file")
589+ }
590+ case os.IsNotExist(err):
591+ // AGENT.md is optional.
592+ default:
593+ return fmt.Errorf("stat AGENT.md: %w", err)
572 }594 }
573 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {595 if exts := bundledExtensionDirs(filepath.Join(dir, "extensions")); len(exts) > 0 {
574 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")596 return fmt.Errorf("bundled executable extensions are not supported by the local runtime: they cannot yet be confined to manifest permissions")
@@ -755,9 +777,23 @@ func checkZotfileRequirements(zf zotfileLoaded, version string) error {
755 777
756 var execLookPath = exec.LookPath778 var execLookPath = exec.LookPath
757 779
758-func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {780+func peelLeadingYes(args []string) (yes bool, rest []string) {
759- if os.Getenv("ZOT_AGENT_CONSENT") == "1" {781+ rest = args
760- return true, nil782+ for len(rest) > 0 {
783+ switch rest[0] {
784+ case "-y", "--yes":
785+ yes = true
786+ rest = rest[1:]
787+ default:
788+ return yes, rest
789+ }
790+ }
791+ return yes, rest
792+}
793+
794+func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet, autoYes bool) (bool, error) {
795+ if autoYes || os.Getenv("ZOT_AGENT_CONSENT") == "1" {
796+ return acceptZotfileConsent(zf, perms)
761 }797 }
762 // "ask" deliberately requires approval on every launch. Other consent798 // "ask" deliberately requires approval on every launch. Other consent
763 // is durable only for this exact artifact digest, so any package change799 // is durable only for this exact artifact digest, so any package change
@@ -776,15 +812,23 @@ func consentZotfile(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
776 }812 }
777 fmt.Print(formatZotfileConsent(zf, perms, th, interactive))813 fmt.Print(formatZotfileConsent(zf, perms, th, interactive))
778 if !interactive {814 if !interactive {
779- return false, fmt.Errorf("refusing to run without interactive consent; set ZOT_AGENT_CONSENT=1 to allow")815+ return false, fmt.Errorf("refusing to run without interactive consent; pass -y/--yes or set ZOT_AGENT_CONSENT=1 to allow")
780 }816 }
781 allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)817 allowed, err := readZotfileConsent(os.Stdin, os.Stdout, th)
782 if err != nil || !allowed {818 if err != nil || !allowed {
783 return allowed, err819 return allowed, err
784 }820 }
821+ return acceptZotfileConsent(zf, perms)
822+}
823+
824+// acceptZotfileConsent records durable consent when the bash mode is not
825+// "ask". Ask mode never caches receipts so every launch re-prompts unless
826+// -y / ZOT_AGENT_CONSENT is used for that run.
827+func acceptZotfileConsent(zf zotfileLoaded, perms tools.PermissionSet) (bool, error) {
785 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {828 if strings.ToLower(strings.TrimSpace(perms.Bash.Mode)) == "ask" {
786 return true, nil829 return true, nil
787 }830 }
831+ consentPath := filepath.Join(ZotHome(), "agents", safeAgentName(zf.Manifest.Name), "consents", zf.Digest+".json")
788 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {832 if err := os.MkdirAll(filepath.Dir(consentPath), 0o700); err != nil {
789 return false, fmt.Errorf("save agent consent: %w", err)833 return false, fmt.Errorf("save agent consent: %w", err)
790 }834 }
A packages/agent/zotfile_pre_test.go +42 -0
@@ -0,0 +1,42 @@
1+package agent
2+
3+import (
4+ "context"
5+ "runtime"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestRunZotfileStartupPreShellFailure(t *testing.T) {
11+ pre := "!false"
12+ if runtime.GOOS == "windows" {
13+ pre = "!exit 1"
14+ }
15+ err := runZotfileStartupPre(context.Background(), pre, t.TempDir(), nil, nil, nil, nil)
16+ if err == nil {
17+ t.Fatal("expected entry.pre shell failure")
18+ }
19+ if !strings.Contains(err.Error(), "entry.pre") {
20+ t.Fatalf("error = %v, want entry.pre context", err)
21+ }
22+}
23+
24+func TestRunZotfileStartupPreEmpty(t *testing.T) {
25+ if err := runZotfileStartupPre(context.Background(), " ", t.TempDir(), nil, nil, nil, nil); err != nil {
26+ t.Fatalf("empty pre: %v", err)
27+ }
28+}
29+
30+func TestRunZotfileStartupPreShellStreams(t *testing.T) {
31+ cmd := "!printf streamed-pre"
32+ if runtime.GOOS == "windows" {
33+ cmd = "!echo streamed-pre"
34+ }
35+ var out strings.Builder
36+ if err := runZotfileStartupPre(context.Background(), cmd, t.TempDir(), nil, nil, nil, &out); err != nil {
37+ t.Fatal(err)
38+ }
39+ if !strings.Contains(out.String(), "streamed-pre") {
40+ t.Fatalf("shell output not streamed: %q", out.String())
41+ }
42+}
@@ -0,0 +1,42 @@
1+package agent
2+
3+import (
4+ "context"
5+ "runtime"
6+ "strings"
7+ "testing"
8+)
9+
10+func TestRunZotfileStartupPreShellFailure(t *testing.T) {
11+ pre := "!false"
12+ if runtime.GOOS == "windows" {
13+ pre = "!exit 1"
14+ }
15+ err := runZotfileStartupPre(context.Background(), pre, t.TempDir(), nil, nil, nil, nil)
16+ if err == nil {
17+ t.Fatal("expected entry.pre shell failure")
18+ }
19+ if !strings.Contains(err.Error(), "entry.pre") {
20+ t.Fatalf("error = %v, want entry.pre context", err)
21+ }
22+}
23+
24+func TestRunZotfileStartupPreEmpty(t *testing.T) {
25+ if err := runZotfileStartupPre(context.Background(), " ", t.TempDir(), nil, nil, nil, nil); err != nil {
26+ t.Fatalf("empty pre: %v", err)
27+ }
28+}
29+
30+func TestRunZotfileStartupPreShellStreams(t *testing.T) {
31+ cmd := "!printf streamed-pre"
32+ if runtime.GOOS == "windows" {
33+ cmd = "!echo streamed-pre"
34+ }
35+ var out strings.Builder
36+ if err := runZotfileStartupPre(context.Background(), cmd, t.TempDir(), nil, nil, nil, &out); err != nil {
37+ t.Fatal(err)
38+ }
39+ if !strings.Contains(out.String(), "streamed-pre") {
40+ t.Fatalf("shell output not streamed: %q", out.String())
41+ }
42+}
M packages/agent/zotfile_test.go +101 -2
@@ -93,6 +93,33 @@ func writeTestZotfile(t *testing.T, manifest string) string {
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", "https://github.com/agents/remote-agent"},
184+ {`agents\remote-agent`, "https://github.com/agents/remote-agent"},
185+ {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
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", "https://github.com/agents/remote-agent"},
136- {`agents\remote-agent`, "https://github.com/patriceckhart/agents/remote-agent"},184+ {`agents\remote-agent`, "https://github.com/agents/remote-agent"},
185+ {"frkr/zot-archify", "https://github.com/frkr/zot-archify"},
137 {"./missing-agent", "./missing-agent"},186 {"./missing-agent", "./missing-agent"},
138 {"Remote-Agent", "Remote-Agent"},187 {"Remote-Agent", "Remote-Agent"},
139 {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},188 {"https://github.com/acme/agents/example", "https://github.com/acme/agents/example"},
@@ -307,3 +356,53 @@ func TestPermissionSummaryShowsDeniedScopes(t *testing.T) {
307 t.Fatalf("summary did not show denied scopes:\n%s", got)356 t.Fatalf("summary did not show denied scopes:\n%s", got)
308 }357 }
309 }358 }
359+
360+func TestPeelLeadingYes(t *testing.T) {
361+ yes, rest := peelLeadingYes([]string{"-y", "--yes", "agent", "-y"})
362+ if !yes {
363+ t.Fatal("expected yes")
364+ }
365+ if len(rest) != 2 || rest[0] != "agent" || rest[1] != "-y" {
366+ t.Fatalf("rest = %v", rest)
367+ }
368+ yes, rest = peelLeadingYes([]string{"agent", "-y"})
369+ if yes || len(rest) != 2 {
370+ t.Fatalf("yes=%v rest=%v", yes, rest)
371+ }
372+}
373+
374+func TestConsentZotfileAutoYesWritesReceipt(t *testing.T) {
375+ home := t.TempDir()
376+ t.Setenv("ZOT_HOME", home)
377+ zf := zotfileLoaded{Digest: "abc123", Manifest: ZotfileManifest{Name: "demo", Version: "1.0.0"}}
378+ var perms tools.PermissionSet
379+ perms.Bash.Mode = "none"
380+ allowed, err := consentZotfile(zf, perms, true)
381+ if err != nil || !allowed {
382+ t.Fatalf("auto yes: allowed=%v err=%v", allowed, err)
383+ }
384+ path := filepath.Join(home, "agents", "demo", "consents", "abc123.json")
385+ if _, err := os.Stat(path); err != nil {
386+ t.Fatalf("consent receipt missing: %v", err)
387+ }
388+ // Second call without -y should use the receipt.
389+ allowed, err = consentZotfile(zf, perms, false)
390+ if err != nil || !allowed {
391+ t.Fatalf("cached consent: allowed=%v err=%v", allowed, err)
392+ }
393+}
394+
395+func TestConsentZotfileAutoYesAskDoesNotCache(t *testing.T) {
396+ home := t.TempDir()
397+ t.Setenv("ZOT_HOME", home)
398+ zf := zotfileLoaded{Digest: "askdig", Manifest: ZotfileManifest{Name: "ask-agent", Version: "1.0.0"}}
399+ var perms tools.PermissionSet
400+ perms.Bash.Mode = "ask"
401+ if _, err := consentZotfile(zf, perms, true); err != nil {
402+ t.Fatal(err)
403+ }
404+ path := filepath.Join(home, "agents", "ask-agent", "consents", "askdig.json")
405+ if _, err := os.Stat(path); !os.IsNotExist(err) {
406+ t.Fatalf("ask mode should not cache consent, err=%v", err)
407+ }
408+}

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