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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,16 @@
All notable changes to Agentctl are documented here. Format loosely follows
[Keep a Changelog](https://keepachangelog.com); versions follow [SemVer](https://semver.org).

## [0.11.2] — 2026-10-23

### Changed

- Claude sessions now start and resume in Claude Code's **auto** permission mode (`--permission-mode auto`) instead of `--dangerously-skip-permissions`.

### Added

- Config key `permission_mode = "bypassPermissions"` opts back in to `--dangerously-skip-permissions` (Resume and the built-in `claude` tool).

## [0.11.1] — 2026-10-23

### Added
Expand Down
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ bin/agentctl Node shebang shim → dist/cli.js
- **No defensive code at boundaries that aren't boundaries**. Internal calls trust each other. Validate only at FS reads / external JSON / process.argv.
- **TDD where it helps**. New `src/core/` modules ship with `tests/core/<name>.test.ts`. UI work in `tui.tsx` gets smoke tests only — full UX validation is manual in a real terminal.
- **Cwd encoding is ambiguous**. `~/.claude/projects/<encoded-cwd>/` uses `-` as both the leading marker and the path separator. Real `-` in segment names (e.g. `My-App-Repo`) collides. `decode.ts` recursively walks candidates with `existsSync` pruning. When no match, returns `cwdDecodeConfident: false` — UI must surface this and `resume` refuses without `--cwd <override>`.
- **`--dangerously-skip-permissions` is hardcoded** into `cli/resume.ts`. Never expose a config flag to remove it. Only `resume` adds it; `peek` / read paths never do.
- **Permission mode comes from config `permission_mode`** (`auto` default → `--permission-mode auto`; `bypassPermissions` opt-in → `--dangerously-skip-permissions`), via `permissionArgs` in `core/config/defaults.ts`. Only `resume` adds it; `peek` / read paths never do.
- **Resume pins `CLAUDE_CONFIG_DIR`** to the session's own profile (`resumeEnv`). Inheriting the
ambient value resumes the transcript under whichever profile launched the menu; when two
profiles share a `projects/` dir that *succeeds silently as the wrong account* rather than
Expand Down
11 changes: 6 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ add one tool per profile:
```toml
[[tool]]
name = "work"
runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --dangerously-skip-permissions"
runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --permission-mode auto"
label = " ⚡ Work account "
```

Expand Down Expand Up @@ -259,7 +259,7 @@ color = "#6C91BF"

[[tool]] # an agent launcher: `runs` is executed in the chosen dir
name = "claude"
runs = "claude --dangerously-skip-permissions"
runs = "claude --permission-mode auto"

[[ide]] # an fzf-style ^key that opens an editor, then the tool
key = "ctrl-v"
Expand Down Expand Up @@ -288,9 +288,10 @@ names by walking the filesystem — and flagged when the result isn't certain.
- **Open source (MIT).** Read or build every line yourself.
- **Local-only. No network calls of its own, no analytics, no accounts.** It reads `~/.claude/` and the
project folders you configure, and launches your terminal. That's it.
- **`--dangerously-skip-permissions`.** The default tool commands include Claude's
`--dangerously-skip-permissions` (and Codex's sandbox-bypass) flag, because this is a launcher for your
own machine. You can change `runs` in your config to drop it.
- **Permissions.** Claude sessions start and resume in Claude Code's **auto** permission mode
(`--permission-mode auto`). To opt into bypass, set `permission_mode = "bypassPermissions"` at the top of
your config (adds `--dangerously-skip-permissions` to Resume and the built-in `claude` tool). The default
Codex tool still uses Codex's sandbox-bypass flag; change its `runs` to drop it.

## Uninstall

Expand Down
10 changes: 8 additions & 2 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,12 @@
# Optional. Which tool's launcher is primary (informational).
default_tool = "claude"

# Claude Code permission mode for the built-in claude tool and for Resume.
# "auto" (default) — Claude Code auto mode (--permission-mode auto)
# "bypassPermissions" — opt in to --dangerously-skip-permissions
# A [[tool]] `runs` line below is used verbatim; this key does not rewrite it.
# permission_mode = "auto"

# ─── dir groups ──────────────────────────────────────────────────────────────
# Each group is a section in the New screen. `path` is scanned one level deep for
# project dirs, sorted by `z` frecency (falls back to mtime). ~ and $HOME expand.
Expand All @@ -21,7 +27,7 @@ color = "#A855F7"
# executed in the chosen dir. Adding/removing a tool takes effect in a NEW shell.
[[tool]]
name = "claude"
runs = "claude --dangerously-skip-permissions"
runs = "claude --permission-mode auto"
label = " ⚡ Projects "
color = "#6C91BF"

Expand All @@ -36,7 +42,7 @@ color = "#A855F7"
# with ⇧⇥. Resume already picks the right profile on its own.
# [[tool]]
# name = "work"
# runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --dangerously-skip-permissions"
# runs = "CLAUDE_CONFIG_DIR=~/.claude2 claude --permission-mode auto"
# label = " ⚡ Work account "
# color = "#4EA37A"

Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@roypadina/agentctl",
"version": "0.11.1",
"version": "0.11.2",
"description": "Start and resume Claude Code & Codex sessions from one terminal menu — fuzzy-resume past sessions with transcript preview.",
"type": "module",
"license": "MIT",
Expand Down
6 changes: 5 additions & 1 deletion src/cli/resume.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { existsSync } from 'node:fs';
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
import { isPrimaryHome } from '../core/profiles.js';
import { loadPermissionMode } from '../core/config/loadConfig.js';
import { permissionArgs } from '../core/config/defaults.js';
import type { PermissionMode } from '../core/config/types.js';
import type { SessionRecord } from '../core/types.js';

export class ResumeError extends Error {
Expand All @@ -23,6 +26,7 @@ export interface ResumeDeps {
opts: { cwd: string; stdio: 'inherit'; env: NodeJS.ProcessEnv },
) => SpawnSyncReturns<Buffer>;
exit?: (code: number) => never;
permissionMode?: () => PermissionMode;
}

/**
Expand Down Expand Up @@ -63,7 +67,7 @@ export function resume(
const bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude';
const child = spawn(
bin,
['--resume', s.id, '--dangerously-skip-permissions'],
['--resume', s.id, ...permissionArgs((deps.permissionMode ?? loadPermissionMode)())],
{ cwd: targetCwd, stdio: 'inherit', env: resumeEnv(s) },
);
const err = child.error as NodeJS.ErrnoException | undefined;
Expand Down
2 changes: 1 addition & 1 deletion src/cli/screens/NewScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ export function NewScreen({ config, warnings, projects, configError, onSwitchTab
const i = tools.findIndex((t) => t.name === (config?.defaultTool ?? 'claude'));
return i >= 0 ? i : 0;
});
const tool = tools[toolIdx] ?? (config ? getTool(config, config.defaultTool) : { name: 'claude', runs: 'claude --dangerously-skip-permissions', label: '', color: '#6C91BF' });
const tool = tools[toolIdx] ?? (config ? getTool(config, config.defaultTool) : { name: 'claude', runs: 'claude --permission-mode auto', label: '', color: '#6C91BF' });

const [query, setQuery] = useState('');
const [cursor, setCursor] = useState(0);
Expand Down
4 changes: 3 additions & 1 deletion src/cli/tui.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { copyToClipboard, resumeCommand } from '../core/clipboard.js';
import { listProfiles, profileAccount, type Profile } from '../core/profiles.js';
import { fuzzyRank } from '../core/fuzzy.js';
import { getRecap, readCachedRecap, spawnRun } from '../core/recap.js';
import { loadPermissionMode } from '../core/config/loadConfig.js';
import { permissionArgs } from '../core/config/defaults.js';
import { windowFor, scrollbar } from './viewport.js';
import { useKeyChunk, upCount, downCount } from './useKeyChunk.js';
import {
Expand Down Expand Up @@ -63,7 +65,7 @@ export function App({ initial, onResume, onBack, onQuit, onSwitchTab }: AppProps
if (onResume) { onResume(s); return; }
exit();
const bin = process.env.AGENTCTL_CLAUDE_BIN ?? 'claude';
spawnSync(bin, ['--resume', s.id, '--dangerously-skip-permissions'], {
spawnSync(bin, ['--resume', s.id, ...permissionArgs(loadPermissionMode())], {
cwd: s.cwd, stdio: 'inherit', env: resumeEnv(s),
});
};
Expand Down
22 changes: 16 additions & 6 deletions src/core/config/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
import type { AgentctlConfig, ThemeConfig, ToolConfig } from './types.js';
import type { AgentctlConfig, PermissionMode, ThemeConfig, ToolConfig } from './types.js';

/** Hex colour fallback. */
export const DEFAULT_COLOR = '#8888aa';

export const DEFAULT_PERMISSION_MODE: PermissionMode = 'auto';

/** CLI args that start Claude Code in the given permission mode. */
export function permissionArgs(mode: PermissionMode): string[] {
return mode === 'bypassPermissions' ? ['--dangerously-skip-permissions'] : ['--permission-mode', 'auto'];
}

/** Built-in tools. Used when config omits a [[tool]] of this name. */
export const DEFAULT_TOOLS: Record<string, ToolConfig> = {
claude: { name: 'claude', runs: 'claude --dangerously-skip-permissions', label: ' ⚡ Projects ', color: '#6C91BF' },
cdx: { name: 'cdx', runs: 'codex --dangerously-bypass-approvals-and-sandbox', label: ' ✦ Codex ', color: '#A855F7' },
};
export function defaultTools(mode: PermissionMode = DEFAULT_PERMISSION_MODE): Record<string, ToolConfig> {
return {
claude: { name: 'claude', runs: ['claude', ...permissionArgs(mode)].join(' '), label: ' ⚡ Projects ', color: '#6C91BF' },
cdx: { name: 'cdx', runs: 'codex --dangerously-bypass-approvals-and-sandbox', label: ' ✦ Codex ', color: '#A855F7' },
};
}

/** Colors duplicated (NOT imported) from tui.tsx to keep core ink-free. */
export const DEFAULT_THEME: ThemeConfig = {
Expand All @@ -24,8 +33,9 @@ export const DEFAULT_RESERVED_KEYS = new Set(['enter', 'ctrl-f', 'ctrl-p', 'ctrl

export const DEFAULT_CONFIG: AgentctlConfig = {
groups: [],
tools: [DEFAULT_TOOLS.claude, DEFAULT_TOOLS.cdx],
tools: Object.values(defaultTools()),
ides: [],
defaultTool: 'claude',
permissionMode: DEFAULT_PERMISSION_MODE,
theme: DEFAULT_THEME,
};
14 changes: 11 additions & 3 deletions src/core/config/loadConfig.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ import { readFileSync, statSync } from 'node:fs';
import { parse, TomlError } from 'smol-toml';
import { configPath } from './paths.js';
import { validateConfig } from './validate.js';
import { DEFAULT_CONFIG, DEFAULT_RESERVED_KEYS, DEFAULT_TOOLS, DEFAULT_COLOR } from './defaults.js';
import { DEFAULT_CONFIG, DEFAULT_RESERVED_KEYS, DEFAULT_PERMISSION_MODE, defaultTools, DEFAULT_COLOR } from './defaults.js';
import {
ConfigError,
type AgentctlConfig,
type LoadConfigResult,
type PermissionMode,
type ToolConfig,
} from './types.js';

Expand Down Expand Up @@ -75,7 +76,7 @@ export function loadConfig(opts: LoadConfigOptions = {}): LoadConfigResult {
const { config, warnings } = validateConfig(raw, { reservedKeys });
// Always guarantee claude/cdx exist as launchers.
if (config.tools.length === 0) {
config.tools = [DEFAULT_TOOLS.claude, DEFAULT_TOOLS.cdx];
config.tools = Object.values(defaultTools(config.permissionMode));
}

const result: LoadConfigResult = {
Expand All @@ -94,7 +95,8 @@ export function loadConfig(opts: LoadConfigOptions = {}): LoadConfigResult {
export function getTool(config: AgentctlConfig, name: string): ToolConfig {
const found = config.tools.find(t => t.name === name);
if (found) return found;
if (DEFAULT_TOOLS[name]) return DEFAULT_TOOLS[name];
const builtin = defaultTools(config.permissionMode)[name];
if (builtin) return builtin;
return { name, runs: name, label: ` ${name} `, color: DEFAULT_COLOR };
}

Expand All @@ -104,6 +106,12 @@ function structuredCloneConfig(c: AgentctlConfig): AgentctlConfig {
tools: c.tools.map(t => ({ ...t })),
ides: c.ides.map(i => ({ ...i })),
defaultTool: c.defaultTool,
permissionMode: c.permissionMode,
theme: { ...c.theme },
};
}

/** Permission mode for resume paths. A broken config must not block resuming → safe default. */
export function loadPermissionMode(): PermissionMode {
try { return loadConfig().config.permissionMode; } catch { return DEFAULT_PERMISSION_MODE; }
}
7 changes: 6 additions & 1 deletion src/core/config/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface GroupConfig {
export interface ToolConfig {
/** Generates a launcher command of this name (claude, cdx, …). */
name: string;
/** Shell command line run in the chosen dir (e.g. "claude --dangerously-skip-permissions"). */
/** Shell command line run in the chosen dir (e.g. "claude --permission-mode auto"). */
runs: string;
/** fzf-style border label. */
label: string;
Expand All @@ -37,11 +37,15 @@ export interface ThemeConfig {
statusInactive: string;
}

/** Claude Code permission mode agentctl launches/resumes with. */
export type PermissionMode = 'auto' | 'bypassPermissions';

export interface AgentctlConfig {
groups: GroupConfig[];
tools: ToolConfig[];
ides: IdeConfig[];
defaultTool: string;
permissionMode: PermissionMode;
theme: ThemeConfig;
}

Expand All @@ -53,6 +57,7 @@ export type ConfigWarningCode =
| 'missing-group-path'
| 'dup-tool-name'
| 'unknown-default-tool'
| 'bad-permission-mode'
| 'unknown-var';

export interface ConfigWarning {
Expand Down
11 changes: 10 additions & 1 deletion src/core/config/validate.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { expandPath } from './paths.js';
import { DEFAULT_COLOR, DEFAULT_THEME } from './defaults.js';
import { DEFAULT_COLOR, DEFAULT_PERMISSION_MODE, DEFAULT_THEME } from './defaults.js';
import {
ConfigError,
type AgentctlConfig,
type ConfigWarning,
type GroupConfig,
type IdeConfig,
type PermissionMode,
type ThemeConfig,
type ToolConfig,
} from './types.js';
Expand Down Expand Up @@ -135,11 +136,19 @@ export function validateConfig(
defaultTool = tools[0].name;
}

let permissionMode: PermissionMode = DEFAULT_PERMISSION_MODE;
if (raw.permission_mode === 'auto' || raw.permission_mode === 'bypassPermissions') {
permissionMode = raw.permission_mode;
} else if (raw.permission_mode !== undefined) {
warnings.push({ code: 'bad-permission-mode', message: `permission_mode "${String(raw.permission_mode)}" must be "auto" or "bypassPermissions" → "auto"` });
}

const config: AgentctlConfig = {
groups,
tools,
ides,
defaultTool,
permissionMode,
theme: buildTheme(raw.theme),
};
return { config, warnings };
Expand Down
14 changes: 11 additions & 3 deletions tests/cli/resume.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,22 +45,30 @@ describe('resume', () => {
).toThrow(/busy/);
});

it('spawns claude with --resume and --dangerously-skip-permissions', () => {
it('spawns claude with --resume and --permission-mode auto by default', () => {
const spawn = vi.fn(() => ({ status: 0, error: undefined })) as never;
const exit = vi.fn();
resume(record(), {}, { exists: () => true, spawn, exit: exit as never });
resume(record(), {}, { exists: () => true, spawn, exit: exit as never, permissionMode: () => 'auto' });
expect(spawn).toHaveBeenCalledOnce();
const call = (spawn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
expect(call[0]).toBe('claude');
expect(call[1]).toEqual([
'--resume',
'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa',
'--dangerously-skip-permissions',
'--permission-mode',
'auto',
]);
expect((call[2] as { cwd: string }).cwd).toBe('/tmp');
expect(exit).toHaveBeenCalledWith(0);
});

it('opt-in bypassPermissions resumes with --dangerously-skip-permissions', () => {
const spawn = vi.fn(() => ({ status: 0, error: undefined })) as never;
resume(record(), {}, { exists: () => true, spawn, exit: vi.fn() as never, permissionMode: () => 'bypassPermissions' });
const call = (spawn as unknown as { mock: { calls: unknown[][] } }).mock.calls[0];
expect(call[1]).toEqual(['--resume', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', '--dangerously-skip-permissions']);
});

it('throws ResumeError(127) when claude binary missing', () => {
const enoent = new Error('ENOENT') as NodeJS.ErrnoException;
enoent.code = 'ENOENT';
Expand Down
1 change: 1 addition & 0 deletions tests/cli/screens.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ const config: AgentctlConfig = {
tools: [{ name: 'claude', runs: 'claude --x', label: ' c ', color: '#6C91BF' }],
ides: [{ key: 'ctrl-v', label: 'code', cmd: 'code "$dir"' }],
defaultTool: 'claude',
permissionMode: 'auto',
theme: { accent: '#FF9F43', border: '#6C91BF', pointer: '#FF9F43', statusBusy: 'green', statusIdle: 'yellow', statusInactive: 'gray' },
};
const dir = (name: string, group: string, color: string): ProjectDir =>
Expand Down
19 changes: 18 additions & 1 deletion tests/core/config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,24 @@ describe('getTool', () => {
const p = cfg(`[[tool]]\nname="foo"\nruns="foo --go"\n`);
const { config } = loadConfig({ path: p });
expect(getTool(config, 'foo').runs).toBe('foo --go');
expect(getTool(config, 'claude').runs).toBe('claude --dangerously-skip-permissions');
expect(getTool(config, 'claude').runs).toBe('claude --permission-mode auto');
expect(getTool(config, 'bar').runs).toBe('bar'); // synthesized
});
});

describe('permission_mode', () => {
it('defaults to auto', () => {
const { config } = loadConfig({ path: cfg('') });
expect(config.permissionMode).toBe('auto');
});
it('bypassPermissions opts in and drives the built-in claude tool', () => {
const { config } = loadConfig({ path: cfg('permission_mode = "bypassPermissions"\n') });
expect(config.permissionMode).toBe('bypassPermissions');
expect(getTool(config, 'claude').runs).toBe('claude --dangerously-skip-permissions');
});
it('invalid value warns and falls back to auto', () => {
const { config, warnings } = loadConfig({ path: cfg('permission_mode = "yolo"\n') });
expect(config.permissionMode).toBe('auto');
expect(warnings.map(w => w.code)).toContain('bad-permission-mode');
});
});
Loading