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
190 changes: 190 additions & 0 deletions Glint/Agent/AgentHookInstaller.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1804,3 +1804,193 @@ enum GrokHookInstaller {
}
}
}

/// Installs a TypeScript extension that forwards pi (pi-coding-agent)
/// lifecycle events to Glint's local agent socket.
///
/// Unlike Claude/Codex (which fire shell hooks whose stdin JSON we parse) and
/// like OMP, pi exposes its lifecycle through a TypeScript extension API
/// (`pi.on("event", …)`). So instead of registering the shared
/// `glint-report.sh` reporter, we drop a `.ts` module into pi's auto-discovered
/// extensions directory and let it speak the same socket protocol directly.
///
/// pi auto-loads `*.ts` from `~/.pi/agent/extensions/` (global scope) on every
/// session — no settings-file merge needed, unlike OMP. The module is inert
/// outside a Glint pane: it bails unless both `GLINT_PANE_ID` and
/// `GLINT_AGENT_SOCK` are present in the environment.
enum PiHookInstaller {
private static let extensionFileName = "glint-agent-bridge.ts"
/// Marker string embedded in the generated extension body — `isInstalled`
/// keys off it so a hand-written file in the same path isn't treated as
/// Glint-managed, and reinstalls can rewrite our own copy safely.
static let marker = "Glint pi extension"

static func defaultExtensionURL() -> URL {
FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi/agent/extensions", isDirectory: true)
.appendingPathComponent(extensionFileName)
}

static func isInstalled(extensionURL: URL = PiHookInstaller.defaultExtensionURL()) -> Bool {
guard let body = try? String(contentsOf: extensionURL), body.contains(marker) else {
return false
}
return true
}

/// Whether pi itself looks installed on this Mac. Prefer the config dir
/// and the binary over a bare directory probe: pi always creates
/// `~/.pi/agent/` once it has run, but we don't want to offer hooks for a
/// stale dot-dir left after an uninstall. The `pi` binary on PATH is the
/// strongest signal (it's an npm bin shim → `node dist/cli.js`).
static func isAgentPresent() -> Bool {
AgentPresence.commandExists("pi")
|| AgentPresence.directoryExists(".pi/agent")
}

static func installIfNeeded(socketPath: String,
extensionURL: URL = PiHookInstaller.defaultExtensionURL()) {
do {
let dir = extensionURL.deletingLastPathComponent()
try FileManager.default.createDirectory(
at: dir,
withIntermediateDirectories: true,
attributes: [.posixPermissions: 0o700]
)
let body = extensionBody
let needsWrite = (try? String(contentsOf: extensionURL)) != body
if needsWrite {
try body.write(to: extensionURL, atomically: true, encoding: .utf8)
try FileManager.default.setAttributes(
[.posixPermissions: 0o600],
ofItemAtPath: extensionURL.path
)
}
NSLog("[glint] pi extension installed at \(extensionURL.path)")
} catch {
NSLog("[glint] pi extension install failed: \(error)")
}
_ = socketPath
}

static func uninstall(extensionURL: URL = PiHookInstaller.defaultExtensionURL()) {
if let body = try? String(contentsOf: extensionURL), body.contains(marker) {
try? FileManager.default.removeItem(at: extensionURL)
NSLog("[glint] pi extension removed from \(extensionURL.path)")
}
}

/// TypeScript extension loaded by pi's extension runner. Only arms when
/// the pane env vars are present, so a pi session outside Glint is a
/// no-op. Session id is pulled from `ctx.sessionManager.getSessionId()`
/// and forwarded as `session_b64` for restore-on-launch.
///
/// pi emits the same event names the official docs document
/// (`session_start`, `before_agent_start`, `tool_call`, `tool_result`,
/// `agent_end`, `session_before_compact`). The ask state is handled in a
/// single `tool_call` branch: `ask_user_question` (pi-subagents' blocking
/// question tool) and `exit_plan_mode` (plan approval) both block the
/// turn waiting for the user, so they're remapped to `NeedsReply` instead
/// of `PreToolUse` — mirroring how Grok's reporter handles the same pair.
static let extensionBody: String = """
// \(marker). Auto-generated by Glint; remove from Settings → Agents.
// @ts-nocheck
import { createConnection } from "node:net"
import { existsSync } from "node:fs"

const AGENT = "pi"
const SESSION_ID_RE = /^\(PaneAgentKind.sessionIdCharsetClass){1,\(PaneAgentKind.sessionIdMaxLength)}$/

function pickSessionId(ctx) {
try {
const id = ctx?.sessionManager?.getSessionId?.()
if (typeof id === "string" && SESSION_ID_RE.test(id)) return id
} catch {}
return null
}

function send(hook, sessionId) {
const pane = process.env.GLINT_PANE_ID
const sock = process.env.GLINT_AGENT_SOCK
if (!pane || !sock || !existsSync(sock)) return Promise.resolve()

const payload = { pane, hook, agent: AGENT }
if (sessionId) {
payload.session_b64 = Buffer.from(sessionId, "utf8").toString("base64")
}
const line = JSON.stringify(payload) + "\\n"

// Use end(line) so the write is flushed before the socket closes —
// write()+destroy() races the kernel and can drop the report.
return new Promise((resolve) => {
let done = false
const finish = () => {
if (done) return
done = true
resolve()
}
try {
const client = createConnection(sock, () => client.end(line))
client.on("error", finish)
client.on("close", finish)
const timer = setTimeout(() => {
try { client.destroy() } catch {}
finish()
}, 1000)
timer.unref?.()
} catch {
finish()
}
})
}

// Tools that block the turn waiting for the user — same semantics as
// OMP's `ask` tool and Grok's ask_user_question/exit_plan_mode. Sourced
// here so the single tool_call branch below stays readable.
const ASK_TOOLS = new Set(["ask_user_question", "exit_plan_mode"])

export default function (pi) {
const pane = process.env.GLINT_PANE_ID
const sock = process.env.GLINT_AGENT_SOCK
if (!pane || !sock) return

pi.on("session_start", (_event, ctx) => {
void send("SessionStart", pickSessionId(ctx))
})

// before_agent_start fires after the user submits a prompt, before the
// agent loop begins — the closest analogue to Claude's UserPromptSubmit.
pi.on("before_agent_start", (_event, ctx) => {
void send("UserPromptSubmit", pickSessionId(ctx))
})

pi.on("tool_call", (event, ctx) => {
// A single branch for the ask state: tools that block on the user
// surface as NeedsReply ("awaiting reply"), every other tool fires
// PreToolUse. The answer's own tool_result → PostToolUse flips the
// pane back to busy.
if (ASK_TOOLS.has(event?.toolName)) {
void send("NeedsReply", pickSessionId(ctx))
} else {
void send("PreToolUse", pickSessionId(ctx))
}
})

pi.on("tool_result", (_event, ctx) => {
void send("PostToolUse", pickSessionId(ctx))
})

pi.on("session_before_compact", (_event, ctx) => {
void send("PreCompact", pickSessionId(ctx))
})

// agent_end can fire multiple times per session (retries, follow-ups).
// agent_settled is the true "pi will not continue automatically"
// signal — use it for the Stop badge so the pane doesn't flash green
// then go busy again on an auto-retry.
pi.on("agent_settled", (_event, ctx) => {
void send("Stop", pickSessionId(ctx))
})
}
"""
}
1 change: 1 addition & 0 deletions Glint/Agent/AgentPaneSummary.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ extension PaneAgentKind {
case .devin: return .devin
case .omp: return .omp
case .grok: return .grok
case .pi: return .pi
}
}
}
Expand Down
9 changes: 9 additions & 0 deletions Glint/Agent/PaneAgentState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ enum PaneAgentKind: String, Codable {
case devin
case omp
case grok
case pi

/// Human-facing label for the per-pane summary popover.
var displayName: String {
Expand All @@ -18,6 +19,7 @@ enum PaneAgentKind: String, Codable {
case .devin: return "Devin"
case .omp: return "OMP"
case .grok: return "Grok"
case .pi: return "Pi"
}
}

Expand Down Expand Up @@ -82,6 +84,13 @@ enum PaneAgentKind: String, Codable {
return validated.map { "omp -r \($0)\n" } ?? "omp -c\n"
case .grok:
return validated.map { "grok --resume \($0)\n" } ?? "grok --continue\n"
case .pi:
// `pi --session-id <id>` uses an exact project session id,
// creating it if missing — so a restored pane lands back in its
// own session instead of being prompted to pick one (as the
// interactive `--resume` would). Falls back to `pi --continue`
// (resume the most-recent) when no id was captured.
return validated.map { "pi --session-id \($0)\n" } ?? "pi --continue\n"
}
}
}
Expand Down
50 changes: 49 additions & 1 deletion Glint/Chrome/SettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable {
case .general: return "Startup, layout, updates"
case .appearance: return "Theme, accent, glass"
case .terminal: return "Font, cursor, scrollback"
case .agents: return "Claude Code, Codex, OMP, Grok, hook routing"
case .agents: return "Claude Code, Codex, OMP, Grok, Pi, hook routing"
case .shortcuts: return "Keyboard reference"
case .about: return nil
}
Expand Down Expand Up @@ -1169,6 +1169,7 @@ private struct AgentsPane: View {
@State private var devinInstallFailed = false
@State private var ompInstallFailed = false
@State private var grokInstallFailed = false
@State private var piInstallFailed = false
@State private var newCodexHomePath = ""
@State private var newCodexHomeLabel = ""
@State private var codexHomeErrors: [UUID: String] = [:]
Expand Down Expand Up @@ -1486,6 +1487,53 @@ private struct AgentsPane: View {
}
}

SettingsCard("Pi",
footer: "Glint installs a TypeScript extension into ~/.pi/agent/extensions/ so pi sessions report thinking, tools, and ask_user_question (awaiting reply). Auto-discovered by pi on every session — no settings merge needed. Only fires when Glint's pane environment variables are present, so a pi session outside Glint is a no-op.") {
SettingsRow("Status", subtitle: piInstallFailed
? "Install failed — check Console for [glint] logs."
: (store.piHooksInstalled
? "Extension installed into your pi extensions directory."
: (store.piDetected
? "Pi detected — install the extension to show its status."
: "Pi not detected on this Mac."))) {
HStack(spacing: 8) {
StatusPill(
label: store.piHooksInstalled ? "Installed" : (store.piDetected ? "Not installed" : "Not detected"),
tone: store.piHooksInstalled ? .ok : .neutral
)
if store.piHooksInstalled {
Button("Uninstall") {
store.uninstallPiHooks()
piInstallFailed = false
}
.controlSize(.small)
} else {
Button("Install") {
store.installPiHooks()
piInstallFailed = !store.piHooksInstalled
}
.controlSize(.small)
.tint(store.accent)
}
}
}
SettingsDivider()
SettingsRow("Resume session on launch",
subtitle: "When Glint reopens, each pane that was running pi at last quit is resumed via `pi --session-id <session-id>` — so multiple pi panes in one workspace land back in their own sessions. Falls back to `pi --continue` for panes whose session id wasn't captured.") {
Toggle("", isOn: $store.restorePiSession)
.toggleStyle(.switch).labelsHidden()
}
SettingsDivider()
SettingsRow("Hook config",
subtitle: "Auto-discovered TypeScript extension under pi's global extensions directory; only reports when Glint's pane environment variables are present.") {
Text("~/.pi/agent/extensions/glint-agent-bridge.ts")
.font(.system(size: 11, design: .monospaced))
.foregroundStyle(Theme.text3)
.lineLimit(1)
.truncationMode(.head)
}
}

SettingsCard("Notifications",
footer: "Dock badges and chimes only update for background workspaces — the one you're looking at stays quiet.") {
SettingsRow("Show Dock badge for agent attention",
Expand Down
Loading