Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
4a9be9d
refactor(platform): one Freshell context env builder for terminals an…
danshapiro Oct 23, 2026
1b2e7aa
fix(claude-sidecar): hosted kilroy gets the managed spawn check and t…
danshapiro Oct 23, 2026
bfb88e9
feat(claude-sidecar): hand query() the ordinary Freshell MCP recipe a…
danshapiro Oct 23, 2026
b786894
feat(protocol): freshAgent create and attach name the pane they run for
danshapiro Oct 23, 2026
4ba6154
feat(freshclaude): every sidecar launch gets the Freshell MCP, its en…
danshapiro Oct 23, 2026
2383d12
feat(server): wire the Freshell MCP for freshclaude/kilroy from the l…
danshapiro Oct 23, 2026
01a12f5
feat(client): fresh-agent create and attach name their pane
danshapiro Oct 23, 2026
fcdf611
test(claude-sidecar): pin the Freshell MCP environment and argv contr…
danshapiro Oct 23, 2026
9637631
test(claude-cli-sim): run stdio MCP servers the way the pinned CLI does
danshapiro Oct 23, 2026
e376dac
test(claude-cli-sim): a program's text step can repeat the last tool …
danshapiro Oct 23, 2026
5a91a15
test(e2e): a freshclaude/kilroy agent uses the Freshell MCP; the Sett…
danshapiro Oct 23, 2026
1f1d7fa
docs(readme): Freshclaude and Kilroy agents get the Freshell MCP server
danshapiro Oct 23, 2026
a11b23b
test(freshclaude): drop a no-op struct update in the MCP launch rig
danshapiro Oct 23, 2026
f6337aa
test(freshclaude): MCP launch tests wait for each turn on disk and fo…
danshapiro Oct 23, 2026
9e980c9
test(claude-sidecar): the Freshell MCP contract polls the transcript …
danshapiro Oct 23, 2026
679bcf8
refactor(freshclaude): resolve the Freshell MCP recipe before spawnin…
danshapiro Oct 23, 2026
01e4c57
test(e2e): the Freshell MCP spec checks the server's tab list, not th…
danshapiro Oct 23, 2026
6280c0f
fix(freshclaude): a handoff resume runs the CLI for the pane that ado…
danshapiro Oct 23, 2026
cf72cdd
fix(claude-sidecar): a malformed Freshell MCP recipe is logged, not d…
danshapiro Oct 23, 2026
ea15f63
test(freshclaude): the launch tests share the rig's query, turn and s…
danshapiro Oct 23, 2026
ab3cd3b
test(e2e): the agent finds its own tab through the Freshell MCP and s…
danshapiro Oct 23, 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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,13 @@ are separate resources. Claude fresh-agent panes use the isolated
`crates/freshell-claude-sidecar` package, which wraps the Claude SDK over
newline-delimited JSON on stdin/stdout. The sidecar is not a network service.

Freshclaude and Kilroy panes give their agent the same Freshell MCP server, so
the agent can list and drive tabs and panes; its default target is the pane it
runs in. The **Agent MCP server** switch (Settings → Advanced) controls this:
turning it off removes the server from Freshclaude and Kilroy agents started or
resumed after the change, with no restart. Terminal panes do not follow the
switch yet.

### Rust server scope

The Rust server supports the browser UI, terminal and session workflows, the
Expand Down
8 changes: 0 additions & 8 deletions crates/freshell-claude-sidecar/hosted-mcp.mjs

This file was deleted.

18 changes: 11 additions & 7 deletions crates/freshell-claude-sidecar/hosted.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@
// which hosted rules apply. The ONE reader of FRESHELL_HOSTED_FRESH_AGENT:
// the session host sets it to the provider it hosts, which for this runtime
// is `claude` (freshclaude) or `kilroy` (crates/freshell-supervisor/src/
// backend.rs). Two rules read it, and they differ on purpose:
// backend.rs). Two rules read it:
// - every hosted sidecar logs CLI output by size only (isHostedSidecar);
// - the managed spawn check and the fixed hosted MCP recipe apply to hosted
// claude alone (usesHostedClaudeRuntime). Extending them to hosted kilroy
// is an explicit item of the api-hosted-ops workstream
// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4).
// - the managed spawn check and the fixed hosted MCP recipe apply to every
// hosted Claude runtime, freshclaude and kilroy alike
// (usesHostedClaudeRuntime): both run the same Claude CLI inside the same
// managed enclosure, and kilroy consumes the Claude launch recipes
// (FreshProvider::native_provider in freshell-runtime-protocol).

/** The providers whose hosted sidecar runs the Claude CLI. */
const HOSTED_CLAUDE_RUNTIMES = new Set(['claude', 'kilroy'])

/** The provider the session host hosts, or undefined outside a session host. */
export function hostedProvider(env = process.env) {
Expand All @@ -19,7 +23,7 @@ export function isHostedSidecar(env = process.env) {
return hostedProvider(env) !== undefined
}

/** Hosted claude: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */
/** Hosted claude or kilroy: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */
export function usesHostedClaudeRuntime(env = process.env) {
return hostedProvider(env) === 'claude'
return HOSTED_CLAUDE_RUNTIMES.has(hostedProvider(env))
}
29 changes: 17 additions & 12 deletions crates/freshell-claude-sidecar/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,17 @@
// { type:'create', protocol:2, requestId, sessionType?, sessionId?,
// boot:{kind:'fresh'} | {kind:'resume', resume}
// | {kind:'fork', resume, resumeSessionAt?, dropsTurn?},
// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp? }
// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp?,
// freshellMcp?:{command,args}, freshellContext?:{tabId?,paneId?} }
// — `boot` picks the conversation the CLI boots (bootOptions): a
// fork keeps the parent through-AND-including resumeSessionAt
// over the raw parentUuid chain, writes a NEW conversation and
// never rewrites the parent's JSONL; dropsTurn arms the SDK's
// fork-time discard guard. `sessionId` is the id Rust minted for
// a fresh or forked boot, when it minted one. One sidecar hosts
// one session: a second create is refused.
// a fresh or forked boot, when it minted one. `hostedMcp` and
// `freshellMcp` pick the Freshell MCP recipe (mcp-servers.mjs);
// `freshellContext` names the pane the CLI's environment carries.
// One sidecar hosts one session: a second create is refused.
// { type:'configure', sessionId, requestId, settings }
// { type:'send', sessionId, text, images? }
// { type:'interrupt', sessionId }
Expand Down Expand Up @@ -111,12 +114,14 @@
// nothing. If the PROCESS itself dies mid-turn, stdout simply ends with no
// `sdk.stream.ended` — the one end the Rust side rings for itself.
//
// Scope discipline (ADR "keep it minimal — only what the claude T2 invariant set
// needs"): the freshell MCP-server injection (createClaudeSdkMcpServers) is
// DELIBERATELY OMITTED — a pinned pure-text T2 turn never calls a tool, MCP tools
// are not in the T2 baseline, and injecting the MCP server would spawn an extra
// node grandchild bound to the REST API. The interactive permission/question
// RESPONSE channel IS in scope (permission-channel.mjs, a faithful port of
// The Freshell MCP server (G19): the create line names the recipe, the hosted
// one or the ordinary one the Rust server resolved while
// settings.codingCli.mcpServer is on (mcp-servers.mjs; never both, never an env
// key, since the SDK puts the MCP config on the CLI's argv). The endpoint and
// token reach the MCP child through the inherited environment the Rust server
// set on this process, and the pane context (`freshellContext`) is merged into
// the SDK env here. The interactive permission/question RESPONSE channel IS in
// scope (permission-channel.mjs, a faithful port of
// server/sdk-bridge.ts): canUseTool parks a pending request, surfaces
// sdk.permission.request / sdk.question.request, and waits for the Rust side's
// permission.respond / question.respond (unknown respond ids are stderr-log-only
Expand All @@ -131,7 +136,7 @@ import { createTurnCompleteGate } from './turn-complete-gate.mjs'
import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs'
import { nextMonotonic } from './monotonic-clock.mjs'
import { managedSdkSpawn } from './exec-profile.mjs'
import { hostedMcpServers } from './hosted-mcp.mjs'
import { mcpServersFor, withFreshellContext } from './mcp-servers.mjs'
import { cliStderrLogger, errorText, log } from './log.mjs'
import { isHostedSidecar } from './hosted.mjs'
import { STREAM_ENDED_EXIT_CODE, streamEnd } from './stream-end.mjs'
Expand Down Expand Up @@ -558,15 +563,15 @@ function handleCreate(req) {
plugins: Array.isArray(req.plugins)
? req.plugins.map(path => ({ type: 'local', path }))
: undefined,
mcpServers: hostedMcpServers(req.hostedMcp),
mcpServers: mcpServersFor(req, process.env, { fields: { sessionId } }),
pathToClaudeCodeExecutable: process.env.CLAUDE_CMD || undefined,
includePartialMessages: true,
// This is the SDK's native persisted-user witness. Without the replay
// flag a supplied SDKUserMessage UUID is not surfaced back to the
// sidecar, so checkpoint identity must remain unbound.
extraArgs: { 'replay-user-messages': null },
abortController: abort,
env: createClaudeSdkCleanEnv(process.env),
env: withFreshellContext(createClaudeSdkCleanEnv(process.env), req.freshellContext),
settingSources: ['user', 'project', 'local'],
spawnClaudeCodeProcess: managedSdkSpawn(sdkStderr),
stderr: sdkStderr,
Expand Down
74 changes: 74 additions & 0 deletions crates/freshell-claude-sidecar/mcp-servers.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// mcp-servers.mjs — the MCP servers a session's query() gets, and the pane
// context its CLI environment carries (api-hosted-ops plan §2.4, G19).
//
// A session gets at most one Freshell MCP recipe:
// - hosted: inside a hosted Claude runtime (hosted.mjs), the session host's
// fixed command, when the lane selected it (`create.hostedMcp`). A hosted
// sidecar never gets the ordinary recipe;
// - ordinary: outside a session host, the command the Rust server resolved
// for this host while `settings.codingCli.mcpServer` is on
// (`create.freshellMcp`, `{ command, args }`).
//
// A recipe never carries an `env` key. The SDK hands the MCP config to the CLI
// on its command line (`--mcp-config <json>`), where any process can read it,
// so the endpoint and token stay in the inherited environment: the Rust
// server sets FRESHELL/FRESHELL_URL/FRESHELL_TOKEN on this process, and the
// CLI passes its environment on to the stdio MCP child.

import { usesHostedClaudeRuntime } from './hosted.mjs'
import { log as sidecarLog } from './log.mjs'

/** The MCP server name both recipes register (its tools are `mcp__freshell__*`). */
export const FRESHELL_MCP_SERVER_NAME = 'freshell'

const HOSTED_RECIPE = Object.freeze({ command: 'node', args: Object.freeze(['/opt/freshell-mcp/server.js']) })

/** The pane-scoped context names: a session's CLI carries its own pane's, never another's. */
const PANE_CONTEXT_ENV = ['FRESHELL_TERMINAL_ID', 'FRESHELL_TAB_ID', 'FRESHELL_PANE_ID']

/** `{ command, args }` copied from a well-formed ordinary recipe, else undefined. */
function ordinaryRecipe(recipe) {
if (!recipe || typeof recipe !== 'object') return undefined
const { command, args } = recipe
if (typeof command !== 'string' || command.length === 0) return undefined
if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return undefined
return { command, args: [...args] }
}

/**
* The `mcpServers` option for the session `req` creates: the hosted recipe,
* the ordinary recipe, or undefined (never both, never an `env` key). An
* ordinary recipe that is present but malformed is left out and logged as a
* warning (`mcp.freshell_recipe_rejected`, with `fields`): the server already
* logged the launch as resolved, so this record is the only sign the pane
* runs without the Freshell MCP.
*/
export function mcpServersFor(req, env = process.env, { log = sidecarLog, fields = {} } = {}) {
if (usesHostedClaudeRuntime(env)) {
if (req?.hostedMcp !== true) return undefined
return { [FRESHELL_MCP_SERVER_NAME]: { command: HOSTED_RECIPE.command, args: [...HOSTED_RECIPE.args] } }
}
const sent = req?.freshellMcp
if (sent === undefined || sent === null) return undefined
const recipe = ordinaryRecipe(sent)
if (!recipe) {
log('warn', 'mcp.freshell_recipe_rejected', { ...fields, recipeType: Array.isArray(sent) ? 'array' : typeof sent })
return undefined
}
return { [FRESHELL_MCP_SERVER_NAME]: recipe }
}

/**
* `env` with the pane the session runs for (`create.freshellContext`,
* `{ tabId?, paneId? }`): the pane-scoped names come from the context alone,
* so a value inherited from elsewhere never reaches the CLI. A request with
* no context (an older server) leaves `env` as it is.
*/
export function withFreshellContext(env, context) {
if (!context || typeof context !== 'object') return env
const out = { ...env }
for (const name of PANE_CONTEXT_ENV) delete out[name]
if (typeof context.tabId === 'string' && context.tabId) out.FRESHELL_TAB_ID = context.tabId
if (typeof context.paneId === 'string' && context.paneId) out.FRESHELL_PANE_ID = context.paneId
return out
}
71 changes: 66 additions & 5 deletions crates/freshell-freshagent/src/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,11 @@ pub struct FreshClaudeState {
/// Host-only selection of the fixed Freshell stdio MCP recipe. Its endpoint
/// and grant remain in the child environment, never in create IPC.
hosted_mcp: bool,
/// The ordinary Freshell MCP (G19): the endpoint every sidecar's
/// environment carries and the recipe provider asked at each launch, both
/// wired by `freshell-server` ([`Self::set_freshell_endpoint`],
/// [`Self::set_freshell_mcp_provider`]); unwired in the session host.
freshell_mcp: crate::claude_freshell_mcp::ClaudeFreshellMcpWiring,
/// Test seam (`test-support` feature, C04 rigs): the complete environment of
/// every claude sidecar this state spawns, replacing the inherited one (the
/// ownership tag is still layered on). `None` in production: the sidecar
Expand Down Expand Up @@ -732,6 +737,9 @@ struct ClaudeConfiguration {
commands: Option<Vec<Value>>,
needs_configure: bool,
runtime_cwd: Option<String>,
/// The pane this runtime was launched for (G19): an undo's replacement and
/// a fork's child launch for the same pane.
freshell_context: Option<crate::claude_freshell_mcp::ClaudeFreshellContext>,
}

impl ClaudeSession {
Expand Down Expand Up @@ -795,6 +803,7 @@ impl FreshClaudeState {
pub fn new(broadcast_tx: Arc<tokio::sync::broadcast::Sender<String>>) -> Self {
Self {
hosted_mcp: false,
freshell_mcp: Default::default(),
sidecar_environment: None,
broadcast_tx,
checkpoint_event_sink: crate::CheckpointEventSink::default(),
Expand Down Expand Up @@ -838,6 +847,26 @@ impl FreshClaudeState {
self
}

/// Wire where every sidecar's CLI reaches this server (G19): its
/// environment carries `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`.
/// Set-once; `false` when an endpoint was already wired.
pub fn set_freshell_endpoint(
&self,
endpoint: crate::claude_freshell_mcp::FreshellEndpoint,
) -> bool {
self.freshell_mcp.set_endpoint(endpoint)
}

/// Wire the Freshell MCP recipe provider (G19), asked at every sidecar
/// launch of a lane that did not select the hosted recipe. Set-once;
/// `false` when a provider was already wired.
pub fn set_freshell_mcp_provider(
&self,
provider: crate::claude_freshell_mcp::ClaudeFreshellMcpProvider,
) -> bool {
self.freshell_mcp.set_provider(provider)
}

/// Test seam (see [`Self::sidecar_environment`]): every sidecar this state
/// spawns gets exactly `environment` (plus its ownership tag) instead of
/// inheriting the server's environment.
Expand Down Expand Up @@ -887,7 +916,12 @@ impl FreshClaudeState {
/// Spawn one claude sidecar process (create, attach-resume and rollback all
/// come through here) for a session of `session_type`.
async fn spawn_sidecar_process(&self, session_type: &str) -> Result<SpawnedSidecar, String> {
spawn_sidecar(self.sidecar_environment.as_deref(), session_type).await
spawn_sidecar(
self.sidecar_environment.as_deref(),
self.freshell_mcp.endpoint(),
session_type,
)
.await
}

pub fn set_checkpoint_event_persister(
Expand Down Expand Up @@ -1393,11 +1427,17 @@ impl FreshClaudeState {
/// vs kilroy, so a kilroy session resumes AS kilroy (the created/snapshot
/// frames keep the flavor; never map by provider alone). Registers under
/// the durable id itself and returns the constructed owner identity.
/// `tab_id`/`pane_id` name the pane that adopts the session (the client
/// names it on every handoff); the resumed CLI runs for that pane, so its
/// Freshell MCP defaults to it.
#[allow(clippy::too_many_arguments)]
pub(crate) async fn resume_for_handoff(
&self,
session_type: &str,
session_id: &str,
cwd: Option<&str>,
tab_id: Option<&str>,
pane_id: Option<&str>,
operation_id: &str,
generation: u64,
) -> Result<freshell_ownership::OwnerIdentity, (String, String)> {
Expand Down Expand Up @@ -1447,6 +1487,8 @@ impl FreshClaudeState {
}
};
let msg = FreshAgentAttach {
tab_id: tab_id.map(str::to_string),
pane_id: pane_id.map(str::to_string),
provider: freshell_protocol::AgentProvider::Claude,
session_id: session_id.to_string(),
session_type: flavor,
Expand Down Expand Up @@ -2423,7 +2465,13 @@ impl FreshClaudeState {
self.hosted_mcp,
)
.with_session_id(boot_session_id)
.with_model_selection(serde_json::to_value(&msg.model_selection).ok());
.with_model_selection(serde_json::to_value(&msg.model_selection).ok())
.with_freshell_context(
crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids(
msg.tab_id.as_deref(),
msg.pane_id.as_deref(),
),
);
// b8ke focused episode-2 round-2 F2: the create's claim owns this
// runtime's coordinator transition (the session-init adoption's Blocked
// arm correlates its own in-flight claim through it).
Expand Down Expand Up @@ -5714,7 +5762,13 @@ impl FreshClaudeState {
&rec,
self.hosted_mcp,
)
.with_session_id(minted_id);
.with_session_id(minted_id)
.with_freshell_context(
crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids(
msg.tab_id.as_deref(),
msg.pane_id.as_deref(),
),
);
// Register under the CLIENT's id: the consumer stamps the map key on every
// envelope and the frozen client routes by envelope sessionId
// (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. The
Expand Down Expand Up @@ -7977,6 +8031,7 @@ pub(crate) fn sidecar_node_command(
/// `test-support` seam); otherwise it inherits this process's.
async fn spawn_sidecar(
environment: Option<&[(String, String)]>,
freshell_endpoint: Option<&crate::claude_freshell_mcp::FreshellEndpoint>,
session_type: &str,
) -> Result<SpawnedSidecar, String> {
let entry = sidecar_entry_path();
Expand All @@ -7996,7 +8051,8 @@ async fn spawn_sidecar(
crate::claude_spawn::ClaudeNodeCommand::new(&entry),
&ownership_id,
)
.with_complete_environment(environment);
.with_complete_environment(environment)
.with_freshell_endpoint(freshell_endpoint);
let node = command.node().to_string();
let (mut child, tree_guard) = command.spawn_guarded().map_err(|error| match error {
crate::claude_spawn::ClaudeSpawnError::ExecProfile(classification) => classification,
Expand Down Expand Up @@ -8047,7 +8103,7 @@ mod provider_exec_tests {
.collect(),
)
.unwrap();
let mut result = spawn_sidecar(None, "freshclaude").await;
let mut result = spawn_sidecar(None, None, "freshclaude").await;
if let Ok(SpawnedSidecar { child, .. }) = &mut result {
let pid = child.id().unwrap();
child.kill().await.unwrap();
Expand Down Expand Up @@ -8674,6 +8730,8 @@ pub(crate) mod tests {

fn attach_msg(session_id: &str) -> FreshAgentAttach {
FreshAgentAttach {
tab_id: None,
pane_id: None,
observed_epoch: None,
observed_generation: None,
provider: freshell_protocol::AgentProvider::Claude,
Expand Down Expand Up @@ -11246,6 +11304,7 @@ rl.on('line', (line) => {

fn dedup_create_msg(request_id: &str) -> FreshAgentCreate {
FreshAgentCreate {
pane_id: None,
naming_handle: None,
observed_epoch: None,
observed_generation: None,
Expand Down Expand Up @@ -12721,6 +12780,8 @@ rl.on('line', (line) => {
// A delayed ATTACH: the tracked-and-alive fast path is BLOCKED
// with the typed error (pre-d3: the silent parity return).
st.handle_attach(freshell_protocol::FreshAgentAttach {
tab_id: None,
pane_id: None,
provider: freshell_protocol::AgentProvider::Claude,
session_id: placeholder.clone(),
session_type: SessionType::Freshclaude,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -252,6 +252,7 @@ impl Drop for EndingSession {

fn create_msg(request_id: &str) -> FreshAgentCreate {
FreshAgentCreate {
pane_id: None,
naming_handle: None,
observed_epoch: None,
observed_generation: None,
Expand Down
Loading
Loading