diff --git a/docs/USAGE.md b/docs/USAGE.md index 6a0d765..49fda7e 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -261,7 +261,16 @@ Avoid writing automated sessions into the default `~/.agent-tty` unless you inte `create` spawns the shell with your inherited environment plus `TERM` (from `--term`) and a default `PROMPT_EOL_MARK=` (empty). The empty `PROMPT_EOL_MARK` suppresses the inverse-video `%` that `zsh` prints at the end of any output without a trailing newline; without it, agent-tty's hidden per-`run` completion marker leaves a stray `%` in snapshots, screenshots, and recordings. The variable is zsh-only and inert in other shells. -Any `--env` value always wins, so you can opt back into the shell's native behavior per session: +The shell also receives two session variables, so scripts inside a session can detect agent-tty and find their own session: + +| Variable | Value | +| ---------------------- | --------------------------------------------------------- | +| `AGENT_TTY_ACTIVE` | `true`. | +| `AGENT_TTY_SESSION_ID` | The session's ID (a ULID, as printed by `create --json`). | + +Both replace inherited values, so a session created from inside another session reports its own ID, not the outer one. Like the `PROMPT_EOL_MARK` default, they are set at spawn time and are not stored in the session's recorded `env`. + +Any `--env` value always wins, so you can opt back into the shell's native behavior or override a session variable per session: ```bash # Restore zsh's styled default marker: diff --git a/src/host/hostMain.ts b/src/host/hostMain.ts index 4ded84a..8a31897 100644 --- a/src/host/hostMain.ts +++ b/src/host/hostMain.ts @@ -245,6 +245,7 @@ export async function runHost(sessionId: string): Promise { rows: manifest.rows, env: manifest.env ?? {}, term: manifest.term ?? 'xterm-256color', + sessionId, }); invariant( diff --git a/src/pty/createPty.ts b/src/pty/createPty.ts index fbf5005..2f98e37 100644 --- a/src/pty/createPty.ts +++ b/src/pty/createPty.ts @@ -16,6 +16,7 @@ export interface PtyOptions { rows: number; env: Record; term: string; + sessionId?: string; } const EXECUTABLE_PERMISSION_MASK = 0o111; @@ -85,17 +86,22 @@ const PROMPT_EOL_MARK_ENV_KEY = 'PROMPT_EOL_MARK'; * Resolves the environment handed to the spawned PTY shell. * * Precedence, lowest to highest: the inherited process environment (minus - * host-only internals), then the `PROMPT_EOL_MARK=''` default, then the caller-supplied `env` (so a `--env` - * value always wins — even an explicit empty one), then `TERM`. The default sits - * after the inherited environment so it also overrides any inherited - * `PROMPT_EOL_MARK`, keeping captures deterministic regardless of the launching - * shell. The presence check is against `env` (the user-explicit set) rather than - * the merged result, so an inherited value never counts as opting out. + * host-only internals), then the `PROMPT_EOL_MARK=''` default, then the + * generated session metadata (`AGENT_TTY_ACTIVE=true` and, when `sessionId` is + * given, `AGENT_TTY_SESSION_ID`), then the caller-supplied `env` (so a `--env` + * value always wins — even an explicit empty one), then `TERM`. The defaults sit + * after the inherited environment so they also override inherited values: a + * nested session reports its own id rather than the outer session's, and + * captures stay deterministic regardless of the launching shell. The + * `PROMPT_EOL_MARK` presence check is against `env` (the user-explicit set) + * rather than the merged result, so an inherited value never counts as opting + * out. */ export function resolvePtyEnv( env: Record, term: string, baseEnv: Record = process.env, + sessionId?: string, ): Record { const resolved: Record = {}; for (const [key, value] of Object.entries(baseEnv)) { @@ -111,6 +117,11 @@ export function resolvePtyEnv( resolved[PROMPT_EOL_MARK_ENV_KEY] = ''; } + resolved.AGENT_TTY_ACTIVE = 'true'; + if (sessionId) { + resolved.AGENT_TTY_SESSION_ID = sessionId; + } + Object.assign(resolved, env); resolved.TERM = term; return resolved; @@ -144,6 +155,6 @@ export function createPty(options: PtyOptions): IPty { cwd, cols, rows, - env: resolvePtyEnv(env, term), + env: resolvePtyEnv(env, term, process.env, options.sessionId), }); } diff --git a/test/integration/pty-basics.test.ts b/test/integration/pty-basics.test.ts index 5f4bbfa..2f92ca1 100644 --- a/test/integration/pty-basics.test.ts +++ b/test/integration/pty-basics.test.ts @@ -213,6 +213,55 @@ describe('pty-basics integration', { timeout: 30000 }, () => { } }); + it('exposes the session id and AGENT_TTY_ACTIVE to the spawned shell', () => { + let sessionId = ''; + + try { + // An id inherited from an outer session must not leak into this one. + const createResult = runCli( + [ + 'create', + '--json', + '--', + '/bin/sh', + '-c', + 'printf "active=%s id=%s\\n" "${AGENT_TTY_ACTIVE-unset}" "${AGENT_TTY_SESSION_ID-unset}"; exec cat', + ], + { AGENT_TTY_HOME: testHome, AGENT_TTY_SESSION_ID: 'outer-session' }, + ); + expect(createResult.status).toBe(0); + expect(createResult.stderr).toBe(''); + const envelope = JSON.parse(createResult.stdout) as SuccessEnvelope<{ + sessionId: string; + }>; + expect(envelope.ok).toBe(true); + sessionId = envelope.result.sessionId; + expect(sessionId.length).toBeGreaterThan(0); + + const waitResult = runCli( + [ + 'wait', + sessionId, + '--text', + `active=true id=${sessionId}`, + '--timeout', + '10000', + '--json', + ], + { AGENT_TTY_HOME: testHome }, + 60_000, + ); + expect(waitResult.status).toBe(0); + expect(waitResult.stderr).toBe(''); + expect(JSON.parse(waitResult.stdout)).toMatchObject({ + ok: true, + result: { matched: true, timedOut: false }, + }); + } finally { + destroySession(testHome, sessionId); + } + }); + it('resize records resize and inspect reflects new dimensions', async () => { let sessionId = ''; diff --git a/test/unit/pty/createPty.test.ts b/test/unit/pty/createPty.test.ts index 62c0e71..3d477a5 100644 --- a/test/unit/pty/createPty.test.ts +++ b/test/unit/pty/createPty.test.ts @@ -64,4 +64,60 @@ describe('resolvePtyEnv', () => { expect(resolved.BAZ).toBe('qux'); expect(Object.prototype.hasOwnProperty.call(resolved, 'EMPTY')).toBe(false); }); + + it('unconditionally sets AGENT_TTY_ACTIVE to true', () => { + const resolved = resolvePtyEnv({}, 'xterm-256color', {}); + expect(resolved.AGENT_TTY_ACTIVE).toBe('true'); + }); + + it('sets AGENT_TTY_SESSION_ID when sessionId is provided', () => { + const resolved = resolvePtyEnv( + {}, + 'xterm-256color', + {}, + 'test-session-123', + ); + expect(resolved.AGENT_TTY_SESSION_ID).toBe('test-session-123'); + }); + + it('does not set AGENT_TTY_SESSION_ID when sessionId is not provided', () => { + const resolved = resolvePtyEnv({}, 'xterm-256color', {}); + expect(resolved.AGENT_TTY_SESSION_ID).toBeUndefined(); + }); + + it('replaces an inherited outer session id with the session id', () => { + const resolved = resolvePtyEnv( + {}, + 'xterm-256color', + { AGENT_TTY_ACTIVE: 'outer', AGENT_TTY_SESSION_ID: 'outer-session' }, + 'inner-session', + ); + + expect(resolved.AGENT_TTY_ACTIVE).toBe('true'); + expect(resolved.AGENT_TTY_SESSION_ID).toBe('inner-session'); + }); + + it('lets caller-supplied env override the injected session variables', () => { + const resolved = resolvePtyEnv( + { AGENT_TTY_ACTIVE: 'custom', AGENT_TTY_SESSION_ID: 'custom-id' }, + 'xterm-256color', + { AGENT_TTY_SESSION_ID: 'outer-session' }, + 'inner-session', + ); + + expect(resolved.AGENT_TTY_ACTIVE).toBe('custom'); + expect(resolved.AGENT_TTY_SESSION_ID).toBe('custom-id'); + }); + + it('lets an explicit empty caller value override the injected session variables', () => { + const resolved = resolvePtyEnv( + { AGENT_TTY_ACTIVE: '', AGENT_TTY_SESSION_ID: '' }, + 'xterm-256color', + {}, + 'inner-session', + ); + + expect(resolved.AGENT_TTY_ACTIVE).toBe(''); + expect(resolved.AGENT_TTY_SESSION_ID).toBe(''); + }); });