Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

## Changed

- Spawned shells now default `PROMPT_EOL_MARK=` (empty) in the session environment, suppressing the inverse-video `%` end-of-partial-line marker that `zsh` prints when output lacks a trailing newline. agent-tty strips a hidden completion-marker postamble after each `run`, which desynced the rendered cursor and left that `%` in snapshots, screenshots, and recordings; the default keeps captures clean. The marker is zsh-only and inert in other shells. Opt back in per session with `agent-tty create --env PROMPT_EOL_MARK='%B%S%#%s%b' -- <shell>` to restore zsh's styled default (a lone `'%'` expands to nothing), or pass any explicit `--env PROMPT_EOL_MARK=...` value. The default is applied at PTY spawn time and is not written to the manifest, so `inspect`, `list`, and `create --json` env maps are unchanged ([#114](https://github.com/coder/agent-tty/pull/114)).
- `inspect` collects renderer state and the session snapshot in a single synchronous tick before awaiting, so concurrent RPC handlers cannot interleave a mutated renderer state with a stale session snapshot ([#104](https://github.com/coder/agent-tty/pull/104)).
- `mise` development toolchain refresh; ordinary CI remains credential-free and does not install the live-demo recorder tools ([#107](https://github.com/coder/agent-tty/pull/107)).

Expand Down
11 changes: 11 additions & 0 deletions docs/TROUBLESHOOTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,14 @@ or install a GitHub Release tarball as described in [`INSTALL.md`](./INSTALL.md)
It gives repeatable artifacts for review and automation, but it does not guarantee exact native-terminal pixel parity.

If a bug depends on a specific native terminal emulator, keep the `agent-tty` artifact as reference evidence and capture native-terminal evidence separately when needed.

## Stray `%` at the End of Captured Output

If a snapshot, screenshot, or recording shows an unexpected inverse-video `%` at the end of output that has no trailing newline, that is `zsh`'s `PROMPT_EOL_MARK` end-of-partial-line indicator. agent-tty spawns shells with `PROMPT_EOL_MARK=` (empty) by default to suppress it, so you should normally not see it.

If it still appears:

- A `PROMPT_EOL_MARK` assignment in your `~/.zshrc` overrides the default (rc files load after the environment is imported). Remove that line, or set the value you want explicitly with `agent-tty create --env PROMPT_EOL_MARK=... -- <shell>`.
- The program running inside the session set the marker itself.

To deliberately keep the marker, pass `--env PROMPT_EOL_MARK='%B%S%#%s%b'` (zsh's styled default) when creating the session.
13 changes: 13 additions & 0 deletions docs/USAGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,19 @@ agent-tty --home "$AGENT_HOME" doctor --json

Avoid writing automated sessions into the default `~/.agent-tty` unless you intentionally want shared local state.

## Shell Environment

`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:

```bash
# Restore zsh's styled default marker:
agent-tty create --env PROMPT_EOL_MARK='%B%S%#%s%b' -- /bin/zsh
```

A lone `'%'` does **not** restore the marker (zsh treats it as a prompt escape that expands to nothing); use `'%B%S%#%s%b'` for the styled default or `'%%'` for a plain percent. The default is applied at spawn time and is not stored in the manifest, so it does not appear in `inspect`, `list`, or `create --json` output. If your `~/.zshrc` assigns `PROMPT_EOL_MARK` it runs after the environment is imported and wins, so the marker can reappear — remove that line or set the value you want via `--env`.

## Anti-Patterns

- Do not reach for `tmux`, `screen`, or ad hoc PTY wrappers first when `agent-tty` can provide an isolated, inspectable session.
Expand Down
50 changes: 45 additions & 5 deletions src/pty/createPty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,50 @@ function ensureDarwinSpawnHelperExecutable(): void {
}
}

/**
* The zsh-only `PROMPT_EOL_MARK` parameter controls the inverse-video glyph zsh
* prints when a line of output has no trailing newline (its default expands to a
* bold standout `%`). agent-tty injects a hidden completion-marker postamble
* after each `run` and strips that postamble's echo from the output stream; the
* strip leaves the rendered cursor mid-line, so zsh's unconditional end-of-line
* mark surfaces as a stray `%` in snapshots, screenshots, and recordings.
* Defaulting the parameter to empty suppresses the glyph. It is inert in shells
* that do not implement it (bash, etc.), so setting it unconditionally is safe.
*/
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, 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.
*/
export function resolvePtyEnv(
env: Record<string, string>,
term: string,
baseEnv: Record<string, string | undefined> = process.env,
): Record<string, string> {
const resolved: Record<string, string> = {};
for (const [key, value] of Object.entries(baseEnv)) {
if (value !== undefined) {
resolved[key] = value;
}
}

if (!Object.prototype.hasOwnProperty.call(env, PROMPT_EOL_MARK_ENV_KEY)) {
resolved[PROMPT_EOL_MARK_ENV_KEY] = '';
}

Object.assign(resolved, env);
resolved.TERM = term;
return resolved;
}

export function createPty(options: PtyOptions): IPty {
const { command, cwd, cols, rows, env, term } = options;

Expand Down Expand Up @@ -95,10 +139,6 @@ export function createPty(options: PtyOptions): IPty {
cwd,
cols,
rows,
env: {
...process.env,
...env,
TERM: term,
},
env: resolvePtyEnv(env, term),
});
}
47 changes: 47 additions & 0 deletions test/integration/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,53 @@ describe('lifecycle integration', { timeout: 30000 }, () => {
expect(allOutput).toContain('bar|qux|vt100');
});

it('defaults PROMPT_EOL_MARK to empty in the spawned shell, overriding an inherited value', async () => {
// The default is injected at PTY spawn time so it never appears in the
// manifest; assert it by reading it back from inside the spawned shell.
// Export a sentinel PROMPT_EOL_MARK in the parent env so this discriminates
// the fix: only an active override yields an empty value in the child —
// without it the shell would inherit the sentinel. (`--env` precedence is
// covered by the resolvePtyEnv unit test; that `--env` reaches the real
// shell is covered by the manifest/env test above.)
const createResult = runCli(
[
'create',
'--json',
'--',
'/bin/sh',
'-c',
'printf "[%s]" "$PROMPT_EOL_MARK"; exit 0',
],
{ AGENT_TTY_HOME: testHome, PROMPT_EOL_MARK: 'inherited-sentinel' },
);
expect(createResult.status).toBe(0);
expect(createResult.stderr).toBe('');
const sessionId = (
JSON.parse(createResult.stdout) as SuccessEnvelope<{ sessionId: string }>
).result.sessionId;

await new Promise<void>((resolve) => {
setTimeout(resolve, 2000);
});

const eventContent = await readFile(
join(testHome, 'sessions', sessionId, 'events.jsonl'),
'utf8',
);
const allOutput = eventContent
.trim()
.split('\n')
.map((line) => JSON.parse(line) as EventRecord)
.filter((event) => event.type === 'output')
.map((event) => {
const data = event.payload.data;
return typeof data === 'string' ? data : '';
})
.join('');
expect(allOutput).toContain('[]');
expect(allOutput).not.toContain('inherited-sentinel');
});

it('persists a provided idle timeout in the session manifest', async () => {
const createResult = runCli(
[
Expand Down
57 changes: 57 additions & 0 deletions test/unit/pty/createPty.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';

import { resolvePtyEnv } from '../../../src/pty/createPty.js';

describe('resolvePtyEnv', () => {
it('defaults PROMPT_EOL_MARK to empty when the caller does not set it', () => {
const resolved = resolvePtyEnv({}, 'xterm-256color', {});

expect(resolved.PROMPT_EOL_MARK).toBe('');
});

it('lets a caller-supplied PROMPT_EOL_MARK win, including an explicit empty one', () => {
expect(
resolvePtyEnv({ PROMPT_EOL_MARK: '%' }, 'xterm-256color', {})
.PROMPT_EOL_MARK,
).toBe('%');
expect(
resolvePtyEnv({ PROMPT_EOL_MARK: '' }, 'xterm-256color', {})
.PROMPT_EOL_MARK,
).toBe('');
});

it('overrides an inherited PROMPT_EOL_MARK when the caller does not set it', () => {
const resolved = resolvePtyEnv({}, 'xterm-256color', {
PROMPT_EOL_MARK: '%',
});

expect(resolved.PROMPT_EOL_MARK).toBe('');
});

it('keeps an inherited PROMPT_EOL_MARK only when the caller re-supplies it', () => {
const resolved = resolvePtyEnv({ PROMPT_EOL_MARK: '%B%S%#%s%b' }, 'vt100', {
PROMPT_EOL_MARK: 'stale',
});

expect(resolved.PROMPT_EOL_MARK).toBe('%B%S%#%s%b');
});

it('always forces TERM to the provided value over inherited and caller env', () => {
const resolved = resolvePtyEnv({ TERM: 'caller' }, 'vt100', {
TERM: 'inherited',
});

expect(resolved.TERM).toBe('vt100');
});

it('passes through inherited and caller env entries and drops undefined values', () => {
const resolved = resolvePtyEnv({ FOO: 'bar' }, 'xterm-256color', {
BAZ: 'qux',
EMPTY: undefined,
});

expect(resolved.FOO).toBe('bar');
expect(resolved.BAZ).toBe('qux');
expect(Object.prototype.hasOwnProperty.call(resolved, 'EMPTY')).toBe(false);
});
});
Loading