mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-10-29 10:33:13 +00:00
A Capacity Dock row that vanishes while hovered fires no hover exit, so the pushed pointing-hand cursor stuck system-wide; the row now pops it on the way out. Claude Code writes a fresh sessions/<pid>.json on every resume, so several files name one session and the first listed was taken; the newest is taken now, and a newest that fails the recycled-pid guard leaves the row inert. collectKimicodeInputs recursed the whole Kimi session store to find wire files, listing ~18k entries for ~2k wires at ~250ms per payload build. It now walks work dir, session and agent levels only: ~23ms, and a decoy wire.jsonl under blobs/ or file-history/ no longer reports itself as a session.
339 lines
15 KiB
Swift
339 lines
15 KiB
Swift
import AppKit
|
|
import Darwin
|
|
import Foundation
|
|
|
|
/// Where a live-session row points: the application that owns the agent's
|
|
/// process, plus the tty of the shell it runs in when the terminal can name a
|
|
/// tab by it.
|
|
struct SessionFocusTarget: Equatable, Sendable {
|
|
let appPID: pid_t
|
|
let bundleID: String
|
|
let appName: String
|
|
let tty: String?
|
|
|
|
/// Terminal.app and iTerm2 can raise the exact tab; every other terminal
|
|
/// only gets the app, and the tooltip promises no more than that.
|
|
var selectsTab: Bool { SessionTabScript.script(bundleID: bundleID, tty: tty) != nil }
|
|
|
|
var tooltip: String {
|
|
selectsTab ? L("Open in %@", appName) : L("Bring %@ to front", appName)
|
|
}
|
|
}
|
|
|
|
/// One process as the ancestry walk sees it.
|
|
struct SessionProcess: Equatable, Sendable {
|
|
let ppid: pid_t
|
|
let tty: String?
|
|
/// Set only when macOS knows this pid as a running application, which is
|
|
/// what makes an ancestor the terminal rather than another shell.
|
|
let bundleID: String?
|
|
let appName: String?
|
|
}
|
|
|
|
/// Raises the terminal a live session is running in.
|
|
///
|
|
/// A session row knows a session id, not a window: neither Claude Code nor Kimi
|
|
/// publishes one. What it can be given is a pid — Claude writes its own into
|
|
/// `~/.claude/sessions/<pid>.json`, Kimi is found by the working directory its
|
|
/// state file records — and a pid has a parent, and somewhere up that chain is
|
|
/// the terminal application. So the app is found by walking the process tree,
|
|
/// and the tab inside it by the tty the shell holds.
|
|
enum SessionFocus {
|
|
/// A pid whose start time disagrees with the session file by more than this
|
|
/// has been recycled and belongs to something else. Wide enough to absorb
|
|
/// the gap between the process starting and the file being written.
|
|
static let pidReuseTolerance: TimeInterval = 5 * 60
|
|
|
|
// MARK: - Pure core
|
|
|
|
/// Walks from the agent's process up to the application that owns it,
|
|
/// keeping the first tty seen on the way — the shell the agent talks to.
|
|
///
|
|
/// Bounded rather than looped to pid 1: a `kinfo_proc` naming itself as its
|
|
/// own parent would otherwise spin, and no real agent-to-terminal chain is
|
|
/// more than a handful deep. launchd ends the walk with nothing, which is
|
|
/// the honest answer for an agent started outside a terminal.
|
|
static func owner(
|
|
of pid: pid_t,
|
|
hops: Int = 8,
|
|
lookup: (pid_t) -> SessionProcess?
|
|
) -> SessionFocusTarget? {
|
|
var current = pid
|
|
var tty: String?
|
|
for _ in 0..<hops {
|
|
guard current > 1, let process = lookup(current) else { return nil }
|
|
if tty == nil { tty = process.tty }
|
|
if let bundleID = process.bundleID {
|
|
return SessionFocusTarget(
|
|
appPID: current,
|
|
bundleID: bundleID,
|
|
appName: process.appName ?? bundleID,
|
|
tty: tty
|
|
)
|
|
}
|
|
guard process.ppid != current else { return nil }
|
|
current = process.ppid
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// The pid running `sessionID`, or nil when no file claims it, the process
|
|
/// is gone, or the pid has since been handed to something unrelated.
|
|
static func pid(
|
|
forSessionID sessionID: String,
|
|
files: [ClaudeSessionFile],
|
|
startTime: (pid_t) -> Date?
|
|
) -> pid_t? {
|
|
// Claude Code writes a fresh `<pid>.json` on every resume and never
|
|
// removes the old one, so several files can name this session and only
|
|
// the newest of them names the process that is still running.
|
|
guard let file = files.filter({ $0.sessionId == sessionID }).max(by: { $0.startedAt < $1.startedAt }),
|
|
let started = startTime(file.pid),
|
|
abs(started.timeIntervalSince1970 - file.startedAt / 1000) < pidReuseTolerance
|
|
else { return nil }
|
|
return file.pid
|
|
}
|
|
|
|
/// The one candidate working in `cwd`. Two agents in the same directory are
|
|
/// treated as not found: raising the wrong window is worse than doing nothing.
|
|
static func pid(forWorkingDirectory cwd: String, candidates: [(pid: pid_t, cwd: String)]) -> pid_t? {
|
|
let matches = candidates.filter { $0.cwd == cwd }
|
|
return matches.count == 1 ? matches[0].pid : nil
|
|
}
|
|
|
|
// MARK: - Resolving a row
|
|
|
|
/// Where this row points, or nil when its process cannot be found — which
|
|
/// leaves the row inert rather than promising a window that is not there.
|
|
/// Reads files and walks the process tree, so callers keep it off the main
|
|
/// actor.
|
|
static func target(for session: LiveSession) -> SessionFocusTarget? {
|
|
guard let pid = sessionPID(for: session) else { return nil }
|
|
return owner(of: pid, lookup: process(of:))
|
|
}
|
|
|
|
/// Selects the session's tab where the terminal allows it, then raises the
|
|
/// owning application either way. The script is a subprocess, so it runs off
|
|
/// the main actor with a hard cap on how long it may take.
|
|
@MainActor
|
|
static func raise(_ target: SessionFocusTarget) {
|
|
let app = NSRunningApplication(processIdentifier: target.appPID)
|
|
let script = SessionTabScript.script(bundleID: target.bundleID, tty: target.tty)
|
|
DispatchQueue.global(qos: .userInitiated).async {
|
|
if let script { runScript(script) }
|
|
DispatchQueue.main.async { app?.activate() }
|
|
}
|
|
}
|
|
|
|
private static func sessionPID(for session: LiveSession) -> pid_t? {
|
|
switch session.provider {
|
|
case "claude":
|
|
return pid(forSessionID: session.id, files: claudeSessionFiles(), startTime: startTime(of:))
|
|
case "kimicode":
|
|
guard let cwd = kimiWorkingDirectory(sessionID: session.id) else { return nil }
|
|
return pid(forWorkingDirectory: resolved(cwd), candidates: kimiProcesses())
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// MARK: - Claude
|
|
|
|
/// Every `<config dir>/sessions/<pid>.json` Claude Code has written. The
|
|
/// directory also holds `.key` files, which are not sessions.
|
|
private static func claudeSessionFiles() -> [ClaudeSessionFile] {
|
|
let decoder = JSONDecoder()
|
|
let directories = UsageDataChangeGuard.claudeConfigDirectories(
|
|
environment: ProcessInfo.processInfo.environment,
|
|
homeDirectory: NSHomeDirectory()
|
|
)
|
|
return directories.flatMap { configDir -> [ClaudeSessionFile] in
|
|
let directory = URL(fileURLWithPath: configDir).appendingPathComponent("sessions")
|
|
let entries = (try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []
|
|
return entries.filter { $0.hasSuffix(".json") }.compactMap { entry in
|
|
guard let data = try? Data(contentsOf: directory.appendingPathComponent(entry)) else { return nil }
|
|
return try? decoder.decode(ClaudeSessionFile.self, from: data)
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - Kimi Code
|
|
|
|
/// Kimi keeps `<home>/sessions/<wd_…>/session_<id>/state.json`, and the row's
|
|
/// id is that directory's suffix. Nothing on disk names the pid, so this only
|
|
/// recovers the directory the agent is working in.
|
|
static func kimiWorkingDirectory(
|
|
sessionID: String,
|
|
home: String = ProcessInfo.processInfo.environment["KIMI_CODE_HOME"]
|
|
?? NSHomeDirectory() + "/.kimi-code"
|
|
) -> String? {
|
|
let sessions = URL(fileURLWithPath: home).appendingPathComponent("sessions")
|
|
let workDirs = (try? FileManager.default.contentsOfDirectory(atPath: sessions.path)) ?? []
|
|
for workDir in workDirs {
|
|
let state = sessions
|
|
.appendingPathComponent(workDir)
|
|
.appendingPathComponent("session_" + sessionID)
|
|
.appendingPathComponent("state.json")
|
|
guard let data = try? Data(contentsOf: state),
|
|
let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
|
|
else { continue }
|
|
if let cwd = object["cwd"] as? String, !cwd.isEmpty { return cwd }
|
|
if let workDir = object["workDir"] as? String, !workDir.isEmpty { return workDir }
|
|
}
|
|
return nil
|
|
}
|
|
|
|
/// Running `kimi` processes with the directory each is working in. The name
|
|
/// comes from `p_comm`, which is the executable's, so a shell function or an
|
|
/// alias named `kimi` is not mistaken for one.
|
|
private static func kimiProcesses() -> [(pid: pid_t, cwd: String)] {
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_ALL, 0]
|
|
var size = 0
|
|
guard sysctl(&mib, u_int(mib.count), nil, &size, nil, 0) == 0, size > 0 else { return [] }
|
|
var table = [kinfo_proc](repeating: kinfo_proc(), count: size / MemoryLayout<kinfo_proc>.stride)
|
|
guard sysctl(&mib, u_int(mib.count), &table, &size, nil, 0) == 0 else { return [] }
|
|
return table.prefix(size / MemoryLayout<kinfo_proc>.stride).compactMap { entry in
|
|
var proc = entry
|
|
let width = MemoryLayout.size(ofValue: proc.kp_proc.p_comm)
|
|
let name = withUnsafePointer(to: &proc.kp_proc.p_comm) { pointer in
|
|
pointer.withMemoryRebound(to: CChar.self, capacity: width) { String(cString: $0) }
|
|
}
|
|
guard name == "kimi", let cwd = workingDirectory(of: proc.kp_proc.p_pid) else { return nil }
|
|
return (proc.kp_proc.p_pid, resolved(cwd))
|
|
}
|
|
}
|
|
|
|
/// The kernel reports `/private/var/…` where the user's path says `/var/…`,
|
|
/// and either side can carry a symlink, so both are compared resolved.
|
|
private static func resolved(_ path: String) -> String {
|
|
URL(fileURLWithPath: path).resolvingSymlinksInPath().path
|
|
}
|
|
|
|
// MARK: - Reading the process table
|
|
|
|
private static func process(of pid: pid_t) -> SessionProcess? {
|
|
guard let info = kernelProcess(pid) else { return nil }
|
|
var tty: String?
|
|
// NODEV (-1) means no controlling terminal; devname would read it as a
|
|
// real device number and answer garbage.
|
|
if info.kp_eproc.e_tdev != -1, let name = devname(info.kp_eproc.e_tdev, S_IFCHR) {
|
|
tty = String(cString: name)
|
|
}
|
|
let app = NSRunningApplication(processIdentifier: pid)
|
|
return SessionProcess(
|
|
ppid: info.kp_eproc.e_ppid,
|
|
tty: tty,
|
|
bundleID: app?.bundleIdentifier,
|
|
appName: app?.localizedName
|
|
)
|
|
}
|
|
|
|
static func startTime(of pid: pid_t) -> Date? {
|
|
guard let info = kernelProcess(pid) else { return nil }
|
|
let started = info.kp_proc.p_starttime
|
|
return Date(timeIntervalSince1970: Double(started.tv_sec) + Double(started.tv_usec) / 1_000_000)
|
|
}
|
|
|
|
private static func kernelProcess(_ pid: pid_t) -> kinfo_proc? {
|
|
var info = kinfo_proc()
|
|
var size = MemoryLayout<kinfo_proc>.stride
|
|
var mib: [Int32] = [CTL_KERN, KERN_PROC, KERN_PROC_PID, pid]
|
|
guard sysctl(&mib, u_int(mib.count), &info, &size, nil, 0) == 0, size > 0 else { return nil }
|
|
return info
|
|
}
|
|
|
|
private static func workingDirectory(of pid: pid_t) -> String? {
|
|
var info = proc_vnodepathinfo()
|
|
let read = proc_pidinfo(pid, PROC_PIDVNODEPATHINFO, 0, &info, Int32(MemoryLayout<proc_vnodepathinfo>.size))
|
|
guard read == Int32(MemoryLayout<proc_vnodepathinfo>.size) else { return nil }
|
|
return withUnsafePointer(to: &info.pvi_cdir.vip_path) { pointer in
|
|
pointer.withMemoryRebound(to: CChar.self, capacity: Int(MAXPATHLEN)) { String(cString: $0) }
|
|
}
|
|
}
|
|
|
|
// MARK: - Running the script
|
|
|
|
/// Detached with a hard cap: a terminal wedged on its own AppleScript must
|
|
/// not keep a worker alive, and the caller has already returned either way.
|
|
private static func runScript(_ script: String) {
|
|
let process = Process()
|
|
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
|
process.arguments = ["-e", script]
|
|
process.standardOutput = FileHandle.nullDevice
|
|
process.standardError = FileHandle.nullDevice
|
|
let finished = DispatchSemaphore(value: 0)
|
|
process.terminationHandler = { _ in finished.signal() }
|
|
do {
|
|
try process.run()
|
|
} catch {
|
|
NSLog("CodeBurn: could not spawn osascript to focus a session: \(error.localizedDescription)")
|
|
return
|
|
}
|
|
if finished.wait(timeout: .now() + 3) == .timedOut {
|
|
NSLog("CodeBurn: osascript did not select the session's tab in 3s; terminating it")
|
|
process.terminate()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One `~/.claude/sessions/<pid>.json`. Claude Code writes far more than this;
|
|
/// only the three fields that identify the running process are read.
|
|
struct ClaudeSessionFile: Decodable, Equatable, Sendable {
|
|
let pid: pid_t
|
|
let sessionId: String
|
|
/// Epoch milliseconds, used only to notice a recycled pid.
|
|
let startedAt: Double
|
|
}
|
|
|
|
/// AppleScript that selects the tab holding a tty.
|
|
///
|
|
/// There is no general mechanism: Terminal.app and iTerm2 both expose a tty per
|
|
/// tab, and everything else (Warp, Ghostty, WezTerm, kitty) publishes nothing to
|
|
/// match on, so those fall back to raising the application.
|
|
enum SessionTabScript {
|
|
static func script(bundleID: String, tty: String?) -> String? {
|
|
// The tty comes from the kernel, but it is interpolated into a script,
|
|
// so it is held to the shape `devname` produces rather than trusted.
|
|
guard let tty, !tty.isEmpty,
|
|
tty.allSatisfy({ $0.isASCII && ($0.isLetter || $0.isNumber) })
|
|
else { return nil }
|
|
switch bundleID {
|
|
case "com.apple.Terminal":
|
|
return """
|
|
tell application "Terminal"
|
|
repeat with w in windows
|
|
repeat with t in tabs of w
|
|
if tty of t is "/dev/\(tty)" then
|
|
set selected of t to true
|
|
set index of w to 1
|
|
return
|
|
end if
|
|
end repeat
|
|
end repeat
|
|
end tell
|
|
"""
|
|
case "com.googlecode.iterm2":
|
|
// "iTerm", not "iTerm2", for the reason PreferredTerminal records:
|
|
// the bundle is iTerm.app, and that name resolves whether or not
|
|
// the app's terminology has loaded.
|
|
return """
|
|
tell application "iTerm"
|
|
repeat with w in windows
|
|
repeat with t in tabs of w
|
|
repeat with s in sessions of t
|
|
if tty of s is "/dev/\(tty)" then
|
|
select s
|
|
select t
|
|
select w
|
|
return
|
|
end if
|
|
end repeat
|
|
end repeat
|
|
end repeat
|
|
end tell
|
|
"""
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
}
|