feat(x): auto-stop on call end + redirect to finished notes (Granola parity phase 3)

- mic-monitor now resolves each mic owner's bundle ID (CoreAudio) and
  executable path (libproc) natively — the main process never shells out
  during capture (child-process spawns fail with EBADF while Electron
  holds a capture session; this bit the ps-based first cut)
- detector matches owners by bundle-ID prefix (com.google.chrome,
  us.zoom, com.tinyspeck.slackmacgap, …) with executable-name fallback,
  and recognizes Rowboat's own capture (com.rowboat / dev electron)
- call-end detection: once a meeting app has been seen on the mic during
  a recording, its absence for 3s means the call ended → auto-stop →
  summarize — same flow as a manual stop. Arms only when a meeting app
  was actually observed, so in-person recordings never auto-stop
- post-summary redirect: Rowboat foregrounds itself on the finished note
  (app:focusMainWindow now steals focus); the new meeting_notes_ready
  notification stays as a background-only fallback with a toggle in
  Settings > Notifications
This commit is contained in:
Gagan 2026-07-14 00:37:10 +05:30
parent 66865b4f6b
commit 95f232cca9
8 changed files with 320 additions and 84 deletions

View file

@ -8,14 +8,18 @@
//
// Two signals, best available wins:
// - macOS 14.4+: per-process audio objects (kAudioHardwarePropertyProcessObjectList
// + kAudioProcessPropertyIsRunningInput) give the PIDs that own the mic,
// so the consumer can attribute the call to Chrome vs Zoom vs Slack.
// + kAudioProcessPropertyIsRunningInput) give the processes that own the
// mic. Each owner is resolved to its bundle ID (CoreAudio) and executable
// path (libproc) HERE, natively the consumer must not need to shell out
// (child-process spawns from Electron's main process fail with EBADF
// while capture is active).
// - Fallback: kAudioDevicePropertyDeviceIsRunningSomewhere on the default
// input device mic in use by *someone*, no attribution.
//
// Protocol: one JSON object per line on stdout, emitted on every state
// change (and once at startup): {"micInUse":true|false,"pids":[123,456]}
// ("pids" is empty when attribution is unavailable.)
// change (and once at startup):
// {"micInUse":true,"owners":[{"pid":123,"bundleId":"com.google.Chrome.helper","path":"/Applications/..."}]}
// ("owners" is empty when attribution is unavailable.)
// The process exits when stdin closes, so it can never outlive the app.
//
// Compiled by apps/main/bundle.mjs (best-effort, macOS only) to
@ -23,6 +27,7 @@
import Foundation
import CoreAudio
import Darwin
func defaultInputDeviceID() -> AudioDeviceID? {
var deviceID = AudioDeviceID(0)
@ -87,14 +92,64 @@ func processPID(_ object: AudioObjectID) -> Int32? {
return Int32(pid)
}
/// PIDs of processes currently capturing from any input device (macOS 14.4+;
func processBundleID(_ object: AudioObjectID) -> String? {
var addr = AudioObjectPropertyAddress(
mSelector: kAudioProcessPropertyBundleID,
mScope: kAudioObjectPropertyScopeGlobal,
mElement: kAudioObjectPropertyElementMain)
var value: Unmanaged<CFString>? = nil
var size = UInt32(MemoryLayout<Unmanaged<CFString>?>.size)
guard AudioObjectGetPropertyData(object, &addr, 0, nil, &size, &value) == noErr,
let cf = value else { return nil }
let str = cf.takeRetainedValue() as String
return str.isEmpty ? nil : str
}
func processPath(_ pid: Int32) -> String? {
var buffer = [CChar](repeating: 0, count: 4096)
let length = proc_pidpath(pid, &buffer, UInt32(buffer.count))
guard length > 0 else { return nil }
return String(cString: buffer)
}
struct MicOwner: Equatable {
let pid: Int32
let bundleId: String
let path: String
}
/// Processes currently capturing from any input device (macOS 14.4+;
/// returns [] where unsupported and the device-level fallback takes over).
func micOwningPIDs() -> [Int32] {
var pids: [Int32] = []
func micOwners() -> [MicOwner] {
var owners: [MicOwner] = []
for object in audioProcessObjectIDs() where processIsRunningInput(object) {
if let pid = processPID(object) { pids.append(pid) }
guard let pid = processPID(object) else { continue }
owners.append(MicOwner(
pid: pid,
bundleId: processBundleID(object) ?? "",
path: processPath(pid) ?? ""))
}
return pids.sorted()
return owners.sorted { $0.pid < $1.pid }
}
func jsonEscape(_ s: String) -> String {
var out = ""
for ch in s.unicodeScalars {
switch ch {
case "\"": out += "\\\""
case "\\": out += "\\\\"
case "\n": out += "\\n"
case "\r": out += "\\r"
case "\t": out += "\\t"
default:
if ch.value < 0x20 {
out += String(format: "\\u%04x", ch.value)
} else {
out.unicodeScalars.append(ch)
}
}
}
return out
}
// Exit when the parent closes our stdin (app quit/crash) never orphan.
@ -106,18 +161,20 @@ Thread {
setbuf(stdout, nil)
var lastInUse: Bool? = nil
var lastPids: [Int32] = []
var lastOwners: [MicOwner] = []
while true {
let pids = micOwningPIDs()
let owners = micOwners()
// Re-resolve the default device every poll: the user can switch input
// devices (AirPods in/out) mid-session.
let deviceInUse = defaultInputDeviceID().map(isRunningSomewhere) ?? false
let inUse = deviceInUse || !pids.isEmpty
if inUse != lastInUse || pids != lastPids {
let inUse = deviceInUse || !owners.isEmpty
if inUse != lastInUse || owners != lastOwners {
lastInUse = inUse
lastPids = pids
let pidList = pids.map(String.init).joined(separator: ",")
print("{\"micInUse\":\(inUse),\"pids\":[\(pidList)]}")
lastOwners = owners
let ownerJson = owners.map { o in
"{\"pid\":\(o.pid),\"bundleId\":\"\(jsonEscape(o.bundleId))\",\"path\":\"\(jsonEscape(o.path))\"}"
}.joined(separator: ",")
print("{\"micInUse\":\(inUse),\"owners\":[\(ownerJson)]}")
}
Thread.sleep(forTimeInterval: 1.0)
}

View file

@ -68,6 +68,7 @@ import { isOnboardingComplete, markOnboardingComplete } from '@x/core/dist/confi
import { loadNotificationSettings, saveNotificationSettings } from '@x/core/dist/config/notification_config.js';
import { saveAppSettings } from '@x/core/dist/config/app_settings.js';
import { setSelfCaptureActive } from '@x/core/dist/meetings/detector.js';
import { notifyIfEnabled } from '@x/core/dist/application/notification/notifier.js';
import { consumePendingToggleMeetingNotes, setTrayRecordingState } from './tray.js';
import { closeMeetingPopup, getMeetingPopupPayload, handleMeetingPopupAction } from './meeting-popup.js';
@ -887,6 +888,19 @@ export function setupIpcHandlers() {
updateSelfCaptureState();
return { success: true as const };
},
'meeting:notifyNotesReady': async (_event, args) => {
// Granola-style re-entry point: the note refreshed in place, but the
// user has usually switched back to the meeting app — the notification
// brings them back. Suppressed while the app is focused.
void notifyIfEnabled('meeting_notes_ready', {
title: 'Meeting notes ready',
message: `Your notes for "${args.title}" are ready.`,
link: `rowboat://open?type=file&path=${encodeURIComponent(args.notePath)}`,
actionLabel: 'Open notes',
onlyWhenBackground: true,
});
return { success: true as const };
},
'meetingDetect:getPayload': async () => {
return { payload: getMeetingPopupPayload() };
},
@ -2246,6 +2260,9 @@ export function setupIpcHandlers() {
if (main.isMinimized()) main.restore();
main.show();
main.focus();
// The user is typically in another app (e.g. just left a meeting) —
// a plain focus() won't take the foreground from it.
app.focus({ steal: true });
}
return {};
},

View file

@ -538,6 +538,13 @@ app.whenReady().then(async () => {
initMeetingDetection({
helperPath: path.join(__dirname, "mic-monitor"),
onDetected: (meeting) => showMeetingPopup(meeting),
// Call ended while recording (meeting app released the mic) — the
// renderer stops capture and generates notes, same as a manual stop.
onExternalCallEnded: () => {
const win = mainWindow;
if (!win || win.isDestroyed() || win.webContents.isLoading()) return;
win.webContents.send("meeting:externalCallEnded", null);
},
});
// Start workspace watcher as a main-process service

View file

@ -1116,6 +1116,16 @@ function App() {
.catch(() => { /* tray may be unavailable */ })
}, [meetingTranscription.state])
// Main detected the meeting app released the mic (call ended) — stop and
// generate notes, exactly like a manual stop. Listener only exists while
// recording, so a stale signal can never toggle a new recording ON.
useEffect(() => {
if (meetingTranscription.state !== 'recording') return
return window.ipc.on('meeting:externalCallEnded', () => {
handleToggleMeetingRef.current?.()
})
}, [meetingTranscription.state])
// Check if voice is available on mount and when OAuth state changes
const refreshVoiceAvailability = useCallback(() => {
Promise.all([
@ -5664,6 +5674,14 @@ function App() {
})
// Refresh the file view
await handleVoiceNoteCreated(notePath)
// Notes are done — bring Rowboat to the foreground on the
// finished note (the post-call "redirect"). The notification
// below is background-only, so it only fires if the focus
// grab didn't take.
void window.ipc.invoke('app:focusMainWindow', null).catch(() => {})
void window.ipc
.invoke('meeting:notifyNotesReady', { notePath, title: noteTitle })
.catch(() => { /* notification is best-effort */ })
}
}
} catch (err) {

View file

@ -2149,7 +2149,7 @@ function CodeModeSettings({ dialogOpen }: { dialogOpen: boolean }) {
// --- Notification Settings ---
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" | "meeting_detection"
type NotificationCategoryKey = "chat_completion" | "new_email" | "agent_permission" | "background_task" | "meeting_detection" | "meeting_notes_ready"
const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; description: string }[] = [
{
@ -2177,6 +2177,11 @@ const NOTIFICATION_CATEGORIES: { key: NotificationCategoryKey; label: string; de
label: "Meeting detection",
description: "A popup offering to take notes when Rowboat notices you're in a call or meeting. Nothing records until you accept.",
},
{
key: "meeting_notes_ready",
label: "Meeting notes ready",
description: "When your meeting notes finish generating after a call. Click to open the note. Only shown while the app is in the background.",
},
]
function NotificationSettings({ dialogOpen }: { dialogOpen: boolean }) {

View file

@ -9,20 +9,26 @@ import { isNotificationCategoryEnabled } from "../config/notification_config.js"
* Ambient meeting detection (Granola-style).
*
* Signal: the mic-monitor helper (apps/main/native/mic-monitor.swift) reports
* when ANY process starts using the microphone with the owning PIDs on
* macOS 14.4+, so the call is attributed to the app that actually holds the
* mic (Chrome vs Zoom vs Slack), not just whatever meeting app happens to be
* running. When the mic has been in use continuously for a few seconds and
* it isn't Rowboat's own capture we label the popup ("Huddle detected" for
* Slack, "Call detected" for FaceTime/WhatsApp, "Meeting detected"
* otherwise), merge with a calendar event whose window covers now
* (start 15 min through end), and emit a DetectedMeeting.
* when ANY process starts using the microphone with the owning processes'
* bundle IDs and executable paths on macOS 14.4+, resolved natively in the
* helper. That matters twice over: attribution is exact (a Meet call in
* Chrome attributes to Chrome even while Slack/Zoom idle in the background),
* and the main process never has to shell out while capture is active
* (child-process spawns from Electron's main fail with EBADF during capture).
*
* Never auto-records: the consumer (main process) shows a "Take Notes?"
* popup and waits for a click.
* When the mic has been in use continuously for a few seconds and it isn't
* Rowboat's own capture we label the popup ("Huddle detected" for Slack,
* "Call detected" for FaceTime/WhatsApp, "Meeting detected" otherwise),
* merge with a calendar event whose window covers now (start 15 min
* through end), and emit a DetectedMeeting. Never auto-records: the consumer
* (main process) shows a "Take Notes?" popup and waits for a click.
*
* Fires at most once per continuous mic-in-use session; the session resets
* after the mic has been idle for MIC_SESSION_RESET_MS.
*
* While Rowboat records, the same owner list powers call-end detection: once
* a meeting app has been seen on the mic, its absence for CALL_END_GRACE_MS
* means the call ended.
*/
const POLL_INTERVAL_MS = 2_000;
@ -34,6 +40,10 @@ const MIC_SESSION_RESET_MS = 30_000;
// A calendar event merges with a detected call from 15 min before its start
// (Granola merges ad-hoc calls within 15 minutes of a scheduled event).
const CALENDAR_MERGE_LEAD_MS = 15 * 60_000;
// While recording, the meeting app must be off the mic this long before we
// call the meeting over. Kept short so notes arrive right after hang-up;
// only guards against sub-poll device churn, not full reconnects.
const CALL_END_GRACE_MS = 3_000;
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
const HELPER_MAX_RESTARTS = 3;
@ -51,12 +61,47 @@ export interface DetectedMeeting {
calendarEvent?: Record<string, unknown>;
}
// Matched against process names by case-insensitive prefix (Electron/Chromium
// apps capture through helper processes: "Google Chrome Helper (Renderer)",
// "Slack Helper"…). When mic-owner PIDs are available this is exact
// attribution; otherwise it's a running-app heuristic ordered by confidence:
// dedicated conferencing apps first, always-running chat apps after, browsers
// as the generic fallback.
interface PlatformMatch {
app: string;
kind: DetectedMeetingKind;
}
// Bundle-ID prefixes (lowercased) → platform. The primary matcher: exact and
// stable, covers helper processes too (com.google.Chrome.helper etc.).
const BUNDLE_MATCHERS: Array<{ prefix: string; app: string; kind: DetectedMeetingKind }> = [
{ prefix: "us.zoom", app: "Zoom", kind: "meeting" },
{ prefix: "com.microsoft.teams", app: "Microsoft Teams", kind: "meeting" },
{ prefix: "com.cisco.webex", app: "Webex", kind: "meeting" },
{ prefix: "com.webex", app: "Webex", kind: "meeting" },
{ prefix: "com.tinyspeck.slackmacgap", app: "Slack", kind: "huddle" },
{ prefix: "com.apple.facetime", app: "FaceTime", kind: "call" },
{ prefix: "net.whatsapp", app: "WhatsApp", kind: "call" },
{ prefix: "com.hnc.discord", app: "Discord", kind: "call" },
// Browsers — generic "Meeting detected".
{ prefix: "com.google.chrome", app: "Google Chrome", kind: "meeting" },
{ prefix: "com.apple.safari", app: "Safari", kind: "meeting" },
{ prefix: "com.apple.webkit", app: "Safari", kind: "meeting" },
{ prefix: "org.mozilla.firefox", app: "Firefox", kind: "meeting" },
{ prefix: "com.brave.browser", app: "Brave Browser", kind: "meeting" },
{ prefix: "com.microsoft.edgemac", app: "Microsoft Edge", kind: "meeting" },
{ prefix: "company.thebrowser", app: "Arc", kind: "meeting" },
];
// Rowboat's own capture (and the dev Electron shell) — never a "meeting".
const SELF_BUNDLE_PREFIXES = ["com.rowboat", "com.github.electron"];
const BROWSER_APPS = new Set([
"Google Chrome",
"Safari",
"Firefox",
"Brave Browser",
"Microsoft Edge",
"Arc",
]);
// Process-name fallbacks (case-insensitive prefix on the executable
// basename): used when an owner has no bundle ID, and by the pre-14.4
// running-app heuristic. Ordered by confidence for the heuristic.
const MEETING_APPS: Array<{ proc: string; app: string; kind: DetectedMeetingKind }> = [
{ proc: "zoom.us", app: "Zoom", kind: "meeting" },
{ proc: "MSTeams", app: "Microsoft Teams", kind: "meeting" },
@ -85,21 +130,38 @@ const KIND_TITLES: Record<DetectedMeetingKind, string> = {
meeting: "Meeting detected",
};
interface MicOwner {
pid: number;
bundleId: string;
path: string;
}
interface DetectorOptions {
/** Absolute path to the compiled mic-monitor helper binary. */
helperPath: string;
onDetected: (meeting: DetectedMeeting) => void;
/**
* Fired once per recording session when the meeting app that was on the
* mic has released it for CALL_END_GRACE_MS while Rowboat is still
* capturing i.e. the call ended. Needs per-process attribution
* (macOS 14.4+); silently unavailable otherwise.
*/
onExternalCallEnded?: () => void;
}
let started = false;
let selfCaptureActive = false;
let micInUse = false;
// PIDs currently capturing from the mic (macOS 14.4+; empty = unknown).
let micPids: number[] = [];
// Processes currently capturing from the mic (macOS 14.4+; empty = unknown).
let micOwners: MicOwner[] = [];
let micInUseSince: number | null = null;
let micIdleSince: number | null = null;
let sessionNotified = false;
let helperRestarts = 0;
// Call-end tracking for the current self-capture session.
let externalAppSeen = false;
let externalAbsentSince: number | null = null;
let callEndFired = false;
/**
* Rowboat's own capture (meeting recording, assistant voice/video call) also
@ -108,6 +170,10 @@ let helperRestarts = 0;
*/
export function setSelfCaptureActive(active: boolean): void {
selfCaptureActive = active;
// Fresh capture session — re-arm call-end tracking.
externalAppSeen = false;
externalAbsentSince = null;
callEndFired = false;
if (active) {
// Whatever mic session is in flight is ours — don't prompt when the
// user stops and the mic lingers.
@ -131,6 +197,7 @@ export function init(options: DetectorOptions): void {
setInterval(() => {
try {
tick(options.onDetected);
checkExternalCallEnd(options.onExternalCallEnded);
} catch (err) {
console.error("[MeetingDetect] tick failed:", err);
}
@ -159,8 +226,13 @@ function spawnHelper(helperPath: string): void {
const parsed = JSON.parse(line);
if (typeof parsed?.micInUse === "boolean") {
micInUse = parsed.micInUse;
micPids = Array.isArray(parsed.pids)
? parsed.pids.filter((p: unknown): p is number => typeof p === "number")
micOwners = Array.isArray(parsed.owners)
? parsed.owners.filter(
(o: unknown): o is MicOwner =>
typeof (o as MicOwner)?.pid === "number" &&
typeof (o as MicOwner)?.bundleId === "string" &&
typeof (o as MicOwner)?.path === "string",
)
: [];
}
} catch {
@ -171,7 +243,7 @@ function spawnHelper(helperPath: string): void {
child.on("exit", (code) => {
micInUse = false;
micPids = [];
micOwners = [];
if (helperRestarts < HELPER_MAX_RESTARTS) {
helperRestarts += 1;
const delay = 5_000 * helperRestarts;
@ -211,18 +283,66 @@ function tick(onDetected: DetectorOptions["onDetected"]): void {
}
if (now - micInUseSince < MIC_DEBOUNCE_MS) return;
// Claim the session before the async work — a slow ps/calendar scan must
// Claim the session before the async work — a slow calendar scan must
// not let a second tick double-fire.
sessionNotified = true;
void detect(onDetected);
}
/**
* While Rowboat is recording, watch whether any known meeting app/browser
* still owns the mic. Once one has been seen, its absence for
* CALL_END_GRACE_MS means the call ended fire once per session. Fully
* synchronous: reads the helper-provided owner list, spawns nothing.
*/
function checkExternalCallEnd(onExternalCallEnded?: () => void): void {
if (!onExternalCallEnded) return;
if (!selfCaptureActive || callEndFired) return;
// No attribution data (pre-14.4 macOS) — feature unavailable.
if (micOwners.length === 0) return;
const now = Date.now();
const externalOnMic = micOwners.some((owner) => {
const match = matchOwner(owner);
return match !== null && match !== "self";
});
if (externalOnMic) {
if (!externalAppSeen) {
console.log(
`[MeetingDetect] call-end watch armed — mic owners: ${describeOwners(micOwners)}`,
);
}
externalAppSeen = true;
externalAbsentSince = null;
return;
}
if (!externalAppSeen) return;
if (externalAbsentSince === null) {
externalAbsentSince = now;
console.log(
`[MeetingDetect] meeting app off the mic — remaining owners: ${describeOwners(micOwners)}`,
);
return;
}
if (now - externalAbsentSince >= CALL_END_GRACE_MS) {
callEndFired = true;
console.log("[MeetingDetect] meeting app released the mic — call likely ended");
onExternalCallEnded();
}
}
function describeOwners(owners: MicOwner[]): string {
return owners
.map((o) => o.bundleId || path.basename(o.path) || `pid ${o.pid}`)
.join(", ");
}
async function detect(onDetected: DetectorOptions["onDetected"]): Promise<void> {
if (!isNotificationCategoryEnabled("meeting_detection")) return;
const source = await findLikelyMeetingApp();
if (!source) {
// Mic in use but nothing call-capable is running (dictation, voice
// Mic in use but nothing call-capable owns it (dictation, voice
// memo, unknown recorder) — stay quiet to avoid noise.
return;
}
@ -246,14 +366,33 @@ async function detect(onDetected: DetectorOptions["onDetected"]): Promise<void>
onDetected(meeting);
}
function defaultNoteTitle(source: { app: string; kind: DetectedMeetingKind }): string {
function defaultNoteTitle(source: PlatformMatch): string {
if (source.kind === "huddle") return `${source.app} huddle`;
if (source.kind === "call") return `${source.app} call`;
return BROWSERS.includes(source.app) ? "Meeting" : `${source.app} meeting`;
return BROWSER_APPS.has(source.app) || BROWSERS.includes(source.app)
? "Meeting"
: `${source.app} meeting`;
}
/** Match a mic owner to a platform (or to Rowboat itself). */
function matchOwner(owner: MicOwner): PlatformMatch | "self" | null {
const bundle = owner.bundleId.toLowerCase();
if (bundle) {
for (const prefix of SELF_BUNDLE_PREFIXES) {
if (bundle.startsWith(prefix)) return "self";
}
for (const matcher of BUNDLE_MATCHERS) {
if (bundle.startsWith(matcher.prefix)) {
return { app: matcher.app, kind: matcher.kind };
}
}
}
const basename = path.basename(owner.path);
return basename ? matchProcessName(basename) : null;
}
/** Match one process name to a platform, by case-insensitive prefix. */
function matchProcessName(name: string): { app: string; kind: DetectedMeetingKind } | null {
function matchProcessName(name: string): PlatformMatch | null {
const lower = name.toLowerCase();
// Safari captures through WebKit's out-of-process media stack.
if (lower.startsWith("com.apple.webkit")) return { app: "Safari", kind: "meeting" };
@ -271,24 +410,20 @@ function matchProcessName(name: string): { app: string; kind: DetectedMeetingKin
}
/**
* Attribute the call to an app. Exact when mic-owner PIDs are known: resolve
* their process names and match those (a Meet call in Chrome attributes to
* Chrome even while Slack/Zoom idle in the background). If owners are known
* but none is call-capable (voice memo, dictation, screen recorder), that's
* NOT a meeting stay quiet. Only without PID info (pre-14.4 macOS) fall
* back to the running-app heuristic.
* Attribute the call to an app. Exact when mic owners are known (bundle IDs
* from the helper, no child processes involved). Owners known but none
* call-capable (voice memo, dictation, screen recorder) NOT a meeting,
* stay quiet. Only without attribution (pre-14.4 macOS) fall back to the
* running-app heuristic.
*/
async function findLikelyMeetingApp(): Promise<{ app: string; kind: DetectedMeetingKind } | null> {
if (micPids.length > 0) {
const owners = await processNamesForPids(micPids);
if (owners.length > 0) {
// Prefer dedicated apps over browsers when several own the mic.
const matches = owners
.map(matchProcessName)
.filter((m): m is NonNullable<typeof m> => m !== null);
const dedicated = matches.find((m) => !BROWSERS.includes(m.app));
return dedicated ?? matches[0] ?? null;
}
async function findLikelyMeetingApp(): Promise<PlatformMatch | null> {
if (micOwners.length > 0) {
const matches = micOwners
.map(matchOwner)
.filter((m): m is PlatformMatch => m !== null && m !== "self");
// Prefer dedicated apps over browsers when several own the mic.
const dedicated = matches.find((m) => !BROWSER_APPS.has(m.app));
return dedicated ?? matches[0] ?? null;
}
// No attribution available — running-app heuristic in table order
@ -311,30 +446,6 @@ async function findLikelyMeetingApp(): Promise<{ app: string; kind: DetectedMeet
return null;
}
function processNamesForPids(pids: number[]): Promise<string[]> {
return new Promise((resolve) => {
execFile(
"ps",
["-o", "comm=", "-p", pids.join(",")],
{ maxBuffer: 1024 * 1024 },
(err, stdout) => {
if (err) {
resolve([]);
return;
}
const names: string[] = [];
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (!trimmed) continue;
// GUI apps list as full executable paths — use the basename.
names.push(path.basename(trimmed));
}
resolve(names);
},
);
});
}
function runningProcessNames(): Promise<Set<string> | null> {
return new Promise((resolve) => {
execFile("ps", ["-axo", "comm="], { maxBuffer: 4 * 1024 * 1024 }, (err, stdout) => {

View file

@ -786,6 +786,23 @@ const ipcSchemas = {
success: z.literal(true),
}),
},
// Renderer → main: meeting notes finished generating — fire the "notes
// ready" notification (background only; click opens the note).
'meeting:notifyNotesReady': {
req: z.object({
notePath: z.string(),
title: z.string(),
}),
res: z.object({
success: z.literal(true),
}),
},
// Main → renderer: the meeting app released the microphone while a
// recording was running — the call likely ended, auto-stop and summarize.
'meeting:externalCallEnded': {
req: z.null(),
res: z.null(),
},
// Renderer → main: assistant voice/video call holds the mic — suppresses
// ambient meeting detection (it would otherwise see our own capture).
'voice:setCallActive': {

View file

@ -8,6 +8,7 @@ import { z } from 'zod';
* - agent_permission: an agent is requesting permission to run a tool
* - background_task: a background task agent pinged via the notify-user tool
* - meeting_detection: popup when Rowboat detects you're in a call/meeting
* - meeting_notes_ready: meeting notes finished generating after a call
*/
export const NotificationCategorySchema = z.enum([
'chat_completion',
@ -15,6 +16,7 @@ export const NotificationCategorySchema = z.enum([
'agent_permission',
'background_task',
'meeting_detection',
'meeting_notes_ready',
]);
export const NotificationCategoriesSchema = z.object({
@ -23,6 +25,7 @@ export const NotificationCategoriesSchema = z.object({
agent_permission: z.boolean(),
background_task: z.boolean(),
meeting_detection: z.boolean(),
meeting_notes_ready: z.boolean(),
});
export const NotificationSettingsSchema = z.object({
@ -36,6 +39,7 @@ export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
agent_permission: true,
background_task: true,
meeting_detection: true,
meeting_notes_ready: true,
},
};