Compare commits
3 Commits
v0.1.80-wi
...
v0.1.83-wi
| Author | SHA1 | Date | |
|---|---|---|---|
| 86176d8830 | |||
| 58a10c65e9 | |||
| d56c6e3845 |
17
app/public/audio-capture-processor.js
Normal file
17
app/public/audio-capture-processor.js
Normal file
@@ -0,0 +1,17 @@
|
||||
class AudioCaptureProcessor extends AudioWorkletProcessor {
|
||||
process(inputs, outputs, parameters) {
|
||||
const input = inputs[0];
|
||||
if (input && input.length > 0 && input[0].length > 0) {
|
||||
const samples = input[0]; // Float32Array, mono channel
|
||||
const int16 = new Int16Array(samples.length);
|
||||
for (let i = 0; i < samples.length; i++) {
|
||||
const s = Math.max(-1, Math.min(1, samples[i]));
|
||||
int16[i] = s < 0 ? s * 0x8000 : s * 0x7FFF;
|
||||
}
|
||||
this.port.postMessage(int16.buffer, [int16.buffer]);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
registerProcessor('audio-capture-processor', AudioCaptureProcessor);
|
||||
@@ -1,7 +1,74 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::models::{AuthMode, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
/// Build the command to run in the container terminal.
|
||||
///
|
||||
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
|
||||
&& project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||
.unwrap_or(false);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
return vec![
|
||||
"claude".to_string(),
|
||||
"--dangerously-skip-permissions".to_string(),
|
||||
];
|
||||
}
|
||||
|
||||
// Resolve AWS profile: project-level → global settings → "default"
|
||||
let profile = project
|
||||
.bedrock_config
|
||||
.as_ref()
|
||||
.and_then(|b| b.aws_profile.clone())
|
||||
.or_else(|| state.settings_store.get().global_aws.aws_profile.clone())
|
||||
.unwrap_or_else(|| "default".to_string());
|
||||
|
||||
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||
// then exec's into claude.
|
||||
let script = format!(
|
||||
r#"
|
||||
echo "Validating AWS session for profile '{profile}'..."
|
||||
if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "AWS session valid."
|
||||
else
|
||||
echo "AWS session expired or invalid."
|
||||
# Check if this profile uses SSO (has sso_start_url configured)
|
||||
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1; then
|
||||
echo "Starting SSO login — click the URL below to authenticate:"
|
||||
echo ""
|
||||
aws sso login --profile '{profile}'
|
||||
if [ $? -ne 0 ]; then
|
||||
echo ""
|
||||
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||
echo "You may see authentication errors."
|
||||
echo ""
|
||||
fi
|
||||
else
|
||||
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||
echo "Starting Claude anyway..."
|
||||
echo ""
|
||||
fi
|
||||
fi
|
||||
exec claude --dangerously-skip-permissions
|
||||
"#,
|
||||
profile = profile
|
||||
);
|
||||
|
||||
vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
script,
|
||||
]
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn open_terminal_session(
|
||||
project_id: String,
|
||||
@@ -19,10 +86,7 @@ pub async fn open_terminal_session(
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = vec![
|
||||
"claude".to_string(),
|
||||
"--dangerously-skip-permissions".to_string(),
|
||||
];
|
||||
let cmd = build_terminal_cmd(&project, &state);
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
let exit_event = format!("terminal-exit-{}", session_id);
|
||||
@@ -69,6 +133,10 @@ pub async fn close_terminal_session(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Close audio bridge if it exists
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
// Close terminal session
|
||||
state.exec_manager.close_session(&session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -92,3 +160,53 @@ pub async fn paste_image_to_terminal(
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn start_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get container_id from the terminal session
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
|
||||
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
let cmd = vec![
|
||||
"bash".to_string(),
|
||||
"-c".to_string(),
|
||||
"FIFO=/tmp/triple-c-audio-input; [ -p \"$FIFO\" ] || mkfifo \"$FIFO\"; trap '' PIPE; while true; do cat > \"$FIFO\" 2>/dev/null; sleep 0.1; done".to_string(),
|
||||
];
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
|_data| { /* ignore output from the audio bridge */ },
|
||||
Box::new(|| { /* no exit handler needed */ }),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn send_audio_data(
|
||||
session_id: String,
|
||||
data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.send_input(&audio_session_id, data).await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn stop_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let audio_session_id = format!("audio-{}", session_id);
|
||||
state.exec_manager.close_session(&audio_session_id).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -508,7 +508,7 @@ pub async fn create_container(
|
||||
if let Some(ref aws_path) = aws_dir {
|
||||
if aws_path.exists() {
|
||||
mounts.push(Mount {
|
||||
target: Some("/home/claude/.aws".to_string()),
|
||||
target: Some("/tmp/.host-aws".to_string()),
|
||||
source: Some(aws_path.to_string_lossy().to_string()),
|
||||
typ: Some(MountTypeEnum::BIND),
|
||||
read_only: Some(true),
|
||||
|
||||
@@ -60,6 +60,22 @@ impl ExecSessionManager {
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
@@ -72,7 +88,7 @@ impl ExecSessionManager {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
|
||||
@@ -101,6 +101,9 @@ pub fn run() {
|
||||
commands::terminal_commands::terminal_resize,
|
||||
commands::terminal_commands::close_terminal_session,
|
||||
commands::terminal_commands::paste_image_to_terminal,
|
||||
commands::terminal_commands::start_audio_bridge,
|
||||
commands::terminal_commands::send_audio_data,
|
||||
commands::terminal_commands::stop_audio_bridge,
|
||||
// MCP
|
||||
commands::mcp_commands::list_mcp_servers,
|
||||
commands::mcp_commands::add_mcp_server,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links";
|
||||
import { openUrl } from "@tauri-apps/plugin-opener";
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useTerminal } from "../../hooks/useTerminal";
|
||||
import { useVoice } from "../../hooks/useVoice";
|
||||
import { UrlDetector } from "../../lib/urlDetector";
|
||||
import UrlToast from "./UrlToast";
|
||||
|
||||
@@ -23,6 +24,8 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const detectorRef = useRef<UrlDetector | null>(null);
|
||||
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
|
||||
|
||||
const voice = useVoice(sessionId);
|
||||
|
||||
const [detectedUrl, setDetectedUrl] = useState<string | null>(null);
|
||||
const [imagePasteMsg, setImagePasteMsg] = useState<string | null>(null);
|
||||
const [isAtBottom, setIsAtBottom] = useState(true);
|
||||
@@ -82,6 +85,25 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
// Send initial size
|
||||
resize(sessionId, term.cols, term.rows);
|
||||
|
||||
// Handle OSC 52 clipboard write sequences from programs inside the container.
|
||||
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
||||
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return false;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return false; // clipboard read request, not supported
|
||||
try {
|
||||
const decoded = atob(payload);
|
||||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("OSC 52 decode failed:", e);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
// Handle user input -> backend
|
||||
const inputDisposable = term.onData((data) => {
|
||||
sendInput(sessionId, data);
|
||||
@@ -170,6 +192,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
aborted = true;
|
||||
detector.dispose();
|
||||
detectorRef.current = null;
|
||||
osc52Disposable.dispose();
|
||||
inputDisposable.dispose();
|
||||
scrollDisposable.dispose();
|
||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||
@@ -180,6 +203,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
|
||||
webglRef.current = null;
|
||||
term.dispose();
|
||||
voice.stop();
|
||||
};
|
||||
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
@@ -264,6 +288,32 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
{imagePasteMsg}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={voice.toggle}
|
||||
title={
|
||||
voice.state === "active"
|
||||
? "Voice active — click to stop"
|
||||
: voice.error
|
||||
? `Voice error: ${voice.error}`
|
||||
: "Enable voice input for /voice mode"
|
||||
}
|
||||
className={`absolute bottom-4 left-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium border shadow-lg transition-colors cursor-pointer ${
|
||||
voice.state === "active"
|
||||
? "bg-[#1a3a2a] text-[#3fb950] border-[#238636] hover:bg-[#243b2a]"
|
||||
: voice.state === "starting"
|
||||
? "bg-[#1f2937] text-[#d29922] border-[#30363d] opacity-75"
|
||||
: voice.state === "error"
|
||||
? "bg-[#3a1a1a] text-[#ff7b72] border-[#da3633] hover:bg-[#4a2020]"
|
||||
: "bg-[#1f2937] text-[#b1bac4] border-[#30363d] hover:bg-[#2d3748] hover:text-[#e6edf3]"
|
||||
}`}
|
||||
disabled={voice.state === "starting"}
|
||||
>
|
||||
{voice.state === "active"
|
||||
? "Mic On"
|
||||
: voice.state === "starting"
|
||||
? "Mic..."
|
||||
: "Mic Off"}
|
||||
</button>
|
||||
{!isAtBottom && (
|
||||
<button
|
||||
onClick={handleScrollToBottom}
|
||||
|
||||
59
app/src/components/terminal/osc52.test.ts
Normal file
59
app/src/components/terminal/osc52.test.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
/**
|
||||
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||
* Extracted here to validate the decode/write path independently.
|
||||
*/
|
||||
|
||||
// Mirrors the handler registered in TerminalView.tsx
|
||||
function handleOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null;
|
||||
try {
|
||||
return atob(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
describe("OSC 52 clipboard handler", () => {
|
||||
it("decodes a valid clipboard write sequence", () => {
|
||||
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||
const encoded = btoa("https://example.com");
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("decodes multi-line content", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const encoded = btoa(text);
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe(text);
|
||||
});
|
||||
|
||||
it("handles primary selection target (p)", () => {
|
||||
const encoded = btoa("selected text");
|
||||
const result = handleOsc52(`p;${encoded}`);
|
||||
expect(result).toBe("selected text");
|
||||
});
|
||||
|
||||
it("returns null for clipboard read request (?)", () => {
|
||||
expect(handleOsc52("c;?")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for missing semicolon", () => {
|
||||
expect(handleOsc52("invalid")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
});
|
||||
|
||||
it("handles empty payload after selection target", () => {
|
||||
// btoa("") = ""
|
||||
const result = handleOsc52("c;");
|
||||
expect(result).toBe("");
|
||||
});
|
||||
});
|
||||
98
app/src/hooks/useVoice.ts
Normal file
98
app/src/hooks/useVoice.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
|
||||
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||
|
||||
export function useVoice(sessionId: string) {
|
||||
const [state, setState] = useState<VoiceState>("inactive");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const audioContextRef = useRef<AudioContext | null>(null);
|
||||
const streamRef = useRef<MediaStream | null>(null);
|
||||
const workletRef = useRef<AudioWorkletNode | null>(null);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
if (state === "active" || state === "starting") return;
|
||||
setState("starting");
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// 1. Start the audio bridge in the container (creates FIFO writer)
|
||||
await commands.startAudioBridge(sessionId);
|
||||
|
||||
// 2. Get microphone access
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
echoCancellation: true,
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
},
|
||||
});
|
||||
streamRef.current = stream;
|
||||
|
||||
// 3. Create AudioContext at 16kHz (browser handles resampling)
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
audioContextRef.current = audioContext;
|
||||
|
||||
// 4. Load AudioWorklet processor
|
||||
await audioContext.audioWorklet.addModule("/audio-capture-processor.js");
|
||||
|
||||
// 5. Connect: mic → worklet → (silent) destination
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
const processor = new AudioWorkletNode(audioContext, "audio-capture-processor");
|
||||
workletRef.current = processor;
|
||||
|
||||
// 6. Handle PCM chunks from the worklet
|
||||
processor.port.onmessage = (event: MessageEvent<ArrayBuffer>) => {
|
||||
const bytes = Array.from(new Uint8Array(event.data));
|
||||
commands.sendAudioData(sessionId, bytes).catch(() => {
|
||||
// Audio bridge may have been closed — ignore send errors
|
||||
});
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
|
||||
setState("active");
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
setError(msg);
|
||||
setState("error");
|
||||
// Clean up on failure
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
}
|
||||
}, [sessionId, state]);
|
||||
|
||||
const stop = useCallback(async () => {
|
||||
// Tear down audio pipeline
|
||||
workletRef.current?.disconnect();
|
||||
workletRef.current = null;
|
||||
|
||||
if (audioContextRef.current) {
|
||||
await audioContextRef.current.close().catch(() => {});
|
||||
audioContextRef.current = null;
|
||||
}
|
||||
|
||||
if (streamRef.current) {
|
||||
streamRef.current.getTracks().forEach((t) => t.stop());
|
||||
streamRef.current = null;
|
||||
}
|
||||
|
||||
// Stop the container-side audio bridge
|
||||
await commands.stopAudioBridge(sessionId).catch(() => {});
|
||||
|
||||
setState("inactive");
|
||||
setError(null);
|
||||
}, [sessionId]);
|
||||
|
||||
const toggle = useCallback(async () => {
|
||||
if (state === "active") {
|
||||
await stop();
|
||||
} else {
|
||||
await start();
|
||||
}
|
||||
}, [state, start, stop]);
|
||||
|
||||
return { state, error, start, stop, toggle };
|
||||
}
|
||||
@@ -49,6 +49,12 @@ export const closeTerminalSession = (sessionId: string) =>
|
||||
invoke<void>("close_terminal_session", { sessionId });
|
||||
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
|
||||
invoke<string>("paste_image_to_terminal", { sessionId, imageData });
|
||||
export const startAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("start_audio_bridge", { sessionId });
|
||||
export const sendAudioData = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("send_audio_data", { sessionId, data });
|
||||
export const stopAudioBridge = (sessionId: string) =>
|
||||
invoke<void>("stop_audio_bridge", { sessionId });
|
||||
|
||||
// MCP Servers
|
||||
export const listMcpServers = () => invoke<McpServer[]>("list_mcp_servers");
|
||||
|
||||
@@ -101,6 +101,24 @@ WORKDIR /workspace
|
||||
|
||||
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
||||
USER root
|
||||
|
||||
# ── OSC 52 clipboard support ─────────────────────────────────────────────
|
||||
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
|
||||
# allowing programs inside the container to copy to the host clipboard.
|
||||
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
|
||||
RUN chmod +x /usr/local/bin/osc52-clipboard \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
|
||||
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
|
||||
|
||||
# ── Audio capture shim (voice mode) ────────────────────────────────────────
|
||||
# Provides fake rec/arecord that read PCM from a FIFO instead of a real mic,
|
||||
# allowing Claude Code voice mode to work inside the container.
|
||||
COPY audio-shim /usr/local/bin/audio-shim
|
||||
RUN chmod +x /usr/local/bin/audio-shim \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \
|
||||
&& ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord
|
||||
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||
|
||||
16
container/audio-shim
Normal file
16
container/audio-shim
Normal file
@@ -0,0 +1,16 @@
|
||||
#!/bin/bash
|
||||
# Audio capture shim for Triple-C voice mode.
|
||||
# Claude Code spawns `rec` or `arecord` to capture mic audio.
|
||||
# Inside Docker there is no mic, so this shim reads PCM data from a
|
||||
# FIFO that the Tauri host app writes to, and outputs it on stdout.
|
||||
|
||||
FIFO=/tmp/triple-c-audio-input
|
||||
|
||||
# Create the FIFO if it doesn't already exist
|
||||
[ -p "$FIFO" ] || mkfifo "$FIFO" 2>/dev/null
|
||||
|
||||
# Clean exit on SIGTERM (Claude Code sends this when recording stops)
|
||||
trap 'exit 0' TERM INT
|
||||
|
||||
# Stream PCM from the FIFO to stdout until we get a signal or EOF
|
||||
cat "$FIFO"
|
||||
@@ -73,6 +73,19 @@ su -s /bin/bash claude -c '
|
||||
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
|
||||
'
|
||||
|
||||
# ── AWS config setup ──────────────────────────────────────────────────────────
|
||||
# Host AWS dir is mounted read-only at /tmp/.host-aws.
|
||||
# Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache.
|
||||
if [ -d /tmp/.host-aws ]; then
|
||||
rm -rf /home/claude/.aws
|
||||
cp -a /tmp/.host-aws /home/claude/.aws
|
||||
chown -R claude:claude /home/claude/.aws
|
||||
chmod 700 /home/claude/.aws
|
||||
# Ensure writable cache directories exist
|
||||
mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache
|
||||
chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli
|
||||
fi
|
||||
|
||||
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
||||
if [ -n "$GIT_TOKEN" ]; then
|
||||
CRED_FILE="/home/claude/.git-credentials"
|
||||
|
||||
26
container/osc52-clipboard
Normal file
26
container/osc52-clipboard
Normal file
@@ -0,0 +1,26 @@
|
||||
#!/bin/bash
|
||||
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
|
||||
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
|
||||
# programs inside the container (e.g. Claude Code) can copy to clipboard.
|
||||
#
|
||||
# Supports common invocations:
|
||||
# echo "text" | xclip -selection clipboard
|
||||
# echo "text" | xsel --clipboard --input
|
||||
# echo "text" | pbcopy
|
||||
#
|
||||
# Paste/output requests exit silently (not supported via OSC 52).
|
||||
|
||||
# Detect paste/output mode — exit silently since we can't read the host clipboard
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
-o|--output) exit 0 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Read all input from stdin
|
||||
data=$(cat)
|
||||
[ -z "$data" ] && exit 0
|
||||
|
||||
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
|
||||
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
|
||||
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null
|
||||
Reference in New Issue
Block a user