{
  "format" : 1,
  "manifest" : {
    "accent" : "red",
    "capabilities" : [
      {
        "run" : {
          "commands" : [
            "ps",
            "kill"
          ]
        }
      }
    ],
    "homepage" : "https://github.com/navbytes/stackbar-plugins/tree/main/System/KillProcess",
    "id" : "io.navbytes.stackbar.plugins.kill-process",
    "minimumStackbar" : "0.35.0",
    "name" : "Kill Process",
    "summary" : "Find a process by name, see its CPU and memory, and quit or force quit it.",
    "surfaces" : [
      "panel",
      "settings"
    ],
    "symbol" : "xmark.octagon.fill",
    "triggers" : [
      {
        "launch" : {

        }
      },
      {
        "every" : {
          "seconds" : 60
        }
      }
    ],
    "version" : "1.3.0"
  },
  "publisher" : {
    "name" : "navbytes",
    "publicKey" : "4dpLtXEpFXdi+6cviY/KdYuUulJPbEmCxxIK0EEN2/w="
  },
  "signature" : "U4ygbVXEos0+CswdNyx5MpVCjt0ypSs9vfKzzgvX0wXUePsfbJ14W+vALX2xQI8xiejdP9etx/y8howfFvzQBQ==",
  "source" : "import StackbarKit\nimport Foundation\n\n/// Find a stuck process by name and end it from the palette, as Raycast's\n/// Kill Process does: type \"kill\" and part of the name, see what it costs in\n/// CPU and memory, and press Return to quit it. Force Quit is a ⌘K away.\n/// Only your own processes are listed, and the few that would log you out\n/// are left alone.\n@main\nfinal class KillProcess: StackbarPlugin {\n    static let manifest = Manifest(\n        id: \"io.navbytes.stackbar.plugins.kill-process\",\n        name: \"Kill Process\",\n        version: \"1.3.0\",\n        summary: \"Find a process by name, see its CPU and memory, and quit or force quit it.\",\n        surfaces: [.panel, .settings],\n        triggers: [.launch, .every(.seconds(60))],\n        capabilities: [.run(commands: [\"ps\", \"kill\"])],\n        minimumStackbar: \"0.35.0\",\n        homepage: \"https://github.com/navbytes/stackbar-plugins/tree/main/System/KillProcess\",\n        accent: .red,\n        symbol: \"xmark.octagon.fill\"\n    )\n\n    static let previews: [PreviewScene<KillProcess>] = [\n        PreviewScene(\"hero\", \"The heaviest processes, one key from gone\") { plugin in\n            plugin.processes = sample\n            plugin.loadedAt = Date()\n        },\n        PreviewScene(\"memory\", \"Sort by memory to find the heavy one\") { plugin in\n            plugin.processes = sample\n            plugin.sortByMemory = true\n        },\n        PreviewScene(\"unavailable\", \"A failed scan keeps the last list\") { plugin in\n            plugin.processes = sample\n            plugin.problem = \"Couldn't run ps. Stackbar will try again in a minute.\"\n        },\n    ]\n\n    static let sample: [Proc] = [\n        Proc(pid: 48211, cpu: 182.4, memory: 1_240_000, name: \"node\", args: \"/usr/local/bin/node /Users/you/projects/api/server.js\"),\n        Proc(pid: 3121, cpu: 64.0, memory: 2_900_000, name: \"Google Chrome Helper (Renderer)\", args: \"/Applications/Google Chrome.app/Contents/Frameworks/Google Chrome Framework.framework/Helpers/Google Chrome Helper (Renderer).app/Contents/MacOS/Google Chrome Helper (Renderer) --type=renderer\"),\n        Proc(pid: 902, cpu: 21.7, memory: 640_000, name: \"Xcode\", args: \"/Applications/Xcode.app/Contents/MacOS/Xcode\"),\n        Proc(pid: 1880, cpu: 8.2, memory: 4_100_000, name: \"ollama\", args: \"/usr/local/bin/ollama serve\"),\n        Proc(pid: 733, cpu: 0.4, memory: 52_000, name: \"redis-server\", args: \"redis-server *:6379\"),\n    ]\n\n    let theme = Theme.red\n\n    struct Proc: Equatable, Identifiable {\n        var pid: Int\n        var cpu: Double\n        /// Resident memory in kilobytes, as `ps` reports it.\n        var memory: Int\n        var name: String\n        /// The whole command line, as `ps` reports it; empty when it couldn't be read.\n        var args: String = \"\"\n        var id: String { \"\\(pid)\" }\n    }\n\n    /// A process as the busy alert tracks it: by pid and by name, since a pid\n    /// can be reused by another program.\n    struct Watch: Hashable {\n        var pid: Int\n        var name: String\n    }\n\n    /// When a process first went above the line, and when it was last seen there.\n    struct Spell: Equatable {\n        var since: Date\n        var last: Date\n    }\n\n    /// Ending these logs you out or freezes the screen. This plugin's own\n    /// process is left out too.\n    static let protected: Set<String> = [\"loginwindow\", \"WindowServer\", \"Dock\", \"launchd\", \"kernel_task\", \"Stackbar\"]\n\n    static func isProtected(pid: Int, name: String) -> Bool {\n        protected.contains(name) || pid == Int(ProcessInfo.processInfo.processIdentifier)\n    }\n\n    var processes: [Proc] = []\n    var loadedAt: Date?\n    var problem: String?\n    var results: [Proc] = []\n    /// Processes above the busy line on each poll, since when.\n    var spells: [Watch: Spell] = [:]\n    /// Processes already notified for their current busy spell.\n    var notified: Set<Watch> = []\n    @Stored(\"sortByMemory\") var sortByMemory = false\n    @Stored(\"alertBusy\") var alertBusy = false\n    @Stored(\"alertAbove\") var alertAbove = \"90%\"\n\n    /// Reads every process and its command line. A failed read keeps the list\n    /// from before, which the panel and the palette show under a note.\n    func load(force: Bool = false) async {\n        if !force, let loadedAt, Date().timeIntervalSince(loadedAt) < 3 { return }\n        // Only this user's processes (-x without -a), with the bare\n        // executable name (-c) and no header (the = after each column).\n        guard let result = try? await Stackbar.run(\"ps\", [\"-xco\", \"pid=,%cpu=,rss=,comm=\"], timeout: .seconds(10)), result.succeeded else {\n            problem = \"Couldn't run ps. Stackbar will try again in a minute.\"\n            return\n        }\n        let listed = Self.parse(result.text)\n        // The command lines come from a second ps call, so a pid can have been\n        // reused in between; parseArgs checks each line against the name.\n        var commands: [Int: String] = [:]\n        if let lines = try? await Stackbar.run(\"ps\", [\"-xwwo\", \"pid=,args=\"], timeout: .seconds(10)), lines.succeeded {\n            commands = Self.parseArgs(lines.text, names: Dictionary(listed.map { ($0.pid, $0.name) }, uniquingKeysWith: { first, _ in first }))\n        }\n        processes = listed.map { process in\n            var process = process\n            process.args = commands[process.pid] ?? \"\"\n            return process\n        }\n        loadedAt = Date()\n        problem = nil\n    }\n\n    /// Every minute, so the panel's list and the numbers beside each\n    /// pid follow along; a scan the palette made a moment ago is reused.\n    /// Busy spells are checked after every fresh read, whatever started it.\n    /// They are timed on the clock, so an early read (a wake, a manual\n    /// refresh) only moves where a spell was last seen and can't speed it up.\n    func refresh() async throws {\n        let before = loadedAt\n        await load(force: Stackbar.refreshReason != .interval)\n        if loadedAt != before { checkBusy(at: Date()) }\n    }\n\n    /// `ps -o pid=,%cpu=,rss=,comm=` lines: `48211 182.4 1240000 node`.\n    static func parse(_ output: String) -> [Proc] {\n        output.split(separator: \"\\n\").compactMap { line -> Proc? in\n            let fields = line.split(separator: \" \", maxSplits: 3, omittingEmptySubsequences: true)\n            guard fields.count == 4, let pid = Int(fields[0]), let cpu = Double(fields[1].replacingOccurrences(of: \",\", with: \".\")), let memory = Int(fields[2]) else { return nil }\n            let name = String(fields[3]).trimmingCharacters(in: .whitespaces)\n            guard !name.isEmpty, !isProtected(pid: pid, name: name) else { return nil }\n            return Proc(pid: pid, cpu: cpu, memory: memory, name: name)\n        }\n    }\n\n    /// `ps -xwwo pid=,args=` lines: the whole command line of each pid. A line\n    /// counts only for a pid in `names` whose command line still holds that\n    /// name, so a pid reused between the two `ps` calls takes no stranger's\n    /// line, and the first line for a pid wins. A line without a pid (the rest\n    /// of an argument that has a newline in it) is skipped.\n    static func parseArgs(_ output: String, names: [Int: String]) -> [Int: String] {\n        var commands: [Int: String] = [:]\n        for raw in output.split(separator: \"\\n\") {\n            let parts = raw.drop { $0 == \" \" }.split(separator: \" \", maxSplits: 1, omittingEmptySubsequences: true)\n            guard parts.count == 2, let pid = Int(parts[0]), commands[pid] == nil, let name = names[pid] else { continue }\n            let args = String(parts[1]).trimmingCharacters(in: .whitespaces)\n            guard args.contains(name) else { continue }\n            commands[pid] = args\n        }\n        return commands\n    }\n\n    /// The command line without the program's own path: `/usr/local/bin/node\n    /// server.js` reads `node server.js`. The path ends at the last \"/name\"\n    /// followed by a space or the end: a directory can have a space and the\n    /// name in it (`/Users/me/node projects/x/node server.js`), and a helper's\n    /// bundle path has the name twice (`Code Helper.app/…/Code Helper --type=…`).\n    /// Capped at about 60 characters.\n    static func shortCommand(_ name: String, _ line: String) -> String {\n        var cut: String.Index?\n        var search = line.startIndex..<line.endIndex\n        while let range = line.range(of: \"/\" + name, range: search) {\n            if range.upperBound == line.endIndex || line[range.upperBound] == \" \" {\n                cut = range.upperBound\n            }\n            search = range.upperBound..<line.endIndex\n        }\n        let text = cut.map { name + String(line[$0...]) } ?? line\n        return text.count > 60 ? String(text.prefix(59)) + \"…\" : text\n    }\n\n    /// Heaviest first, by CPU or memory.\n    func sorted(_ list: [Proc]) -> [Proc] {\n        list.sorted { sortByMemory ? ($0.memory, $0.cpu) > ($1.memory, $1.cpu) : ($0.cpu, $0.memory) > ($1.cpu, $1.memory) }\n    }\n\n    /// Processes whose name or command line holds every word typed, or whose\n    /// pid is what was typed; the heaviest first, names that start with the\n    /// first word before the rest.\n    func matches(_ query: String) -> [Proc] {\n        let words = query.lowercased().split(separator: \" \").map(String.init)\n        guard !words.isEmpty else { return Array(sorted(processes).prefix(60)) }\n        if words.count == 1, let pid = Int(words[0]) { return processes.filter { $0.pid == pid } }\n        let found = processes.filter { process in\n            let text = (process.name + \" \" + process.args).lowercased()\n            return words.allSatisfy { text.contains($0) }\n        }\n        let first = words[0]\n        return Array(sorted(found).sorted { ($0.name.lowercased().hasPrefix(first) ? 0 : 1) < ($1.name.lowercased().hasPrefix(first) ? 0 : 1) }.prefix(60))\n    }\n\n    static func memory(_ kilobytes: Int) -> String {\n        let megabytes = Double(kilobytes) / 1024\n        return megabytes >= 1024 ? String(format: \"%.1f GB\", megabytes / 1024) : String(format: \"%.0f MB\", megabytes)\n    }\n\n    /// `Stackbar plugin: system-monitor` for a plugin's own process, which is\n    /// named by its identifier.\n    static func label(_ name: String) -> String {\n        guard name.hasPrefix(\"io.navbytes.stackbar.\") else { return name }\n        return \"Stackbar plugin: \" + (name.split(separator: \".\").last.map(String.init) ?? name)\n    }\n\n    // MARK: Busy alerts\n\n    /// Notifies once when one of your processes has been above the line for\n    /// two minutes of polls. Polls are a minute apart, so a gap longer than 90\n    /// seconds (a sleep, a missed poll) starts the count again. A process that\n    /// drops below the line can notify again later.\n    func checkBusy(at now: Date) {\n        guard alertBusy else {\n            spells = [:]\n            notified = []\n            return\n        }\n        let line = Double(alertAbove.dropLast()) ?? 90\n        var next: [Watch: Spell] = [:]\n        for process in processes where process.cpu >= line {\n            let watch = Watch(pid: process.pid, name: process.name)\n            var spell = spells[watch] ?? Spell(since: now, last: now)\n            if now.timeIntervalSince(spell.last) > 90 { spell = Spell(since: now, last: now) }\n            spell.last = now\n            next[watch] = spell\n        }\n        spells = next\n        notified.formIntersection(next.keys)\n        for (watch, spell) in next where now.timeIntervalSince(spell.since) >= 120 && !notified.contains(watch) {\n            guard let process = processes.first(where: { $0.pid == watch.pid && $0.name == watch.name }) else { continue }\n            notified.insert(watch)\n            Stackbar.notify(\"\\(Self.label(process.name)) has been busy for two minutes\", body: \"It has used \\(Int(process.cpu))% CPU recently. Show it to quit it.\", actions: [NotificationAction(\"Show\", command: \"search\", arguments: [\"text\": process.name])])\n        }\n    }\n\n    // MARK: Ending\n\n    /// Ends the processes the shared way. Each pid must still have the name it\n    /// was listed under, since pids get reused. The signal goes out, then the\n    /// pids are checked for up to two seconds. Processes that ended leave both\n    /// lists in place, and the HUD says how many ended.\n    func end(_ targets: [Proc], force: Bool) async {\n        let allowed = targets.filter { !Self.isProtected(pid: $0.pid, name: $0.name) }\n        guard let first = allowed.first else { return }\n        guard let names = await Self.commandNames(allowed.map(\\.pid)) else {\n            Stackbar.hud(\"Couldn't check \\(Self.label(first.name)); nothing was quit\", isError: true)\n            return\n        }\n        let ours = allowed.filter { names[$0.pid] == $0.name }\n        guard !ours.isEmpty else {\n            forget(Set(allowed.map(\\.pid)))\n            Stackbar.hud(\"\\(Self.label(first.name)) has already ended\")\n            return\n        }\n        let pids = ours.map(\\.pid)\n        let sent = try? await Stackbar.run(\"kill\", [force ? \"-KILL\" : \"-TERM\"] + pids.map(String.init), timeout: .seconds(10))\n        var alive = Set(pids)\n        for _ in 0..<8 {\n            try? await Task.sleep(for: .milliseconds(250))\n            // A check that failed says nothing, so the pids still count as alive.\n            guard let listed = await Self.commandNames(Array(alive)) else { continue }\n            alive = alive.filter { listed[$0] != nil }\n            if alive.isEmpty { break }\n        }\n        forget(Set(allowed.map(\\.pid)).subtracting(alive))\n        let ended = ours.count - alive.count\n        if alive.isEmpty {\n            Stackbar.hud(ended == allowed.count ? \"Ended \\(ended) of \\(allowed.count)\" : \"Ended \\(ended) · \\(allowed.count - ended) had already ended\")\n        } else {\n            let refused = sent?.errorText.contains(\"not permitted\") == true\n            Stackbar.hud(\"Ended \\(ended) of \\(allowed.count) · \\(Self.label(first.name)) is still running\" + (refused ? \": it belongs to another user\" : \"\"), isError: true)\n        }\n    }\n\n    /// The name each of these pids runs as now, in the same columns the list\n    /// is read in, so the names compare. Pids that are gone are left out. Nil\n    /// when the check itself failed (`ps` threw, timed out or wrote an error),\n    /// which is not the same as \"gone\".\n    static func commandNames(_ pids: [Int]) async -> [Int: String]? {\n        guard !pids.isEmpty else { return [:] }\n        guard let result = try? await Stackbar.run(\"ps\", [\"-co\", \"pid=,comm=\", \"-p\", pids.map(String.init).joined(separator: \",\")], timeout: .seconds(10)), !result.timedOut else { return nil }\n        // `ps -p` exits 1 with no output when none of the pids is running.\n        if result.status != 0 && !result.errorText.isEmpty { return nil }\n        var names: [Int: String] = [:]\n        for line in result.text.split(separator: \"\\n\") {\n            let fields = line.split(separator: \" \", maxSplits: 1, omittingEmptySubsequences: true)\n            guard fields.count == 2, let pid = Int(fields[0]) else { continue }\n            names[pid] = String(fields[1]).trimmingCharacters(in: .whitespaces)\n        }\n        return names\n    }\n\n    /// Takes these pids out of both lists, in place.\n    func forget(_ pids: Set<Int>) {\n        processes.removeAll { pids.contains($0.pid) }\n        results.removeAll { pids.contains($0.pid) }\n    }\n\n    /// The command line, shortened, then CPU and memory. The command line is\n    /// what tells two processes with the same name apart.\n    func subtitle(_ process: Proc) -> String {\n        let command = process.args.isEmpty ? \"pid \\(process.pid)\" : Self.shortCommand(process.name, process.args)\n        return \"\\(command) · \\(String(format: \"%.1f\", process.cpu))% CPU · \\(Self.memory(process.memory))\"\n    }\n\n    func row(_ process: Proc) -> Row {\n        let group = processes.filter { $0.name == process.name }\n        // Stackbar asks for a second press before any of these runs.\n        let name = Self.label(process.name)\n        var row = Row(name, subtitle: subtitle(process), symbol: \"gearshape\", key: process.id)\n            .action(\"Quit Process\", role: .critical, confirm: \"quit \\(name) (pid \\(process.pid))\") { await self.end([process], force: false) }\n            .action(\"Force Quit Process\", shortcut: \"cmd+shift+return\", role: .critical, confirm: \"force quit \\(name) (pid \\(process.pid))\") { await self.end([process], force: true) }\n        if group.count > 1 {\n            row = row.action(\"Quit All \\(group.count) Named \\(process.name)\", role: .critical, confirm: \"quit all \\(group.count) \\(name)\") { await self.end(group, force: false) }\n        }\n        row = row.action(\"Copy PID\", shortcut: \"cmd+shift+c\") { Stackbar.copy(\"\\(process.pid)\") }\n        if process.cpu >= 80 { row = row.badge(\"High CPU\", role: .critical) }\n        return row\n    }\n\n    // MARK: Commands\n\n    var commands: [Command] {\n        Command(\"search\", title: \"Kill Process\", subtitle: \"Quit or force quit a process by name\", symbol: \"xmark.octagon\", keywords: [\"kill\", \"process\", \"quit\", \"force\", \"cpu\", \"memory\", \"activity\"])\n            .view(search: { invocation in\n                await self.load()\n                // A notification's Show button passes the name as an argument, not as typed text.\n                let text = invocation.text.isEmpty ? invocation[\"text\"] ?? \"\" : invocation.text\n                self.results = self.matches(text)\n            }) { _ in\n                if self.processes.isEmpty, let problem = self.problem {\n                    ContentUnavailableView(\"Can't list processes\", symbol: \"exclamationmark.triangle\", message: problem)\n                } else {\n                    if let problem = self.problem { Banner(problem, theme: .warning) }\n                    if self.results.isEmpty {\n                        ContentUnavailableView(\"No process matches\", symbol: \"magnifyingglass\", message: \"Type part of a process name, or its pid.\")\n                    } else {\n                        List {\n                            ForEach(self.results) { process in self.row(process) }\n                        }\n                    }\n                }\n            }\n    }\n\n    // MARK: Panel\n\n    /// The busiest process and its CPU: the state at a glance.\n    var headline: String {\n        guard let busiest = processes.max(by: { $0.cpu < $1.cpu }) else { return \"Kill Process\" }\n        return \"\\(Self.label(busiest.name)) · \\(Int(busiest.cpu.rounded()))% CPU\"\n    }\n\n    var panel: ViewContent {\n        PanelHeader(headline, subtitle: \"\\(processes.count) of your processes · Kill Process in the palette\", symbol: \"xmark.octagon.fill\", theme: theme) {\n            IconButton(\"arrow.clockwise\", label: \"Refresh\", theme: theme, prominence: .tinted) { await self.load(force: true) }\n        }\n        if let problem {\n            Banner(problem, theme: .warning)\n        }\n        Picker(\"Sort by\", options: [\"CPU\", \"Memory\"], selection: sortByMemory ? \"Memory\" : \"CPU\", style: .segmented) { self.sortByMemory = $0 == \"Memory\" }\n        Card(\"Heaviest now\", spacing: 0, padding: Space.s) {\n            ForEach(Array(sorted(processes).prefix(8))) { process in self.row(process) }\n        }\n    }\n\n    // MARK: Settings\n\n    var settings: ViewContent {\n        Section(\"Alerts\") {\n            Toggle(\"Notify when a process stays busy\", isOn: alertBusy) { self.alertBusy = $0 }\n            Picker(\"Busy above\", options: [\"50%\", \"90%\", \"150%\"], selection: alertAbove, style: .segmented) { self.alertAbove = $0 }\n            Text(\"After two minutes above the line, once, naming the process. A process that calms down can notify again. Checked every minute, for your own processes.\").style(.caption)\n        }\n    }\n}\n",
  "sourceDigest" : "e9070137752ae9b563beb4f58a61764928ba1a5a3b1f79010dcf635b1586be22"
}