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
37 changes: 22 additions & 15 deletions Sources/App/DeckardHooksInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -14,25 +14,32 @@ enum DeckardHooksInstaller {
[ -z "$DECKARD_SOCKET_PATH" ] && exit 0

EVENT="$1"
cat > /dev/null # drain stdin (hooks don't carry rate_limits)
INPUT=$(cat)
EXTRA=""

# For session-start, walk parent PIDs to find the Claude session ID
# For session-start, extract the session ID so Deckard can find the JSONL transcript.
# Primary: read session_id from stdin JSON (works for both new and resumed sessions).
# Fallback: walk parent PIDs to find ~/.claude/sessions/<pid>.json (original method,
# unreliable for resumed sessions but better than nothing if stdin is empty).
if [ "$EVENT" = "session-start" ]; then
PID=$$
CWD="$(pwd)"
for _ in 1 2 3 4 5; do
PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ')
[ -z "$PID" ] || [ "$PID" = "1" ] && break
SESSION_FILE="$HOME/.claude/sessions/${PID}.json"
if [ -f "$SESSION_FILE" ]; then
FILE_CWD=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('cwd',''))" "$SESSION_FILE" 2>/dev/null)
if [ "$FILE_CWD" = "$CWD" ]; then
SID=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['sessionId'])" "$SESSION_FILE" 2>/dev/null)
[ -n "$SID" ] && EXTRA=",\\"sessionId\\":\\"$SID\\"" && break
SID=$(printf '%s' "$INPUT" | python3 -c "import json,sys; print(json.load(sys.stdin).get('session_id',''))" 2>/dev/null)
if [ -z "$SID" ]; then
PID=$$
CWD="$(pwd)"
for _ in 1 2 3 4 5; do
PID=$(ps -o ppid= -p "$PID" 2>/dev/null | tr -d ' ')
[ -z "$PID" ] || [ "$PID" = "1" ] && break
SESSION_FILE="$HOME/.claude/sessions/${PID}.json"
if [ -f "$SESSION_FILE" ]; then
FILE_CWD=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1])).get('cwd',''))" "$SESSION_FILE" 2>/dev/null)
if [ "$FILE_CWD" = "$CWD" ]; then
SID=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['sessionId'])" "$SESSION_FILE" 2>/dev/null)
break
fi
fi
fi
done
done
fi
[ -n "$SID" ] && EXTRA=",\\"sessionId\\":\\"$SID\\""
fi

printf '{"command":"hook.%s","surfaceId":"%s"%s}\\n' "$EVENT" "$DECKARD_SURFACE_ID" "$EXTRA" \\
Expand Down
22 changes: 9 additions & 13 deletions Sources/Window/DeckardWindowController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1122,19 +1122,15 @@ class DeckardWindowController: NSWindowController, NSSplitViewDelegate {

func updateSessionId(forSurfaceId surfaceIdStr: String, sessionId: String) {
guard let tab = tabForSurfaceId(surfaceIdStr) else { return }
// Only set the session ID if the tab doesn't already have one.
// Resumed sessions report a new ID in ~/.claude/sessions/<pid>.json
// that doesn't correspond to an actual JSONL session file.
if tab.sessionId == nil || tab.sessionId!.isEmpty {
tab.sessionId = sessionId
SessionManager.shared.saveSessionName(sessionId: sessionId, name: tab.name)
saveState()
// Start watching if this is the currently displayed tab
if let project = currentProject,
let idx = project.tabs.firstIndex(where: { $0.id == tab.id }),
idx == project.selectedTabIndex {
refreshContextBar(for: tab)
}
guard tab.sessionId != sessionId else { return }
tab.sessionId = sessionId
SessionManager.shared.saveSessionName(sessionId: sessionId, name: tab.name)
saveState()
// Start watching if this is the currently displayed tab
if let project = currentProject,
let idx = project.tabs.firstIndex(where: { $0.id == tab.id }),
idx == project.selectedTabIndex {
refreshContextBar(for: tab)
}
}

Expand Down
75 changes: 75 additions & 0 deletions Tests/DeckardHooksInstallerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,4 +416,79 @@ final class DeckardHooksInstallerTests: XCTestCase {
XCTAssertFalse(marker.isEmpty, "Marker '\(marker)' should be non-empty")
}
}

// MARK: - Hook script reads session_id from stdin with PID walking fallback

func testHookScriptReadsSessionIdFromStdin() throws {
let tempDir = NSTemporaryDirectory() + "deckard-hooks-test-\(UUID().uuidString)/"
let hooksDir = tempDir + "hooks/"
try FileManager.default.createDirectory(atPath: hooksDir, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) }

let hookPath = hooksDir + "notify.sh"
let statusLinePath = hooksDir + "statusline.sh"
DeckardHooksInstaller.installHookScript(
hookScriptPath: hookPath,
statusLineScriptPath: statusLinePath
)

let content = try String(contentsOfFile: hookPath, encoding: .utf8)

// Must read stdin into a variable (not drain it)
XCTAssertTrue(content.contains("INPUT=$(cat)"),
"Hook script should capture stdin into INPUT variable")
XCTAssertFalse(content.contains("cat > /dev/null"),
"Hook script must not drain stdin — session_id comes from it")

// Must extract session_id from stdin JSON for session-start
XCTAssertTrue(content.contains("session_id"),
"Hook script should extract session_id from stdin JSON")
}

func testHookScriptFallsBackToPidWalking() throws {
let tempDir = NSTemporaryDirectory() + "deckard-hooks-test-\(UUID().uuidString)/"
let hooksDir = tempDir + "hooks/"
try FileManager.default.createDirectory(atPath: hooksDir, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) }

let hookPath = hooksDir + "notify.sh"
let statusLinePath = hooksDir + "statusline.sh"
DeckardHooksInstaller.installHookScript(
hookScriptPath: hookPath,
statusLineScriptPath: statusLinePath
)

let content = try String(contentsOfFile: hookPath, encoding: .utf8)

// PID walking should still exist as fallback when stdin doesn't contain session_id
XCTAssertTrue(content.contains("ppid"),
"Hook script should fall back to PID walking when stdin has no session_id")
XCTAssertTrue(content.contains(".claude/sessions/"),
"Hook script should check session files as fallback")
}

func testHookScriptTriesStdinBeforePidWalking() throws {
let tempDir = NSTemporaryDirectory() + "deckard-hooks-test-\(UUID().uuidString)/"
let hooksDir = tempDir + "hooks/"
try FileManager.default.createDirectory(atPath: hooksDir, withIntermediateDirectories: true)
addTeardownBlock { try? FileManager.default.removeItem(atPath: tempDir) }

let hookPath = hooksDir + "notify.sh"
let statusLinePath = hooksDir + "statusline.sh"
DeckardHooksInstaller.installHookScript(
hookScriptPath: hookPath,
statusLineScriptPath: statusLinePath
)

let content = try String(contentsOfFile: hookPath, encoding: .utf8)

// stdin extraction must come BEFORE PID walking
guard let stdinPos = content.range(of: "session_id")?.lowerBound,
let pidPos = content.range(of: "ppid")?.lowerBound else {
XCTFail("Script must contain both session_id extraction and ppid fallback")
return
}
XCTAssertTrue(stdinPos < pidPos,
"Hook script should try stdin session_id before falling back to PID walking")
}
}
Loading