diff --git a/Sources/App/DeckardHooksInstaller.swift b/Sources/App/DeckardHooksInstaller.swift index 110b5f8..6985210 100644 --- a/Sources/App/DeckardHooksInstaller.swift +++ b/Sources/App/DeckardHooksInstaller.swift @@ -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/.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" \\ diff --git a/Sources/Window/DeckardWindowController.swift b/Sources/Window/DeckardWindowController.swift index dd6665a..6fca1ca 100644 --- a/Sources/Window/DeckardWindowController.swift +++ b/Sources/Window/DeckardWindowController.swift @@ -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/.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) } } diff --git a/Tests/DeckardHooksInstallerTests.swift b/Tests/DeckardHooksInstallerTests.swift index 9a7c64b..fd3bf8a 100644 --- a/Tests/DeckardHooksInstallerTests.swift +++ b/Tests/DeckardHooksInstallerTests.swift @@ -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") + } }