Loading files
56ae2e47fThis file is pinned to the selected release. Download the original to open it in your design tool.
import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it, vi } from 'vitest'
import { PROCESS_ENGINES } from '../engines/types.js'
import type { AgentCommandOwnershipSnapshot } from './engineBin.js'
import {
ambiguousAgentProcess,
bypassPermissionActive,
permissionModeFromArgv,
engineProcessMatch,
engineProcessMatchScore,
LSTART_MARKER_RE,
liveProcessRows,
parseProcessRow,
pasteRawIntoTmux,
resumeSessionId,
sendLiteralToTmux,
sendToTmux,
tmuxCaptureArgs,
} from './tmux.js'
const ownership = (cursor: string[] = [], grok: string[] = []): AgentCommandOwnershipSnapshot => ({
cursorFileKeys: new Set(cursor),
grokFileKeys: new Set(grok),
conflictingFileKeys: new Set(cursor.filter((key) => grok.includes(key))),
agentCandidates: [],
cursorAgentCandidates: [],
grokCandidates: [],
})
describe('tmux process primitives', () => {
it('drops a zombie from the process table — it keeps the identity of the engine it no longer is', () => {
// The row the remote-machine rig showed for a stopped agent's Claude, orphaned to a pid 1 that
// never reaped: same pid, comm and start time as the saved identity, so resume thought it alive.
const zombie = parseProcessRow('78206 1 claude Tue Sep 22 08:23:44 2026 [claude] <defunct>')!
const bsdZombie = parseProcessRow('78207 1 (claude) Tue Sep 22 08:23:44 2026 <defunct>')!
const live = parseProcessRow('3998 3992 node Mon Sep 21 08:15:25 2026 node /home/node/.npm-global/bin/codex')!
// Only a TRAILING marker is a corpse; an argument that mentions the word is a running process.
const mentions = parseProcessRow('4000 3992 grep Mon Sep 21 08:15:25 2026 grep <defunct> log.txt')!
// Pids that do not exist here, so on Linux the /proc check cannot vouch for a live process either.
vi.spyOn(process, 'platform', 'get').mockReturnValue('darwin')
expect(liveProcessRows([zombie, live, bsdZombie, mentions])).toEqual([live, mentions])
vi.restoreAllMocks()
})
it('parses a process whose comm field contains spaces', () => {
expect(parseProcessRow('4242 100 ⌘ Greeting Thu Jul 30 11:00:03 2026 cmd -r abcdef12-3456-7890-abcd-ef1234567890')).toEqual({
pid: 4242,
parentPid: 100,
executable: '⌘ Greeting',
startMarker: 'Thu Jul 30 11:00:03 2026',
args: 'cmd -r abcdef12-3456-7890-abcd-ef1234567890',
})
})
/**
* Real `ps -axo pid=,ppid=,comm=,lstart=,args=` output captured on Ubuntu 24.04 (procps-ng 4.0.4),
* not a macOS-shaped invention. Two things differ from macOS and both are load-bearing:
* - `comm` is a bare name, never a path (`/sbin/docker-init` reads back as `docker-init`);
* - `comm` is hard-capped at 15 bytes by TASK_COMM_LEN, so the binary really named
* `a-very-long-engine-name-binary` reads back as `a-very-long-eng`.
* `tmux: server` also proves comm can still carry a space on Linux, which is why the parser anchors
* on lstart rather than splitting comm as one token.
*/
it('parses real Linux procps rows, including a 15-byte-truncated comm', () => {
expect(parseProcessRow(' 1 0 docker-init Fri Aug 21 09:28:14 2026 /sbin/docker-init -- bash -lc')).toEqual({
pid: 1,
parentPid: 0,
executable: 'docker-init',
startMarker: 'Fri Aug 21 09:28:14 2026',
args: '/sbin/docker-init -- bash -lc',
})
expect(parseProcessRow(' 15 1 tmux: server Fri Aug 21 09:28:14 2026 tmux new-session -d -s probe sleep 400')).toEqual({
pid: 15,
parentPid: 1,
executable: 'tmux: server',
startMarker: 'Fri Aug 21 09:28:14 2026',
args: 'tmux new-session -d -s probe sleep 400',
})
const truncated = parseProcessRow(
' 12 7 a-very-long-eng Fri Aug 21 09:28:14 2026 /tmp/a-very-long-engine-name-binary -e x')
expect(truncated?.executable).toBe('a-very-long-eng')
expect(Buffer.byteLength(truncated!.executable)).toBe(15)
})
/**
* Linux procps substitutes `?` for any byte it cannot print in the current locale, and a daemon under
* systemd/docker/ssh usually has no locale at all. Measured on Ubuntu 24.04: a Command Code pane reads
* back as `??? <title>` in BOTH comm and args, so both halves of its marker fail and the reaper evicts
* the live pane. processRows() now reads ps under LC_ALL=C.UTF-8 and repairs any surviving `?` row from
* /proc, which is raw bytes. These two cases pin the before and the after.
*/
/**
* `lstart`'s shape belongs to LC_TIME, not to `ps`. Captured with
* `LC_TIME=<locale> ps -axo pid=,ppid=,comm=,lstart=,args=` on macOS 15.5, one run per locale on the
* same machine in the same second. Of sixteen locales tried only C, en_US and hu_HU parse at all; the
* rest reorder the fields and yield ZERO rows out of ~590.
*
* Zero rows is the whole bug: processRows returns `[]`, not null, so "we looked and the machine is
* empty" is indistinguishable from the truth, resolvePaneEngineProcess finds no engine under any pane,
* and watchCreatedPane burns its full ten minutes before reporting START_TIMEOUT — "claude did not
* expose an engine process within 10 minutes" — against a pane where claude is running and still
* firing hooks. This is why processRows spawns ps under psEnv instead of inheriting the locale.
*/
it('cannot parse an lstart column written by a locale that reorders it', () => {
const rows = [
'15160 2281 /Users/admin/.lo Tue 15 Sep 23:10:38 2026 /Users/admin/.local/bin/claude',
'15160 2281 /Users/admin/.lo Di. 15 Sep. 23:10:38 2026 /Users/admin/.local/bin/claude',
'15160 2281 /Users/admin/.lo mar. 15 sept. 23:10:38 2026 /Users/admin/.local/bin/claude',
'15160 2281 /Users/admin/.lo \u706b 9/15 23:10:38 2026 /Users/admin/.local/bin/claude',
'15160 2281 /Users/admin/.lo \u0432\u0442\u043e\u0440\u043d\u0438\u043a, 15 \u0441\u0435\u043d\u0442\u044f\u0431\u0440\u044f 2026 \u0433. 23:10:38 /Users/admin/.local/bin/claude',
]
for (const row of rows) expect(parseProcessRow(row)).toBeNull()
// The same process, same second, under the LC_TIME=C that psEnv guarantees.
expect(parseProcessRow('15160 2281 /Users/admin/.lo Tue Sep 15 23:10:38 2026 /Users/admin/.local/bin/claude'))
.toEqual({
pid: 15160,
parentPid: 2281,
executable: '/Users/admin/.lo',
startMarker: 'Tue Sep 15 23:10:38 2026',
args: '/Users/admin/.local/bin/claude',
})
})
/**
* checkSessionRuntime adopts, rather than compares, any saved marker this rejects. It has to reject
* localized stamps too: a marker recorded before psEnv landed (hu_HU parsed fine, `K szept. 15 …`) can
* never equal the C-locale stamp read back for the same live process, and comparing them would evict a
* live pane exactly once per session on upgrade.
*/
it('recognises only a C-locale lstart stamp as a comparable start marker', () => {
expect(LSTART_MARKER_RE.test('Tue Sep 15 23:10:38 2026')).toBe(true)
expect(LSTART_MARKER_RE.test('Fri Aug 21 09:28:14 2026')).toBe(true)
expect(LSTART_MARKER_RE.test('K szept. 15 23:10:38 2026')).toBe(false) // hu_HU, parsed pre-fix
expect(LSTART_MARKER_RE.test('Tue 15 Sep 23:10:38 2026')).toBe(false) // en_AU
expect(LSTART_MARKER_RE.test('Greeting Thu Jul 30 11:00:03 2026')).toBe(false) // pre-fix shifted comm
})
it('scores Command Code from the real bytes, and cannot from the mangled ones', () => {
const mangled = parseProcessRow(' 185 178 ??? harness-cli Fri Aug 21 09:34:20 2026 ??? harness-cli ubuntu probe')
expect(engineProcessMatchScore(mangled!, 'commandcode')).toBe(0)
const repaired = parseProcessRow(' 185 178 ⌘ harness-cli Fri Aug 21 09:34:20 2026 ⌘ harness-cli ubuntu probe')
expect(engineProcessMatchScore(repaired!, 'commandcode')).toBe(3)
})
it('recognises stable installed binary forms', () => {
expect(engineProcessMatchScore({ executable: 'devin', args: 'devin' }, 'devin')).toBe(3)
expect(engineProcessMatchScore({ executable: 'muse-bin-0.1.0-R708.1', args: 'muse-bin-0.1.0-R708.1' }, 'muse')).toBe(3)
expect(engineProcessMatchScore({ executable: '/Users/demo/.grok/bin/grok', args: 'grok' }, 'grok')).toBe(3)
})
it.each([
['codex', 'codex-aarch64-apple-darwin'],
['codex', 'codex-x86_64-unknown-linux-musl'],
['kilo', 'kilo-darwin-arm64'],
['kilo', 'kilo-linux-x64-baseline'],
['grok', 'grok-1.0.5-macos-aarch64'],
['grok', 'grok-linux-x86_64'],
] as const)('recognises the %s standalone release image %s', (engine, executable) => {
expect(engineProcessMatchScore({ executable, args: executable }, engine)).toBe(3)
})
it('uses installed executable identity ahead of misleading argv names', () => {
const allFiles = new Map([['codex', new Set(['codex-native'])]]) as AgentCommandOwnershipSnapshot['engineFileKeys']
const commands: AgentCommandOwnershipSnapshot = {
...ownership(),
engineFileKeys: allFiles,
engineCandidates: new Map(),
}
const row = {
executable: 'renamed-release-image',
args: 'renamed-release-image --some-flag',
imagePath: '/custom/native/renamed-release-image',
imageFileKey: 'codex-native',
}
expect(engineProcessMatch(row, 'codex', commands)).toEqual({
score: 4,
evidence: 'file-identity',
imagePath: '/custom/native/renamed-release-image',
})
expect(engineProcessMatchScore(row, 'claude', commands)).toBe(0)
})
it.each(PROCESS_ENGINES)('recognises a renamed native %s image from installed file identity', (engine) => {
const key = `native-${engine}`
const commands: AgentCommandOwnershipSnapshot = {
...ownership(),
engineFileKeys: new Map([[engine, new Set([key])]]),
engineCandidates: new Map(),
}
expect(engineProcessMatch({
executable: 'vendor-rewritten-title',
args: 'vendor-rewritten-title',
imageFileKey: key,
}, engine, commands)).toMatchObject({ score: 4, evidence: 'file-identity' })
})
it('rejects Antigravity IDE binaries even when their basename is agy', () => {
expect(engineProcessMatchScore({
executable: '/Applications/Antigravity.app/Contents/Resources/bin/agy',
args: '/Applications/Antigravity.app/Contents/Resources/bin/agy',
}, 'agy')).toBe(0)
})
it('recognises Claude native installer version targets without accepting a bare semver', () => {
expect(engineProcessMatchScore({
executable: '/Users/demo/.local/share/claude/versions/2.1.246',
args: '/Users/demo/.local/share/claude/versions/2.1.246',
}, 'claude')).toBe(3)
expect(engineProcessMatchScore({
executable: '2.1.246',
args: '/home/demo/.local/share/claude/versions/2.1.246',
}, 'claude')).toBe(3)
expect(engineProcessMatchScore({ executable: '2.1.246', args: '2.1.246' }, 'claude')).toBe(0)
})
it('reads Hermes out of the inline source it runs as', () => {
// 0.21.5+2144.g7b761da, copied off `ps` on this machine. The `sh` stub in
// `~/.hermes/hermes-agent/.hermes/bin/hermes` execs Hermes' own interpreter with the whole
// launcher as `-c` text, so argv names no script and `comm` is a python. Nothing here carried
// the engine's name anywhere the matcher looked, and a running Hermes read as absent: its pane
// was retained six seconds after New Harness, then failed RESUME_UNCONFIRMED ten minutes later.
const python = '/Users/demo/.hermes/tools/python-3.14.7+20260901-darwin-arm64/bin/python3'
const launcher = (entry: string) => `${python} -I -c import os, re, sys`
+ ` sys.path.insert(0, '/Users/demo/.hermes/hermes-agent')`
+ ` import hermes_bootstrap from ${entry} import main sys.exit(main())`
// macOS prints `comm` through a 16-column field beside lstart, so every absolute path arrives
// truncated. The interpreter has to be read from argv[0] or this row scores on a home directory.
const comm = '/Users/demo'
expect(engineProcessMatchScore({ executable: comm, args: launcher('hermes_cli.main') }, 'hermes')).toBe(2)
// `hermes-acp` is the other stub in that bin, identical but for its entry module. An ACP adapter
// is not the harness's engine, and the sys.path root alone would have claimed it.
expect(engineProcessMatchScore({ executable: comm, args: launcher('acp_adapter.entry') }, 'hermes')).toBe(0)
// Inline source stays unreadable as an entrypoint for everyone else: a prompt may say anything.
expect(engineProcessMatchScore({
executable: comm,
args: `${python} -I -c print('x') compare hermes_cli and codex`,
}, 'hermes')).toBe(0)
expect(engineProcessMatchScore({
executable: 'python3',
args: `python3 worker.py from hermes_cli.main import main /Users/demo/.hermes/hermes-agent'`,
}, 'hermes')).toBe(0)
// And the shapes that already worked keep working.
expect(engineProcessMatchScore({ executable: 'hermes', args: 'hermes --resume 20260728_115628_f2c86a' }, 'hermes')).toBe(3)
expect(engineProcessMatchScore({ executable: comm, args: `${python} -m hermes_cli.main` }, 'hermes')).toBe(2)
})
it('recognizes managed Hermes runpy launchers without relying on their install directory', () => {
const bootstrap = "import os, sys, runpy; os.environ.pop('PYTHONHOME', None); os.environ.pop('PYTHONPATH', None); os.environ.pop('VIRTUAL_ENV', None); sys.path.insert(0, '/opt/custom install'); os.environ['HERMES_HOME'] = os.environ.get('HERMES_HOME') or str(__import__('hermes_constants').get_default_hermes_root()); import hermes_bootstrap; runpy.run_module('hermes_cli.main', run_name='__main__', alter_sys=True)"
const old = "import sys, runpy; sys.path.insert(0, '/opt/hermes-agent'); runpy.run_module('hermes_cli.main', run_name='__main__')"
for (const code of [bootstrap, old]) {
for (const source of [code, `\"${code}\"`]) {
expect(engineProcessMatchScore({ executable: '/home/demo/.her', args: `/opt/python3.14 -I -I -c ${source} --resume 20260927_101500_ab12cd` }, 'hermes')).toBe(2)
}
}
for (const args of [
`python3 worker.py -c ${bootstrap}`,
`node -c ${bootstrap}`,
`python3 -c print(\"${bootstrap}\")`,
`python3 -c ${bootstrap.replace('hermes_cli.main', 'acp_adapter.entry')}`,
"python3 -c import sys; sys.path.insert(0, '/opt/hermes-agent'); print('from hermes_cli.main import main')",
]) {
expect(engineProcessMatchScore({ executable: 'python3', args }, 'hermes')).toBe(0)
}
})
it('reads an engine through the ori launcher, before and after its exec', () => {
// `ori claude` computes an environment and then execve's the vendor binary away, so for all but the
// first ~100ms the pane row IS `claude` — that case must keep scoring exactly as a bare launch does.
expect(engineProcessMatchScore({ executable: 'claude', args: '/Users/demo/.local/bin/claude --resume x' }, 'claude')).toBe(3)
// The pre-exec window (and any future ori that spawns instead of exec'ing) resolves through the flags.
expect(engineProcessMatchScore({ executable: 'ori', args: 'ori claude' }, 'claude')).toBe(3)
expect(engineProcessMatchScore({ executable: 'ori', args: '/Users/demo/.local/bin/ori claude --model anthropic/claude-sonnet-4.6 -p hi' }, 'claude')).toBe(3)
expect(engineProcessMatchScore({ executable: 'ori', args: 'ori --log-level debug codex --full-auto' }, 'codex')).toBe(3)
expect(engineProcessMatchScore({ executable: 'ori', args: 'ori opencode' }, 'opencode')).toBe(3)
// Wrapping does not make it a different engine, and ori's own subcommands are not engines.
expect(engineProcessMatchScore({ executable: 'ori', args: 'ori claude' }, 'codex')).toBe(0)
expect(engineProcessMatchScore({ executable: 'ori', args: 'ori eval' }, 'claude')).toBe(0)
expect(engineProcessMatchScore({ executable: 'ori', args: 'ori login' }, 'claude')).toBe(0)
})
it('assigns the colliding agent basename only from executable ownership', () => {
const cursor = ownership(['cursor-file'], ['grok-file'])
const grok = ownership(['cursor-file'], ['grok-file'])
const cursorRow = { executable: 'agent', args: 'agent', imageFileKey: 'cursor-file' }
const grokRow = { executable: 'agent', args: 'agent', imageFileKey: 'grok-file' }
expect(engineProcessMatchScore(cursorRow, 'cursor', cursor)).toBe(4)
expect(engineProcessMatchScore(cursorRow, 'grok', cursor)).toBe(0)
expect(engineProcessMatchScore(grokRow, 'grok', grok)).toBe(4)
expect(engineProcessMatchScore(grokRow, 'cursor', grok)).toBe(0)
expect(engineProcessMatchScore({ executable: 'agent', args: 'agent' }, 'cursor', cursor)).toBe(0)
const conflict = ownership(['same-file'], ['same-file'])
expect(engineProcessMatchScore({ executable: 'agent', args: 'agent', imageFileKey: 'same-file' }, 'cursor', conflict)).toBe(0)
expect(engineProcessMatchScore({ executable: 'agent', args: 'agent', imageFileKey: 'same-file' }, 'grok', conflict)).toBe(0)
})
it('does not treat a daemon role named agent as the colliding CLI command', () => {
expect(ambiguousAgentProcess({
executable: '/usr/sbin/distnoted',
args: '/usr/sbin/distnoted agent',
}, ownership())).toBe(false)
expect(ambiguousAgentProcess({
executable: '/usr/sbin/cfprefsd',
args: '/usr/sbin/cfprefsd agent',
}, ownership())).toBe(false)
})
it('extracts only explicit resume ids', () => {
expect(resumeSessionId('cursor', 'agent --resume=53d3843c-724e-47ff-ae3a-9fedfa328bba'))
.toBe('53d3843c-724e-47ff-ae3a-9fedfa328bba')
expect(resumeSessionId('opencode', 'opencode -s ses_05e335115ffeM05DT5hJHeN3Vp'))
.toBe('ses_05e335115ffeM05DT5hJHeN3Vp')
expect(resumeSessionId('hermes', 'hermes --resume 20260728_115628_f2c86a'))
.toBe('20260728_115628_f2c86a')
expect(resumeSessionId('kilo', 'kilo --session ses_024a007fdffe11yG68JPxsHJly'))
.toBe('ses_024a007fdffe11yG68JPxsHJly')
expect(resumeSessionId('pi', 'pi --session-id 53d3843c-724e-47ff-ae3a-9fedfa328bba'))
.toBe('53d3843c-724e-47ff-ae3a-9fedfa328bba')
expect(resumeSessionId('commandcode', 'cmd --resume 53d3843c-724e-47ff-ae3a-9fedfa328bba'))
.toBe('53d3843c-724e-47ff-ae3a-9fedfa328bba')
expect(resumeSessionId('muse', 'muse resume 53d3843c-724e-47ff-ae3a-9fedfa328bba'))
.toBe('53d3843c-724e-47ff-ae3a-9fedfa328bba')
expect(resumeSessionId('amp', 'amp threads continue T-019fda49-724e-47ff-ae3a-9fedfa328bba'))
.toBe('T-019fda49-724e-47ff-ae3a-9fedfa328bba')
expect(resumeSessionId('grok', 'grok -r 53d3843c-724e-47ff-ae3a-9fedfa328bba'))
.toBe('53d3843c-724e-47ff-ae3a-9fedfa328bba')
expect(resumeSessionId('commandcode', 'cmd -r Greeting')).toBeNull()
// Devin's ids are word slugs; a bare word is not one.
expect(resumeSessionId('devin', 'devin --resume brisk-otter')).toBe('brisk-otter')
expect(resumeSessionId('devin', 'devin -r blue-agustinia --model x')).toBe('blue-agustinia')
expect(resumeSessionId('devin', 'devin -r latest')).toBeNull()
// Hermes takes -r too; opencode's --fork names the parent, never the session.
expect(resumeSessionId('hermes', 'hermes -r 20260927_101500_ab12cd')).toBe('20260927_101500_ab12cd')
expect(resumeSessionId('opencode', 'opencode -s ses_abc --fork')).toBeNull()
})
it('reads a claude/codex resume id from argv, but never the parent of a fork', () => {
// A daemon that could not see the pane when SessionStart fired (a session named by an older build)
// has only argv to learn the session from — measured on machine-remote-1, where six agents sat
// sessionless for ten days and could not be forked.
expect(resumeSessionId('claude', '/opt/agent-cli/.local/bin/claude --resume f56f0a36-aa58-4af1-a6e2-a77386122332'))
.toBe('f56f0a36-aa58-4af1-a6e2-a77386122332')
expect(resumeSessionId('claude', 'claude --dangerously-skip-permissions -r f4749d75-aef8-4d07-8031-48e2abecf7e5'))
.toBe('f4749d75-aef8-4d07-8031-48e2abecf7e5')
expect(resumeSessionId('codex', 'node /usr/local/bin/codex resume 53d3843c-724e-47ff-ae3a-9fedfa328bba --approve-for-me'))
.toBe('53d3843c-724e-47ff-ae3a-9fedfa328bba')
// `--resume <parent> --fork-session` writes a NEW session: the id on argv is the parent's.
expect(resumeSessionId('claude', 'claude --resume 53d3843c-724e-47ff-ae3a-9fedfa328bba --fork-session')).toBeNull()
// `codex fork <parent>` likewise names the parent, and is not a resume.
expect(resumeSessionId('codex', 'codex fork 53d3843c-724e-47ff-ae3a-9fedfa328bba')).toBeNull()
expect(resumeSessionId('claude', 'claude --continue')).toBeNull()
})
it('reads the exact permission mode a live process was launched with', () => {
expect(permissionModeFromArgv('claude', '/usr/local/bin/claude --dangerously-skip-permissions')).toBe('full')
expect(permissionModeFromArgv('claude', 'claude --permission-mode plan --resume abc')).toBe('plan')
expect(permissionModeFromArgv('claude', 'claude --permission-mode=acceptEdits')).toBe('acceptEdits')
expect(permissionModeFromArgv('claude', 'claude --permission-mode auto')).toBe('auto')
expect(permissionModeFromArgv('codex', 'codex --dangerously-bypass-approvals-and-sandbox')).toBe('full')
expect(permissionModeFromArgv('codex', 'codex --sandbox read-only')).toBe('readOnly')
expect(permissionModeFromArgv('codex', 'codex resume abc --approve-for-me')).toBe('auto')
expect(permissionModeFromArgv('cursor', 'cursor-agent --force')).toBe('auto')
expect(permissionModeFromArgv('opencode', 'opencode --auto')).toBe('auto')
// Skip-everything outranks a mode named beside it: that process runs without permissions.
expect(permissionModeFromArgv('claude', 'claude --permission-mode plan --dangerously-skip-permissions')).toBe('full')
})
it('names no mode for an argv that carries none — never `ask`, never a guess', () => {
// No flag is silence, not a choice; `bypassPermission` keeps deciding there.
expect(permissionModeFromArgv('claude', 'claude --resume abc')).toBeNull()
// A mode the table does not know cannot be reapplied, so it is not recorded.
expect(permissionModeFromArgv('claude', 'claude --permission-mode bogus')).toBeNull()
expect(permissionModeFromArgv('claude', 'claude --permission-mode manual auto')).toBeNull()
expect(permissionModeFromArgv('codex', 'codex --sandbox workspace-write')).toBeNull()
// Only an exact token counts, as for the bypass flag.
expect(permissionModeFromArgv('claude', 'claude "please avoid --dangerously-skip-permissions for now"')).toBeNull()
expect(permissionModeFromArgv('claude', 'claude --dangerously-skip-permissions-explained')).toBeNull()
// Engines with no mode table.
expect(permissionModeFromArgv('pi', 'pi --dangerously-skip-permissions')).toBeNull()
expect(permissionModeFromArgv('terminal', 'zsh -l')).toBeNull()
})
it('reads bypass-permission mode from a live process argv via exact token match', () => {
expect(bypassPermissionActive('claude', '/usr/local/bin/claude --permission-mode auto')).toBe(true)
expect(bypassPermissionActive('claude', 'claude --permission-mode=auto --resume abc')).toBe(true)
expect(bypassPermissionActive('codex', 'codex resume abc --approve-for-me')).toBe(true)
// Launched before the auto modes: the old flags still count as approving (and, recorded as the
// `full` mode, come back as themselves on a relaunch).
expect(bypassPermissionActive('claude', '/usr/local/bin/claude --dangerously-skip-permissions'))
.toBe(true)
expect(bypassPermissionActive('codex', 'codex --dangerously-bypass-approvals-and-sandbox'))
.toBe(true)
expect(bypassPermissionActive('cursor', 'cursor-agent --force')).toBe(true)
expect(bypassPermissionActive('opencode', 'opencode --auto')).toBe(true)
})
it('does not false-positive on a flag that only appears as a substring', () => {
// The flag text can legitimately appear inside a prompt/argument; only an exact token counts.
expect(bypassPermissionActive('claude', 'claude "please avoid --dangerously-skip-permissions for now"'))
.toBe(false)
expect(bypassPermissionActive('claude', 'claude --dangerously-skip-permissions-explained')).toBe(false)
})
it('reads false when the confirmed flag is absent', () => {
expect(bypassPermissionActive('claude', 'claude --resume abc')).toBe(false)
// Another mode, or "auto" that is not the mode's value.
expect(bypassPermissionActive('claude', 'claude --permission-mode plan')).toBe(false)
expect(bypassPermissionActive('codex', 'codex --sandbox read-only')).toBe(false)
expect(bypassPermissionActive('claude', 'claude --permission-mode manual auto')).toBe(false)
expect(bypassPermissionActive('claude', 'claude auto --permission-mode')).toBe(false)
})
it('always reads false for engines with no confirmed bypass flag — never guesses', () => {
expect(bypassPermissionActive('pi', 'pi --dangerously-skip-permissions')).toBe(false)
expect(bypassPermissionActive('hermes', 'hermes --dangerously-skip-permissions')).toBe(false)
expect(bypassPermissionActive('devin', 'devin --dangerously-skip-permissions')).toBe(false)
})
it('maps neutral visible/history and ANSI capture options to tmux flags', () => {
expect(tmuxCaptureArgs('%7', 60)).toEqual([
'capture-pane', '-p', '-e', '-J', '-t', '%7', '-S', '-60',
])
expect(tmuxCaptureArgs('%7', 60, { visible: true, ansi: false })).toEqual([
'capture-pane', '-p', '-J', '-t', '%7',
])
})
it('carries prompt and literal bytes only over stdin, never child argv or diagnostics', async () => {
const dir = mkdtempSync(join(tmpdir(), 'harness-tmux-input-'))
const argsFile = join(dir, 'args')
const stdinFile = join(dir, 'stdin')
const fakeTmux = join(dir, 'tmux')
const sentinel = `prompt sentinel $' ${process.pid}`
writeFileSync(fakeTmux, `#!/bin/sh
printf '%s\\n' "$*" >> "$TMUX_INPUT_ARGS"
if [ "$1" = "load-buffer" ]; then
cat >> "$TMUX_INPUT_STDIN"
printf '\\n' >> "$TMUX_INPUT_STDIN"
fi
if [ "$1" = "paste-buffer" ] && [ "$TMUX_INPUT_FAIL_PASTE" = "1" ]; then
printf 'synthetic paste failure\\n' >&2
exit 2
fi
`)
chmodSync(fakeTmux, 0o700)
const previous = {
path: process.env.PATH,
args: process.env.TMUX_INPUT_ARGS,
stdin: process.env.TMUX_INPUT_STDIN,
fail: process.env.TMUX_INPUT_FAIL_PASTE,
}
const errors: string[] = []
const error = vi.spyOn(console, 'error').mockImplementation((...values) => {
errors.push(values.map(String).join(' '))
})
try {
process.env.PATH = `${dir}:${previous.path ?? ''}`
process.env.TMUX_INPUT_ARGS = argsFile
process.env.TMUX_INPUT_STDIN = stdinFile
expect(await sendLiteralToTmux('%7', sentinel)).toBe(true)
expect(await sendToTmux('%7', sentinel)).toBe(true)
process.env.TMUX_INPUT_FAIL_PASTE = '1'
expect(await sendLiteralToTmux('%7', sentinel)).toBe(false)
const argv = readFileSync(argsFile, 'utf8')
expect(argv).not.toContain(sentinel)
expect(readFileSync(stdinFile, 'utf8').split(sentinel)).toHaveLength(4)
expect(errors.join('\n')).not.toContain(sentinel)
} finally {
error.mockRestore()
if (previous.path === undefined) delete process.env.PATH
else process.env.PATH = previous.path
if (previous.args === undefined) delete process.env.TMUX_INPUT_ARGS
else process.env.TMUX_INPUT_ARGS = previous.args
if (previous.stdin === undefined) delete process.env.TMUX_INPUT_STDIN
else process.env.TMUX_INPUT_STDIN = previous.stdin
if (previous.fail === undefined) delete process.env.TMUX_INPUT_FAIL_PASTE
else process.env.TMUX_INPUT_FAIL_PASTE = previous.fail
rmSync(dir, { recursive: true, force: true })
}
})
it('brackets every submitted message and sends one separate Enter only after a successful paste', async () => {
const dir = mkdtempSync(join(tmpdir(), 'harness-tmux-submit-'))
const argsFile = join(dir, 'args')
const fakeTmux = join(dir, 'tmux')
writeFileSync(fakeTmux, `#!/bin/sh
printf '%s\\n' "$*" >> "$TMUX_SUBMIT_ARGS"
if [ "$1" = "load-buffer" ]; then cat > /dev/null; fi
if [ "$1" = "paste-buffer" ] && [ "$TMUX_SUBMIT_FAIL" = "1" ]; then exit 2; fi
`)
chmodSync(fakeTmux, 0o700)
const previous = { path: process.env.PATH, args: process.env.TMUX_SUBMIT_ARGS, fail: process.env.TMUX_SUBMIT_FAIL }
const error = vi.spyOn(console, 'error').mockImplementation(() => {})
const commands = () => readFileSync(argsFile, 'utf8').trim().split('\n')
.map(line => line.replace(/machinemsg-\d+-\d+/g, 'buffer'))
try {
process.env.PATH = `${dir}:${previous.path ?? ''}`
process.env.TMUX_SUBMIT_ARGS = argsFile
delete process.env.TMUX_SUBMIT_FAIL
for (const message of ['Testing voice.', 'Résumé 日本語.', 'first\nsecond', 'x'.repeat(501)]) {
writeFileSync(argsFile, '')
expect(await sendToTmux('%7', message)).toBe(true)
expect(commands()).toEqual([
'load-buffer -b buffer -',
'paste-buffer -t %7 -b buffer -p -d',
'send-keys -t %7 Enter',
])
}
// A clipboard paste and a live filter edit must remain unsubmitted.
writeFileSync(argsFile, '')
expect(await pasteRawIntoTmux('%7', 'clipboard')).toBe(true)
expect(await sendLiteralToTmux('%7', 'filter')).toBe(true)
expect(commands()).toEqual([
'load-buffer -b buffer -', 'paste-buffer -t %7 -b buffer -p -d',
'load-buffer -b buffer -', 'paste-buffer -t %7 -b buffer -d',
])
writeFileSync(argsFile, '')
process.env.TMUX_SUBMIT_FAIL = '1'
expect(await sendToTmux('%7', 'must not send')).toBe(false)
expect(commands()).toEqual([
'load-buffer -b buffer -', 'paste-buffer -t %7 -b buffer -p -d', 'delete-buffer -b buffer',
])
} finally {
error.mockRestore()
if (previous.path === undefined) delete process.env.PATH
else process.env.PATH = previous.path
if (previous.args === undefined) delete process.env.TMUX_SUBMIT_ARGS
else process.env.TMUX_SUBMIT_ARGS = previous.args
if (previous.fail === undefined) delete process.env.TMUX_SUBMIT_FAIL
else process.env.TMUX_SUBMIT_FAIL = previous.fail
rmSync(dir, { recursive: true, force: true })
}
})
})