Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
cb997fd
Probe: Claude's documented control requests over the managed stream-j…
raiseCatError Oct 9, 2026
5d02198
Markdown for untrusted text: blocks, inline styles, code highlighting…
raiseCatError Oct 9, 2026
45ad1eb
Markdown: reveal real link destinations, refuse smuggled characters, …
raiseCatError Oct 9, 2026
4a012ed
Managed Claude: a control plane and runtime telemetry with provenance
raiseCatError Oct 9, 2026
711d6fe
Agent results: read a result only as the tool that produced it; offer…
raiseCatError Oct 9, 2026
e01e66b
Managed Claude: native /model and /effort pickers
raiseCatError Oct 9, 2026
cb1551f
Claude settings reader: only a bounded regular file is read
raiseCatError Oct 9, 2026
a580908
Claude mods: real types and states, toggles through Claude's own comm…
raiseCatError Oct 9, 2026
7a88a58
Agent view: a right panel of what the target is doing to its context …
raiseCatError Oct 9, 2026
2dad06c
Design: agent workspace controls, Claude mods and the context panel
raiseCatError Oct 9, 2026
35dfc0f
Probe: live /model and /effort through NMSh's managed session against…
raiseCatError Oct 9, 2026
1fd0eb5
Probe: live plugin toggle inside an NMSh-managed session, in a dispos…
raiseCatError Oct 9, 2026
68bb3d4
Probe guard: only /tmp/nmsh-cc-probe, never a real Claude configurati…
raiseCatError Oct 9, 2026
8979ca6
Probes: refuse every ~/.claude-* identity without naming anyone's acc…
raiseCatError Oct 10, 2026
ebdb1ad
Test comment: describe the two-account fixture without personal names
raiseCatError Oct 10, 2026
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
85 changes: 85 additions & 0 deletions docs/design/agent-workspace-controls.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# Agent workspace: native controls, Claude mods and the context panel

Continues [Managed Targets, agent UI, and unified mods](claude-managed-targets-agent-ui.md). Verified against
Claude Code 2.1.295 (2026-10-08) and its documentation on 2026-10-09.

## Principles

- **NMSh owns its interface; Claude owns its state.** Pickers, lists, confirmation and feedback are NMSh's. Every
change goes through a mechanism Claude documents: control requests on the managed stream-json channel, or Claude's
own `claude plugin …` commands. NMSh never edits Claude's settings files and never types into Claude's text menus.
- **Nothing is claimed without evidence.** Each fact carries its provenance (`provider`, `requested`, `observed`,
`estimate`) and age. A change is "requested" until Claude reports it; an estimate says it is one; an unreported value
is said in words, never shown as 0% or a guessed level.
- **A command beginning with `/` is not automatically NMSh's.** `isSlashInput()` is the single rule: a first word with
a `/` after the leading one is a path and belongs to the shell. In a managed agent composer only exact NMSh forms
(`/model`, `/effort`, `/panel`, `/context`, `/approval`, `/copy`) are intercepted; a leading space sends text verbatim.

## Mechanisms

| Need | Mechanism | Takes effect |
|---|---|---|
| Model | control request `set_model` | from Claude's next model call, mid-turn included; confirmed when `system/init` reports it |
| Effort | `apply_flag_settings {effortLevel}` (`null` = model default) | next turn, this session only |
| Launch effort | `--effort` flag from a launch profile (only when `--help` documents it) | the whole session |
| Context breakdown | `get_context_usage` (`summary`) | on request (`/context`) |
| Plugin inventory | `claude plugin list --json` per launch identity (`CLAUDE_CONFIG_DIR`) | read |
| Plugin contents | manifest files read as bounded JSON: `.claude-plugin/plugin.json`, `hooks/hooks.json`, component folders | read; nothing imported or run |
| Enable / disable | `claude plugin enable\|disable <id> --scope <installed scope> --json` | settings; then `reload_plugins` to running managed sessions of that account |
| Inspect | `claude plugin details <id>`, `claude plugin validate <dir>` (static) | read |

Claude's `plugin enable` accepts an id that is not installed and writes it into settings. NMSh only sends ids from the
current listing, and reads the listing again afterwards as evidence.

## What is operational, what is representation, what is unsupported

**Operational (real provider effect, verified by tests against the documented wire format and by the 2.1.295 probe):**

- `/model` picker and `/model <name>`: `set_model`, acknowledgement awaited, refusal kept beside the rows.
- `/effort` picker and `/effort <level>`: `apply_flag_settings`, only the running model's `supportedEffortLevels`.
- `/context`: Claude's own breakdown in the right panel (categories, headroom to auto-compact).
- Plugin enable/disable at the installed scope and account, with listing re-read and live `reload_plugins`.
- Absolute executable paths in the shell composer, verified end to end on zsh, Bash and Fish.

**Representation of provider facts (display only, with provenance):**

- Header model and effort; right panel context meter (estimate between breakdowns), plan (TodoWrite), files read and
edited with line counts, subagents and tasks, compactions, usage limits near their cap, estimated cost.
- Plugin type, components, dependencies, scope conflicts, project overrides, "Loaded in" (from a target's own report).
- What a plugin does in a managed session: per Claude's docs, under `claude -p` a mod's hooks run but nothing it draws
appears (skins, filetree panes, terminal-browser, replay-theater's viewer); skills, agents, commands, settings hooks,
MCP and LSP servers load as usual.

**Unsupported, said in the UI rather than faked:**

- The effort level in effect is never reported by headless Claude; NMSh shows the acknowledged request, launch flag or
settings value, labelled as such.
- Personal skills (`skills/` folder) and settings hooks have no Claude command to toggle; they are listed with the reason.
- A plugin decided by the project's `.claude/settings(.local).json` is not toggled at user scope; the override is named.
- Mod panes, bands and restyled rows: not hosted by NMSh (no NMSh sandbox exists or is implied).
- Claude's own terminal sessions see a plugin change after `/reload-plugins` or a restart.

## Research conclusions applied

Two research passes (coding-agent interfaces and context tooling; workspace navigation and TUI craft) are summarized in
the PR. The decisions they drove:

- **Two tiers of context.** A quiet pressure snapshot (bar, used of window, headroom, source and age) always in the
panel; the attribution report only on request. Never `null → 0%` (Claude HUD #330/#576); name the source because
Claude's own figures differ by denominator (CC #63015).
- **Model and effort pickers converge on one shape** (Claude Code, Codex): catalog rows with current/default marks,
per-model effort levels, explicit timing. NMSh adds provenance and keeps Enter as the only action.
- **Show the target of every file activity** (CC #21151): the panel lists paths with `+a -r` or `read`.
- **Plan only when the provider emits one**: task tools are off by default on 5.5-generation models.
- **Panels give way before the conversation does.** Right panel by default from 120 columns, on request from 72,
bounded to 30–46 columns; below that the conversation is full width. Breakpoints follow Herdr and lazygit practice.
- **State before decoration.** No transcript parsing or network on the render path (status-line tools' CPU and 429
storms); everything comes from events NMSh already receives.

## Next steps (not in this change)

- Left workspace sidebar (projects, shell sessions, managed targets, worktrees, PRs) with Herdr's state vocabulary
(blocked / working / done-unseen / idle / unknown, worst-state roll-up) and selection ≠ preview ≠ activation.
- Per-turn diff source in the right panel from the provider's `structuredPatch` hunks already retained.
- MCP server status and session toggles in the panel (`mcp_status`, `mcp_toggle` are implemented in the adapter).
- Prompt-to-prompt navigation (`{` / `}`) in the agent transcript, matching Claude Code's transcript view.
151 changes: 151 additions & 0 deletions scripts/probes/claude-controls.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/**
* Disposable probe of Claude Code's documented control requests over the same stream-json transport NMSh's managed
* adapter uses (the requests the official Agent SDK's Query methods send: initialize, get_context_usage, mcp_status,
* set_model, apply_flag_settings, set_permission_mode). It records shapes, acknowledgements and the runtime facts
* that confirm each change; it never prints account identity values, credentials or prompts beyond fixed probe text.
*
* NMSH_PROBE_CLAUDE=/path/to/claude CLAUDE_CONFIG_DIR=/path/to/config node scripts/probes/claude-controls.mjs
*/
import {spawn, spawnSync} from 'node:child_process';
import {mkdtempSync, writeFileSync, readFileSync, rmSync} from 'node:fs';
import {join} from 'node:path';
import {tmpdir} from 'node:os';
import {randomUUID} from 'node:crypto';

const executable = process.env.NMSH_PROBE_CLAUDE ?? 'claude';
const version = spawnSync(executable, ['--version'], {encoding: 'utf8', timeout: 5000}).stdout.trim();
const root = mkdtempSync(join(tmpdir(), 'nmsh-claude-controls-'));
const facts = {version, steps: []};
const note = (step, value) => { facts.steps.push({step, ...value}); console.log(JSON.stringify({step, ...value})); };

const argv = ['--print', '--input-format', 'stream-json', '--output-format', 'stream-json', '--verbose',
'--permission-prompts', 'host', '--permission-prompt-tool', 'stdio', '--setting-sources', '', '--strict-mcp-config',
'--mcp-config', '{"mcpServers":{}}', '--max-budget-usd', '0.5', '--safe-mode', '--no-session-persistence',
'--include-partial-messages', '--model', 'haiku', '--tools', 'Read,Edit'];

writeFileSync(join(root, 'fixture.txt'), 'alpha\nbeta\ngamma\n', {mode: 0o600});
const child = spawn(executable, argv, {cwd: root, stdio: ['pipe', 'pipe', 'pipe']});
const kill = setTimeout(() => { note('timeout', {}); child.kill('SIGKILL'); }, 150000);
let stderr = '';
child.stderr.on('data', chunk => { stderr = (stderr + chunk).slice(-2000); });

const pending = new Map();
const waiters = [];
const seen = [];
let buffer = '';
child.stdout.on('data', chunk => {
buffer += chunk;
let index;
while ((index = buffer.indexOf('\n')) >= 0) {
const line = buffer.slice(0, index); buffer = buffer.slice(index + 1);
let event; try { event = JSON.parse(line); } catch { continue; }
seen.push(event);
if (event.type === 'control_response') {
const waiter = pending.get(event.response?.request_id);
if (waiter) { pending.delete(event.response.request_id); waiter(event.response); }
} else if (event.type === 'control_request' && event.request?.subtype === 'can_use_tool') {
note('permission-request', {tool: event.request.tool_name, inputKeys: Object.keys(event.request.input ?? {})});
child.stdin.write(JSON.stringify({type: 'control_response', response: {subtype: 'success', request_id: event.request_id,
response: {behavior: 'allow', updatedInput: event.request.input}}}) + '\n');
}
for (const waiter of [...waiters]) if (waiter.match(event)) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(event); }
}
});

const request = (body, timeout = 30000) => new Promise(resolve => {
const id = randomUUID();
const timer = setTimeout(() => { pending.delete(id); resolve({subtype: 'timeout'}); }, timeout);
pending.set(id, response => { clearTimeout(timer); resolve(response); });
child.stdin.write(JSON.stringify({type: 'control_request', request_id: id, request: body}) + '\n');
});
const until = (match, timeout = 90000) => new Promise(resolve => {
const waiter = {match, resolve};
waiters.push(waiter);
setTimeout(() => { const at = waiters.indexOf(waiter); if (at >= 0) { waiters.splice(at, 1); resolve(undefined); } }, timeout);
});
const turn = async text => {
const start = seen.length;
child.stdin.write(JSON.stringify({type: 'user', message: {role: 'user', content: [{type: 'text', text}]}}) + '\n');
const result = await until(event => event.type === 'result');
return {events: seen.slice(start), result};
};
const shape = value => value && typeof value === 'object' ? Object.keys(value).sort() : typeof value;
const summarizeTurn = ({events, result}) => {
const types = [...new Set(events.map(event => `${event.type}${event.subtype ? `/${event.subtype}` : ''}`))];
const init = events.find(event => event.type === 'system' && event.subtype === 'init');
const streamKinds = [...new Set(events.filter(event => event.type === 'stream_event').map(event => event.event?.type))];
const assistantModels = [...new Set(events.filter(event => event.type === 'assistant').map(event => event.message?.model))];
const toolResults = events.filter(event => event.type === 'user' && event.tool_use_result !== undefined).map(event => shape(event.tool_use_result));
const patch = events.find(event => event.type === 'user' && event.tool_use_result?.structuredPatch)?.tool_use_result?.structuredPatch;
const rateLimits = events.filter(event => event.type === 'rate_limit_event').map(event => ({status: event.rate_limit_info?.status,
type: event.rate_limit_info?.rateLimitType, utilization: event.rate_limit_info?.utilization, hasReset: typeof event.rate_limit_info?.resetsAt === 'number'}));
return {types, init: init && {model: init.model, effort: init.effort, permissionMode: init.permissionMode, capabilities: init.capabilities,
slashCommands: init.slash_commands?.length, tools: init.tools, outputStyle: init.output_style, viewMode: init.view_mode},
streamKinds, assistantModels, toolResults, patch, rateLimits,
result: result && {subtype: result.subtype, isError: result.is_error, numTurns: result.num_turns, costUsd: result.total_cost_usd,
usage: result.usage && {input: result.usage.input_tokens, output: result.usage.output_tokens, cacheRead: result.usage.cache_read_input_tokens, cacheWrite: result.usage.cache_creation_input_tokens},
modelUsage: result.modelUsage && Object.fromEntries(Object.entries(result.modelUsage).map(([model, usage]) => [model, {contextWindow: usage.contextWindow,
maxOutputTokens: usage.maxOutputTokens, input: usage.inputTokens, output: usage.outputTokens, cacheRead: usage.cacheReadInputTokens}])),
text: typeof result.result === 'string' ? result.result.slice(0, 120) : undefined}};
};

try {
const init = await request({subtype: 'initialize', hooks: {}});
const body = init.response ?? {};
note('initialize', {ack: init.subtype, keys: shape(body),
models: (body.models ?? []).map(model => ({value: model.value, resolved: model.resolvedModel, name: model.displayName, effort: model.supportsEffort,
levels: model.supportedEffortLevels, fast: model.supportsFastMode, auto: model.supportsAutoMode})),
commands: {count: body.commands?.length, sample: (body.commands ?? []).slice(0, 60).map(command => `${command.name}${command.builtin ? '*' : ''}${command.argumentHint ? ` ${command.argumentHint}` : ''}`)},
agents: (body.agents ?? []).map(agent => agent.name), outputStyle: body.output_style, styles: body.available_output_styles,
accountKeys: shape(body.account), apiProvider: body.account?.apiProvider, subscriptionType: body.account?.subscriptionType});

const usageBefore = await request({subtype: 'get_context_usage', detail: 'summary'});
const usage = usageBefore.response ?? {};
note('context-before-turn', {ack: usageBefore.subtype, error: usageBefore.error, keys: shape(usage), totalTokens: usage.totalTokens, maxTokens: usage.maxTokens,
rawMaxTokens: usage.rawMaxTokens, percentage: usage.percentage, model: usage.model, autoCompactThreshold: usage.autoCompactThreshold, isAutoCompactEnabled: usage.isAutoCompactEnabled,
categories: (usage.categories ?? []).map(category => ({name: category.name, tokens: category.tokens, kind: category.kind})),
memoryFiles: (usage.memoryFiles ?? []).length, mcpTools: (usage.mcpTools ?? []).length, systemTools: (usage.systemTools ?? []).length,
skills: usage.skills && {total: usage.skills.totalSkills, included: usage.skills.includedSkills, tokens: usage.skills.tokens}, apiUsage: usage.apiUsage});

const mcp = await request({subtype: 'mcp_status'});
note('mcp-status', {ack: mcp.subtype, keys: shape(mcp.response), servers: (mcp.response?.mcpServers ?? []).map(server => ({name: server.name, status: server.status}))});

const first = await turn('Reply with exactly: PROBE-ONE');
note('turn-haiku', summarizeTurn(first));

const model = await request({subtype: 'set_model', model: 'sonnet'});
note('set-model', {ack: model.subtype, error: model.error, response: model.response});
const effort = await request({subtype: 'apply_flag_settings', settings: {effortLevel: 'low'}});
note('apply-effort', {ack: effort.subtype, error: effort.error, response: effort.response});
const mode = await request({subtype: 'set_permission_mode', mode: 'acceptEdits'});
note('set-permission-mode', {ack: mode.subtype, error: mode.error, response: mode.response});

const second = await turn('Read fixture.txt, then use Edit to replace beta with BETA. Reply DONE when finished.');
note('turn-after-changes', summarizeTurn(second));
note('fixture-after', {content: readFileSync(join(root, 'fixture.txt'), 'utf8')});

const usageAfter = await request({subtype: 'get_context_usage', detail: 'summary'});
const after = usageAfter.response ?? {};
note('context-after-turns', {ack: usageAfter.subtype, totalTokens: after.totalTokens, maxTokens: after.maxTokens, percentage: after.percentage, model: after.model,
categories: (after.categories ?? []).map(category => ({name: category.name, tokens: category.tokens, kind: category.kind})),
messageBreakdown: after.messageBreakdown && {toolCall: after.messageBreakdown.toolCallTokens, toolResult: after.messageBreakdown.toolResultTokens,
assistant: after.messageBreakdown.assistantMessageTokens, user: after.messageBreakdown.userMessageTokens,
byType: after.messageBreakdown.toolCallsByType}, apiUsage: after.apiUsage});

const slash = await turn('/context');
note('slash-context', {types: [...new Set(slash.events.map(event => `${event.type}${event.subtype ? `/${event.subtype}` : ''}`))],
localOutput: slash.events.filter(event => event.subtype === 'local_command_output' || event.type === 'local_command_output').map(event => shape(event)),
result: slash.result && {subtype: slash.result.subtype, isError: slash.result.is_error, costUsd: slash.result.total_cost_usd, text: String(slash.result.result ?? '').slice(0, 160)}});

const reset = await request({subtype: 'apply_flag_settings', settings: {effortLevel: null}});
note('reset-effort', {ack: reset.subtype, error: reset.error});
} catch (error) {
note('error', {message: String(error?.message ?? error).slice(0, 300)});
} finally {
child.stdin.end();
await new Promise(resolve => { child.once('close', resolve); setTimeout(resolve, 8000).unref(); });
clearTimeout(kill);
child.kill('SIGKILL');
if (stderr.trim()) note('stderr-tail', {text: stderr.slice(-400)});
rmSync(root, {recursive: true, force: true});
}
Loading
Loading