detached 6506e47

Expose agent session metadata to child processes

commit
6506e47cb280ca63b5a1f8470cca35628683797f
author
Zeno Jiricek · · 2026-09-08 23:26 UTC
committer
Zeno Jiricek · · 2026-09-08 23:30 UTC
parent
1b0d383
signature
unsigned
5 files · +68 -3
M README.md +14 -1
@@ -816,7 +816,20 @@ A model installed through Ollama is kept in Ollama's internal storage and is not
816816
817817 ## Inline images
818818
819-Scripts and shell commands launched by zot inherit `ZOT_AGENT=1`. Use this signal when a script needs to distinguish execution inside zot from a standalone invocation.
819+Scripts and shell commands launched by zot inherit runtime metadata:
820+
821+| Variable | Meaning |
822+| --- | --- |
823+| `AI_AGENT` | Generic agent marker, set to `zot` |
824+| `ZOT_AGENT` | Zot-specific process marker, set to `1` |
825+| `ZOT_CHILD_SESSION` | Set to `1` for commands launched by a zot tool |
826+| `ZOT_SESSION_ID` | Current session ID, when session persistence is enabled |
827+| `ZOT_SESSION_FILE` | Absolute path to the current session file, when persisted |
828+| `ZOT_PROVIDER` | Current model provider |
829+| `ZOT_MODEL` | Current model ID |
830+| `ZOT_REASONING_LEVEL` | Current reasoning level, when configured |
831+
832+These values contain no credentials. Use `ZOT_AGENT` or `AI_AGENT` to distinguish execution inside zot from a standalone invocation; use the session and model values for diagnostics or attribution.
820833
821834 When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
822835
@@ -816,7 +816,20 @@ A model installed through Ollama is kept in Ollama's internal storage and is not
816 816
817 ## Inline images817 ## Inline images
818 818
819-Scripts and shell commands launched by zot inherit `ZOT_AGENT=1`. Use this signal when a script needs to distinguish execution inside zot from a standalone invocation.819+Scripts and shell commands launched by zot inherit runtime metadata:
820+
821+| Variable | Meaning |
822+| --- | --- |
823+| `AI_AGENT` | Generic agent marker, set to `zot` |
824+| `ZOT_AGENT` | Zot-specific process marker, set to `1` |
825+| `ZOT_CHILD_SESSION` | Set to `1` for commands launched by a zot tool |
826+| `ZOT_SESSION_ID` | Current session ID, when session persistence is enabled |
827+| `ZOT_SESSION_FILE` | Absolute path to the current session file, when persisted |
828+| `ZOT_PROVIDER` | Current model provider |
829+| `ZOT_MODEL` | Current model ID |
830+| `ZOT_REASONING_LEVEL` | Current reasoning level, when configured |
831+
832+These values contain no credentials. Use `ZOT_AGENT` or `AI_AGENT` to distinguish execution inside zot from a standalone invocation; use the session and model values for diagnostics or attribution.
820 833
821 When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:834 When a tool returns an image (for example `read` on a PNG), zot renders it inline on terminals that support it: **Ghostty**, **Kitty**, **iTerm2**, **WezTerm**. On other terminals you see a text placeholder with MIME type, pixel dimensions, and byte size. Control with the `ZOT_INLINE_IMAGES` env var:
822 835
M packages/agent/args_test.go +3 -0
@@ -106,6 +106,9 @@ func TestRunSetsAgentEnvironment(t *testing.T) {
106106 if got := os.Getenv("ZOT_AGENT"); got != "1" {
107107 t.Fatalf("ZOT_AGENT = %q, want %q", got, "1")
108108 }
109+ if got := os.Getenv("AI_AGENT"); got != "zot" {
110+ t.Fatalf("AI_AGENT = %q, want %q", got, "zot")
111+ }
109112 }
110113
111114 func TestHelpOutputStreams(t *testing.T) {
@@ -106,6 +106,9 @@ func TestRunSetsAgentEnvironment(t *testing.T) {
106 if got := os.Getenv("ZOT_AGENT"); got != "1" {106 if got := os.Getenv("ZOT_AGENT"); got != "1" {
107 t.Fatalf("ZOT_AGENT = %q, want %q", got, "1")107 t.Fatalf("ZOT_AGENT = %q, want %q", got, "1")
108 }108 }
109+ if got := os.Getenv("AI_AGENT"); got != "zot" {
110+ t.Fatalf("AI_AGENT = %q, want %q", got, "zot")
111+ }
109 }112 }
110 113
111 func TestHelpOutputStreams(t *testing.T) {114 func TestHelpOutputStreams(t *testing.T) {
M packages/agent/cli.go +33 -1
@@ -208,8 +208,13 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
208208 func Run(rawArgs []string, version string) error {
209209 // Mark the process and all child processes as running under zot. This
210210 // lets shell tools, extensions, and scripts distinguish zot execution
211- // from an ordinary invocation.
211+ // from an ordinary invocation. Clear per-session values because embedded
212+ // callers may invoke Run more than once in one process.
213+ _ = os.Setenv("AI_AGENT", "zot")
212214 _ = os.Setenv("ZOT_AGENT", "1")
215+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
216+ _ = os.Unsetenv(name)
217+ }
213218
214219 // Apply network configuration before any subcommand can make an HTTP
215220 // request. Standard proxy environment variables retain precedence.
@@ -1065,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
10651070 sess = newSess
10661071 currentAg.SetMessages(msgs)
10671072 bindAgentSession(currentAg, sess)
1073+ setZotSessionEnvironment(r, sess)
10681074 startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
10691075 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
10701076 currentAg.SeedCost(cum)
@@ -1472,6 +1478,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
14721478 if sess != nil {
14731479 _ = sess.UpdateModel(providerName, model)
14741480 }
1481+ r.Provider = providerName
1482+ r.Model = model
1483+ setZotSessionEnvironment(r, sess)
14751484 },
14761485 })
14771486
@@ -1548,6 +1557,7 @@ func agentSessionsRoot(root string, args Args) string {
15481557 // exists, its id is bound onto ag so providers can sticky-route.
15491558 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
15501559 if args.NoSess {
1560+ setZotSessionEnvironment(r, nil)
15511561 return nil, nil
15521562 }
15531563 // Sweep meta-only files left over from older zot versions (and from
@@ -1605,9 +1615,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
16051615 }
16061616 }
16071617 bindAgentSession(ag, s)
1618+ setZotSessionEnvironment(r, s)
16081619 return s, nil
16091620 }
16101621
1622+// setZotSessionEnvironment publishes the non-secret runtime metadata that
1623+// zot's child processes can use for attribution and diagnostics.
1624+func setZotSessionEnvironment(r Resolved, sess *core.Session) {
1625+ values := map[string]string{
1626+ "ZOT_PROVIDER": r.Provider,
1627+ "ZOT_MODEL": r.Model,
1628+ "ZOT_REASONING_LEVEL": r.Reasoning,
1629+ }
1630+ if sess != nil {
1631+ values["ZOT_SESSION_ID"] = sess.ID
1632+ values["ZOT_SESSION_FILE"] = sess.Path
1633+ }
1634+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
1635+ if value := values[name]; value != "" {
1636+ _ = os.Setenv(name, value)
1637+ } else {
1638+ _ = os.Unsetenv(name)
1639+ }
1640+ }
1641+}
1642+
16111643 func pickSession(root, cwd string) (string, error) {
16121644 files := core.ListSessions(root, cwd)
16131645 if len(files) == 0 {
@@ -208,8 +208,13 @@ func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
208 func Run(rawArgs []string, version string) error {208 func Run(rawArgs []string, version string) error {
209 // Mark the process and all child processes as running under zot. This209 // Mark the process and all child processes as running under zot. This
210 // lets shell tools, extensions, and scripts distinguish zot execution210 // lets shell tools, extensions, and scripts distinguish zot execution
211- // from an ordinary invocation.211+ // from an ordinary invocation. Clear per-session values because embedded
212+ // callers may invoke Run more than once in one process.
213+ _ = os.Setenv("AI_AGENT", "zot")
212 _ = os.Setenv("ZOT_AGENT", "1")214 _ = os.Setenv("ZOT_AGENT", "1")
215+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
216+ _ = os.Unsetenv(name)
217+ }
213 218
214 // Apply network configuration before any subcommand can make an HTTP219 // Apply network configuration before any subcommand can make an HTTP
215 // request. Standard proxy environment variables retain precedence.220 // request. Standard proxy environment variables retain precedence.
@@ -1065,6 +1070,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1065 sess = newSess1070 sess = newSess
1066 currentAg.SetMessages(msgs)1071 currentAg.SetMessages(msgs)
1067 bindAgentSession(currentAg, sess)1072 bindAgentSession(currentAg, sess)
1073+ setZotSessionEnvironment(r, sess)
1068 startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")1074 startExtensionSession(extMgr, currentAg, r.CWD, "session_switch")
1069 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {1075 if cum, last, uerr := core.SessionUsageDetail(path); uerr == nil {
1070 currentAg.SeedCost(cum)1076 currentAg.SeedCost(cum)
@@ -1472,6 +1478,9 @@ func runInteractive(ctx context.Context, args Args, version string) error {
1472 if sess != nil {1478 if sess != nil {
1473 _ = sess.UpdateModel(providerName, model)1479 _ = sess.UpdateModel(providerName, model)
1474 }1480 }
1481+ r.Provider = providerName
1482+ r.Model = model
1483+ setZotSessionEnvironment(r, sess)
1475 },1484 },
1476 })1485 })
1477 1486
@@ -1548,6 +1557,7 @@ func agentSessionsRoot(root string, args Args) string {
1548 // exists, its id is bound onto ag so providers can sticky-route.1557 // exists, its id is bound onto ag so providers can sticky-route.
1549 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {1558 func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string) (*core.Session, error) {
1550 if args.NoSess {1559 if args.NoSess {
1560+ setZotSessionEnvironment(r, nil)
1551 return nil, nil1561 return nil, nil
1552 }1562 }
1553 // Sweep meta-only files left over from older zot versions (and from1563 // Sweep meta-only files left over from older zot versions (and from
@@ -1605,9 +1615,31 @@ func openOrCreateSession(args Args, r Resolved, ag *core.Agent, version string)
1605 }1615 }
1606 }1616 }
1607 bindAgentSession(ag, s)1617 bindAgentSession(ag, s)
1618+ setZotSessionEnvironment(r, s)
1608 return s, nil1619 return s, nil
1609 }1620 }
1610 1621
1622+// setZotSessionEnvironment publishes the non-secret runtime metadata that
1623+// zot's child processes can use for attribution and diagnostics.
1624+func setZotSessionEnvironment(r Resolved, sess *core.Session) {
1625+ values := map[string]string{
1626+ "ZOT_PROVIDER": r.Provider,
1627+ "ZOT_MODEL": r.Model,
1628+ "ZOT_REASONING_LEVEL": r.Reasoning,
1629+ }
1630+ if sess != nil {
1631+ values["ZOT_SESSION_ID"] = sess.ID
1632+ values["ZOT_SESSION_FILE"] = sess.Path
1633+ }
1634+ for _, name := range []string{"ZOT_SESSION_ID", "ZOT_SESSION_FILE", "ZOT_PROVIDER", "ZOT_MODEL", "ZOT_REASONING_LEVEL"} {
1635+ if value := values[name]; value != "" {
1636+ _ = os.Setenv(name, value)
1637+ } else {
1638+ _ = os.Unsetenv(name)
1639+ }
1640+ }
1641+}
1642+
1611 func pickSession(root, cwd string) (string, error) {1643 func pickSession(root, cwd string) (string, error) {
1612 files := core.ListSessions(root, cwd)1644 files := core.ListSessions(root, cwd)
1613 if len(files) == 0 {1645 if len(files) == 0 {
M packages/agent/tools/bash.go +3 -1
@@ -81,7 +81,9 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string
8181 return core.ToolResult{}, err
8282 }
8383 cmd.Dir = cwd
84- cmd.Env = os.Environ()
84+ // Mark direct tool subprocesses separately from the zot process itself.
85+ // Descendants inherit this marker as usual.
86+ cmd.Env = append(os.Environ(), "ZOT_CHILD_SESSION=1")
8587 // Bound output draining if descendants retain handles after cancellation.
8688 cmd.WaitDelay = 2 * time.Second
8789 setProcessGroup(cmd)
@@ -81,7 +81,9 @@ func executeShell(ctx context.Context, raw json.RawMessage, progress func(string
81 return core.ToolResult{}, err81 return core.ToolResult{}, err
82 }82 }
83 cmd.Dir = cwd83 cmd.Dir = cwd
84- cmd.Env = os.Environ()84+ // Mark direct tool subprocesses separately from the zot process itself.
85+ // Descendants inherit this marker as usual.
86+ cmd.Env = append(os.Environ(), "ZOT_CHILD_SESSION=1")
85 // Bound output draining if descendants retain handles after cancellation.87 // Bound output draining if descendants retain handles after cancellation.
86 cmd.WaitDelay = 2 * time.Second88 cmd.WaitDelay = 2 * time.Second
87 setProcessGroup(cmd)89 setProcessGroup(cmd)
M packages/agent/tools/tools_test.go +15 -0
@@ -355,6 +355,21 @@ func TestBashSuccess(t *testing.T) {
355355 }
356356 }
357357
358+func TestBashExposesChildSessionMarker(t *testing.T) {
359+ if runtime.GOOS == "windows" {
360+ t.Skip("posix shell only")
361+ }
362+ tool := &BashTool{CWD: t.TempDir()}
363+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "printf '%s' \"$ZOT_CHILD_SESSION\""}), nil)
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ got := res.Content[0].(provider.TextBlock).Text
368+ if !strings.Contains(got, "1") {
369+ t.Fatalf("ZOT_CHILD_SESSION = %q, want 1", got)
370+ }
371+}
372+
358373 func TestBashSyntax(t *testing.T) {
359374 if runtime.GOOS == "windows" {
360375 t.Skip("bash syntax is not used on Windows")
@@ -355,6 +355,21 @@ func TestBashSuccess(t *testing.T) {
355 }355 }
356 }356 }
357 357
358+func TestBashExposesChildSessionMarker(t *testing.T) {
359+ if runtime.GOOS == "windows" {
360+ t.Skip("posix shell only")
361+ }
362+ tool := &BashTool{CWD: t.TempDir()}
363+ res, err := tool.Execute(context.Background(), mustJSON(t, map[string]any{"command": "printf '%s' \"$ZOT_CHILD_SESSION\""}), nil)
364+ if err != nil {
365+ t.Fatal(err)
366+ }
367+ got := res.Content[0].(provider.TextBlock).Text
368+ if !strings.Contains(got, "1") {
369+ t.Fatalf("ZOT_CHILD_SESSION = %q, want 1", got)
370+ }
371+}
372+
358 func TestBashSyntax(t *testing.T) {373 func TestBashSyntax(t *testing.T) {
359 if runtime.GOOS == "windows" {374 if runtime.GOOS == "windows" {
360 t.Skip("bash syntax is not used on Windows")375 t.Skip("bash syntax is not used on Windows")

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