Skip to content
29 changes: 17 additions & 12 deletions Sources/Shellraiser/App/ShellraiserApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,18 @@ import AppKit
/// App delegate that confirms before allowing the application to terminate.
@MainActor
final class ShellraiserAppDelegate: NSObject, NSApplicationDelegate {
/// Test seam used to override quit confirmation without presenting AppKit UI.
var confirmQuit: () -> Bool = {
let alert = NSAlert()
alert.messageText = "Quit Shellraiser?"
alert.informativeText = "All workspaces and terminal sessions will be closed."
alert.addButton(withTitle: "Quit Shellraiser")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning

return alert.runModal() == .alertFirstButtonReturn
}

/// Routes top-level AppleScript keys to the app delegate.
func application(_ sender: NSApplication, delegateHandlesKey key: String) -> Bool {
key == "terminals" || key == "surfaceConfigurations" || key == "workspaces"
Expand Down Expand Up @@ -84,19 +96,12 @@ final class ShellraiserAppDelegate: NSObject, NSApplicationDelegate {

/// Intercepts standard app termination and requires explicit user confirmation.
func applicationShouldTerminate(_ sender: NSApplication) -> NSApplication.TerminateReply {
let alert = NSAlert()
alert.messageText = "Quit Shellraiser?"
alert.informativeText = "All workspaces and terminal sessions will be closed."
alert.addButton(withTitle: "Quit Shellraiser")
alert.addButton(withTitle: "Cancel")
alert.alertStyle = .warning

return switch alert.runModal() {
case .alertFirstButtonReturn:
.terminateNow
default:
.terminateCancel
guard confirmQuit() else {
return .terminateCancel
}

ShellraiserScriptingController.shared.prepareForTermination()
return .terminateNow
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -217,9 +217,10 @@ struct PaneLeafView: View {
)
},
onChildExited: {
guard let workspace = manager.workspace(id: workspaceId) else { return }
guard let paneId = workspace.rootPane.paneId(containing: activeSurface.id) else { return }
manager.closeSurface(workspaceId: workspaceId, paneId: paneId, surfaceId: activeSurface.id)
manager.handleSurfaceChildExit(
workspaceId: workspaceId,
surfaceId: activeSurface.id
)
},
onPaneNavigationRequest: { direction in
manager.focusAdjacentPane(from: activeSurface.id, direction: direction)
Expand Down
264 changes: 252 additions & 12 deletions Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,20 +10,26 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
let zshShimDirectory: URL
let eventLogURL: URL

private let fileManager = FileManager.default
private let fileManager: FileManager
private var cachedExecutablePaths: [String: String?] = [:]

/// Creates the bridge rooted in the process temp directory to avoid path escaping issues.
private init() {
let runtimeDirectory = fileManager.temporaryDirectory.appendingPathComponent(
"ShellraiserRuntime",
isDirectory: true
private convenience init() {
self.init(
rootURL: FileManager.default.temporaryDirectory.appendingPathComponent(
"ShellraiserRuntime",
isDirectory: true
)
)
self.runtimeDirectory = runtimeDirectory
self.binDirectory = runtimeDirectory.appendingPathComponent("bin", isDirectory: true)
self.zshShimDirectory = runtimeDirectory.appendingPathComponent("zsh", isDirectory: true)
self.eventLogURL = runtimeDirectory.appendingPathComponent("agent-completions.log")
}

/// Creates a bridge rooted in the supplied directory for isolated runtime support.
init(rootURL: URL, fileManager: FileManager = .default) {
self.fileManager = fileManager
self.runtimeDirectory = rootURL
self.binDirectory = rootURL.appendingPathComponent("bin", isDirectory: true)
self.zshShimDirectory = rootURL.appendingPathComponent("zsh", isDirectory: true)
self.eventLogURL = rootURL.appendingPathComponent("agent-completions.log")
prepareRuntimeSupport()
}

Expand Down Expand Up @@ -179,7 +185,7 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {

payload=""
case "$phase" in
started|completed)
started|completed|session|exited|hook-session)
;;
*)
exit 0
Expand All @@ -190,8 +196,23 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
codex:completed)
payload="${4:-}"
;;
codex:session|claudeCode:session)
payload="${4:-}"
;;
claudeCode:hook-session)
hook_payload="$(cat)"
compact_payload="$(printf '%s' "$hook_payload" | tr -d '\n')"
session_id="$(printf '%s' "$compact_payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tr '[:upper:]' '[:lower:]' | sed -n '1p')"
transcript_path="$(printf '%s' "$compact_payload" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')"
payload="$(printf '%s\n%s' "$session_id" "$transcript_path")"
phase="session"
;;
esac

if [ "$phase" = "session" ] && [ -z "$payload" ]; then
exit 0
fi
Comment on lines +202 to +214
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Session payload emptiness guard is ineffective for hook-session events.

On Line 207, payload is always at least a newline for hook-session (printf '%s\n%s' ...), so the Line 212 guard won’t suppress missing session_id cases. That can still emit a malformed session event.

💡 Suggested fix
-            payload="$(printf '%s\n%s' "$session_id" "$transcript_path")"
-            phase="session"
+            [ -n "$session_id" ] || exit 0
+            payload="$(printf '%s\n%s' "$session_id" "$transcript_path")"
+            phase="session"
             ;;
         esac

-        if [ "$phase" = "session" ] && [ -z "$payload" ]; then
+        if [ "$phase" = "session" ] && [ -z "$payload" ]; then
             exit 0
         fi
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
claudeCode:hook-session)
hook_payload="$(cat)"
compact_payload="$(printf '%s' "$hook_payload" | tr -d '\n')"
session_id="$(printf '%s' "$compact_payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tr '[:upper:]' '[:lower:]' | sed -n '1p')"
transcript_path="$(printf '%s' "$compact_payload" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')"
payload="$(printf '%s\n%s' "$session_id" "$transcript_path")"
phase="session"
;;
esac
if [ "$phase" = "session" ] && [ -z "$payload" ]; then
exit 0
fi
claudeCode:hook-session)
hook_payload="$(cat)"
compact_payload="$(printf '%s' "$hook_payload" | tr -d '\n')"
session_id="$(printf '%s' "$compact_payload" | sed -n 's/.*"session_id"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | tr '[:upper:]' '[:lower:]' | sed -n '1p')"
transcript_path="$(printf '%s' "$compact_payload" | sed -n 's/.*"transcript_path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' | sed -n '1p')"
[ -n "$session_id" ] || exit 0
payload="$(printf '%s\n%s' "$session_id" "$transcript_path")"
phase="session"
;;
esac
if [ "$phase" = "session" ] && [ -z "$payload" ]; then
exit 0
fi
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@Sources/Shellraiser/Infrastructure/Agents/AgentRuntimeBridge.swift` around
lines 202 - 214, The guard for hook-session is ineffective because payload is
constructed with printf '%s\n%s' (so it's never empty); update the logic that
handles the "hook-session" branch (the hook-session case that defines
hook_payload, compact_payload, session_id, transcript_path, and payload) to
either (a) build payload only when session_id (or transcript_path, as required)
is non-empty, or (b) keep payload as-is but change the later guard to check the
extracted session_id (and/or transcript_path) emptiness instead of payload;
ensure the runtime uses the session_id variable (not the newline-only payload)
to decide whether to exit for phase="session".


timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
encoded="$(printf '%s' "$payload" | /usr/bin/base64 | tr -d '\n')"
printf '%s\t%s\t%s\t%s\t%s\n' "$timestamp" "$runtime" "$surface" "$phase" "$encoded" >> "${SHELLRAISER_EVENT_LOG}"
Expand Down Expand Up @@ -233,6 +254,26 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
cat > "$settings_file" <<EOF
{
"hooks": {
"SessionStart": [
{
"matcher": "startup",
"hooks": [
{
"type": "command",
"command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" hook-session"
}
]
},
{
"matcher": "resume",
"hooks": [
{
"type": "command",
"command": "\"$SHELLRAISER_HELPER_PATH\" claudeCode \"$SHELLRAISER_SURFACE_ID\" hook-session"
}
]
}
],
"UserPromptSubmit": [
{
"hooks": [
Expand Down Expand Up @@ -299,7 +340,12 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
}
EOF

exec "$real" --settings "$settings_file" "$@"
set +e
"$real" --settings "$settings_file" "$@"
status=$?
set -e
"$helper" claudeCode "$surface" exited || true
exit "$status"
"""#
}

Expand Down Expand Up @@ -329,8 +375,202 @@ final class AgentRuntimeBridge: AgentRuntimeSupporting {
export SHELLRAISER_HELPER_PATH="$helper"
export SHELLRAISER_SURFACE_ID="$surface"

parse_codex_session_id() {
case "${1:-}" in
resume|fork)
shift
while [ "$#" -gt 0 ]; do
case "$1" in
--*)
shift
;;
*)
printf '%s\n' "$1"
return 0
;;
esac
done
;;
esac

return 1
}

codex_is_interactive_start() {
case "${1:-}" in
""|-*|resume|fork)
return 0
;;
exec|review|login|logout|mcp|mcp-server|app-server|app|completion|sandbox|debug|apply|cloud|features|help)
return 1
;;
*)
return 0
;;
esac
}

monitor_codex_session() {
root="${HOME}/.codex/sessions"
cwd="$(pwd)"
stamp_file="$1"
start_timestamp="$2"
helper_path="$3"
surface_id="$4"

[ -d "$root" ] || exit 0

extract_codex_session_timestamp() {
session_line="$1"
payload_timestamp="$(
printf '%s\n' "$session_line" \
| sed -n 's/.*"timestamp":"[^"]*".*"timestamp":"\([^"]*\)".*/\1/p' \
| head -n 1
)"
if [ -n "$payload_timestamp" ]; then
printf '%s\n' "$payload_timestamp"
return 0
fi

printf '%s\n' "$session_line" \
| sed -n 's/.*"timestamp":"\([^"]*\)".*/\1/p' \
| head -n 1
}

extract_codex_surface_id() {
session_line="$1"
surface_value="$(
printf '%s\n' "$session_line" \
| sed -n 's/.*"surface_id":"\([^"]*\)".*/\1/p' \
| head -n 1
)"
if [ -n "$surface_value" ]; then
printf '%s\n' "$surface_value"
return 0
fi

surface_value="$(
printf '%s\n' "$session_line" \
| sed -n 's/.*"notify":\[[^]]*"codex","\([^"]*\)","completed"[^]]*\].*/\1/p' \
| head -n 1
)"
if [ -n "$surface_value" ]; then
printf '%s\n' "$surface_value"
return 0
fi

printf '%s\n' "$session_line" \
| sed -n 's/.*"notify":"[^"]*\\\\"codex\\\\",\\\\"\([^"]*\)\\\\",\\\\"completed\\\\"[^"]*".*/\1/p' \
| head -n 1
}

normalize_codex_session_timestamp() {
timestamp="${1:-}"
case "$timestamp" in
*.*Z)
base="${timestamp%%.*}"
fraction="${timestamp#*.}"
fraction="${fraction%Z}"
;;
*Z)
base="${timestamp%Z}"
fraction=""
;;
*)
printf '%s\n' "$timestamp"
return 0
;;
esac

fraction="$(printf '%-9.9s' "$fraction" | tr ' ' '0')"
printf '%s.%sZ\n' "$base" "$fraction"
}

timestamp_is_at_or_after() {
candidate_timestamp="$(normalize_codex_session_timestamp "$1")"
baseline_timestamp="$(normalize_codex_session_timestamp "$2")"
latest_timestamp="$(
LC_ALL=C printf '%s\n%s\n' "$candidate_timestamp" "$baseline_timestamp" \
| LC_ALL=C sort \
| tail -n 1
)"
[ "$latest_timestamp" = "$candidate_timestamp" ]
}

surface_matches_current_codex_session() {
session_line="$1"
candidate_surface_id="$(extract_codex_surface_id "$session_line")"
[ -n "$candidate_surface_id" ] && [ "$candidate_surface_id" = "$surface_id" ]
}

attempts=0
while [ "$attempts" -lt 40 ]; do
[ -f "$stamp_file" ] || exit 0

while IFS= read -r session_file; do
[ -f "$session_file" ] || continue
first_line="$(sed -n '1p' "$session_file" 2>/dev/null || true)"
if ! printf '%s\n' "$first_line" | grep -F "\"cwd\":\"$cwd\"" >/dev/null; then
continue
fi

if ! surface_matches_current_codex_session "$first_line"; then
continue
fi

session_timestamp="$(extract_codex_session_timestamp "$first_line")"
if [ -n "$session_timestamp" ] && ! timestamp_is_at_or_after "$session_timestamp" "$start_timestamp"; then
continue
fi

session_id="$(
printf '%s\n' "$first_line" \
| sed -n 's/.*"id":"\([^"]*\)".*/\1/p' \
| head -n 1
)"
if [ -n "$session_id" ]; then
"$helper_path" codex "$surface_id" session "$session_id" || true
exit 0
fi
done <<EOF
$(find "$root" -type f -name 'rollout-*.jsonl' -newer "$stamp_file" -print 2>/dev/null | sort -r)
EOF

attempts=$((attempts + 1))
sleep 0.5
done
}

session_id="$(parse_codex_session_id "$@" || true)"
if [ -n "$session_id" ]; then
"$helper" codex "$surface" session "$session_id" || true
elif codex_is_interactive_start "${1:-}"; then
stamp_file="${TMPDIR:-/tmp}/schmux-codex-${surface}-$$.stamp"
start_timestamp="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
: > "$stamp_file"
monitor_codex_session "$stamp_file" "$start_timestamp" "$helper" "$surface" >/dev/null 2>&1 &
monitor_pid="$!"
fi

notify_config="notify=[\"$SHELLRAISER_HELPER_PATH\",\"codex\",\"$SHELLRAISER_SURFACE_ID\",\"completed\"]"
exec "$real" -c "$notify_config" "$@"
set +e
"$real" -c "$notify_config" "$@"
status=$?
set -e
if [ -n "${monitor_pid:-}" ]; then
rm -f "$stamp_file"
wait_attempts=0
while kill -0 "$monitor_pid" 2>/dev/null && [ "$wait_attempts" -lt 10 ]; do
sleep 0.1
wait_attempts=$((wait_attempts + 1))
done
if kill -0 "$monitor_pid" 2>/dev/null; then
kill "$monitor_pid" 2>/dev/null || true
fi
wait "$monitor_pid" 2>/dev/null || true
fi
"$helper" codex "$surface" exited || true
exit "$status"
"""#
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ struct PendingCompletionTarget {
enum AgentActivityPhase: String {
case started
case completed
case session
case exited
}

/// Parsed activity event emitted by managed Claude/Codex wrappers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,17 @@ final class ShellraiserScriptingController {
self.workspaceManager = workspaceManager
}

/// Resets retained singleton state so tests do not leak scripting context across cases.
func resetForTesting() {
workspaceManager = nil
surfaceConfigurationsByID.removeAll()
}

/// Forwards app-termination preparation to the installed workspace manager.
func prepareForTermination() {
workspaceManager?.prepareForTermination()
}

/// Returns scriptable terminal objects for every open terminal surface.
func terminals() -> [ScriptableTerminal] {
terminalSnapshots().map(ScriptableTerminal.init(snapshot:))
Expand Down
Loading
Loading