feat: native desktop notifications + rowboat:// deep links

Adds INotificationService with an Electron implementation, plus a deep-link
dispatcher (rowboat://) for routing notification clicks back into the app.

Notifications:
- New `notify-user` skill + builtin tool. Title, message, optional primary
    link, optional secondary actions. Supports https:// (opens in browser) and
    rowboat:// (opens in app) targets.
- ElectronNotificationService holds strong refs to active Notification
    instances so click handlers survive GC (otherwise macOS click silently
    no-ops).
- Calendar meeting notifier fires 1-min warnings with "take notes" /
    "join + take notes" actions backed by deep links.

Deep links (rowboat://):
- forge.config.cjs declares the protocol; main.ts wires single-instance
    lock, setAsDefaultProtocolClient, open-url (mac), second-instance (win/
    linux), and first-launch argv extraction.
- New deeplink.ts dispatcher with dispatchUrl(url): main-handled actions
    (rowboat://action?type=...) vs renderer navigation (rowboat://open?...)
    via app:openUrl IPC. Includes pending-URL buffering for first-launch
    delivery before the renderer is ready.
- Renderer parseDeepLink supports file / chat / graph / task /
    suggested-topics targets.
- New app:consumePendingDeepLink IPC for renderer one-time drain on mount.

Refactor: extractConferenceLink moved out of calendar-block.tsx into
shared lib/calendar-event.ts (used by both the block and the take-notes
deep-link handler)
This commit is contained in:
arkml 2026-05-04 15:47:30 +05:30 committed by GitHub
parent 9ed54e2b94
commit 1c2b2ac1fc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 712 additions and 20 deletions

View file

@ -85,6 +85,8 @@ ${thirdPartyBlock}**Meeting Prep:** When users ask you to prepare for a meeting,
**Tracks (Auto-Updating Note Blocks):** When users ask you to **track**, **monitor**, **watch**, or **keep an eye on** something in a note or say things like "every morning tell me X", "show the current Y in this note", "pin live updates of Z here" load the \`tracks\` skill first. Also load it when a user presses Cmd+K with a note open and requests auto-refreshing content at the cursor. Track blocks are YAML-fenced scheduled blocks whose output is rewritten on each run — useful for weather, news, prices, status pages, and personal dashboards.
**Browser Control:** When users ask you to open a website, browse in-app, search the web in the embedded browser, or interact with a live webpage inside Rowboat, load the \`browser-control\` skill first. It explains the \`read-page -> indexed action -> refreshed page\` workflow for the browser pane.
**Notifications:** When you need to send a desktop notification completion alert after a long task, time-sensitive update, or a clickable result that lands the user on a specific note/view load the \`notify-user\` skill first. It documents the \`notify-user\` tool and the \`rowboat://\` deep links you can attach to it.
## Learning About the User (save-to-memory)

View file

@ -14,6 +14,7 @@ import appNavigationSkill from "./app-navigation/skill.js";
import browserControlSkill from "./browser-control/skill.js";
import composioIntegrationSkill from "./composio-integration/skill.js";
import tracksSkill from "./tracks/skill.js";
import notifyUserSkill from "./notify-user/skill.js";
const CURRENT_DIR = path.dirname(fileURLToPath(import.meta.url));
const CATALOG_PREFIX = "src/application/assistant/skills";
@ -112,6 +113,12 @@ const definitions: SkillDefinition[] = [
summary: "Control the embedded browser pane - open sites, inspect page state, and interact with indexed page elements.",
content: browserControlSkill,
},
{
id: "notify-user",
title: "Notify User",
summary: "Send native desktop notifications with optional clickable links — including rowboat:// deep links that open a specific note, chat, or view inside the app.",
content: notifyUserSkill,
},
];
const skillEntries = definitions.map((definition) => ({

View file

@ -0,0 +1,70 @@
export const skill = String.raw`
# Notify User
Load this skill when you need to send a desktop notification to the user e.g. after a long-running task completes, when a track block detects something noteworthy, or when an agent wants to ping the user with a clickable result.
## When to use
- **Use it for**: completion alerts, threshold breaches, status changes, new items the user asked you to watch for, anything time-sensitive.
- **Don't use it for**: routine progress updates, anything the user can already see in the chat, or repeated pings inside a loop (there is no built-in rate limit restraint is on you).
## The tool: \`notify-user\`
Triggers a native macOS notification. The call returns immediately; it does not block waiting for the user to click.
### Parameters
- **\`title\`** (optional, defaults to \`"Rowboat"\`) — bold headline at the top.
- **\`message\`** (required) — body text. Keep it short — macOS truncates after a couple of lines.
- **\`link\`** (optional) — URL to open when the user clicks the notification. Two kinds accepted:
- **\`https://...\` / \`http://...\`** — opens in the default browser
- **\`rowboat://...\`** — opens a view inside Rowboat (see deep links below)
- If omitted, clicking the notification focuses the Rowboat app.
### Examples
Plain alert (no link clicking focuses the app):
\`\`\`json
{
"title": "Backup complete",
"message": "All 142 files synced to iCloud."
}
\`\`\`
External link:
\`\`\`json
{
"title": "New email from Monica",
"message": "Re: Q4 planning — needs your input by Friday",
"link": "https://mail.google.com/mail/u/0/#inbox/abc123"
}
\`\`\`
Deep link into a Rowboat note:
\`\`\`json
{
"message": "Daily brief is ready",
"link": "rowboat://open?type=file&path=knowledge/Daily/2026-04-25.md"
}
\`\`\`
## Deep links: \`rowboat://\`
Use these as the \`link\` parameter to land the user on a specific view in Rowboat instead of an external site. URL-encode paths/names that contain spaces or special characters.
| Target | Format | Example |
|---|---|---|
| Open a file | \`rowboat://open?type=file&path=<workspace-relative path>\` | \`rowboat://open?type=file&path=knowledge/People/Acme.md\` |
| Open chat | \`rowboat://open?type=chat\` (optional \`&runId=<id>\`) | \`rowboat://open?type=chat&runId=abc123\` |
| Knowledge graph | \`rowboat://open?type=graph\` | — |
| Background task view | \`rowboat://open?type=task&name=<task-name>\` | \`rowboat://open?type=task&name=daily-brief\` |
| Suggested topics | \`rowboat://open?type=suggested-topics\` | — |
The \`type=file\` path is workspace-relative (the same path you'd pass to \`workspace-readFile\`).
## Anti-patterns
- **Don't notify per step** of a multi-step task. Notify on completion, not on progress.
- **Don't repeat what's already on screen.** If the result is already in the chat or in a track block the user is viewing, skip the notification.
- **Don't dump the result into \`message\`.** Surface the headline; put the detail behind a deep link or external link.
- **Don't notify silently-failing things either.** If something failed, say so in the message — don't swallow the failure into a generic "done".
`;
export default skill;

View file

@ -29,6 +29,7 @@ import { getAccessToken } from "../../auth/tokens.js";
import { API_URL } from "../../config/env.js";
import { updateContent, updateTrackBlock } from "../../knowledge/track/fileops.js";
import type { IBrowserControlService } from "../browser-control/service.js";
import type { INotificationService } from "../notification/service.js";
// Parser libraries are loaded dynamically inside parseFile.execute()
// to avoid pulling pdfjs-dist's DOM polyfills into the main bundle.
// Import paths are computed so esbuild cannot statically resolve them.
@ -1526,4 +1527,44 @@ export const BuiltinTools: z.infer<typeof BuiltinToolsSchema> = {
}
},
},
'notify-user': {
description: "Show a native OS notification to the user. Clicking the notification opens the provided link in the default browser, or focuses the Rowboat app if no link is given.",
inputSchema: z.object({
title: z.string().min(1).max(120).optional().describe("Bold headline shown at the top of the notification. Defaults to 'Rowboat'."),
message: z.string().min(1).describe("Body text of the notification."),
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
message: "link must be an http(s):// or rowboat:// URL",
}).optional().describe("Optional URL opened when the user clicks the notification. Accepts http(s):// (opens in browser) or rowboat:// (opens a view inside Rowboat — see the notify-user skill for deep-link shapes)."),
actionLabel: z.string().min(1).max(20).optional().describe("Optional label for an inline action button on the notification (e.g. 'Open', 'View', 'Take Notes'). Only shown when `link` is set. Click on the button triggers the same action as clicking the notification body."),
secondaryActions: z.array(z.object({
label: z.string().min(1).max(30),
link: z.string().url().refine((v) => /^(https?|rowboat):\/\//i.test(v), {
message: "secondary action link must be an http(s):// or rowboat:// URL",
}),
})).max(4).optional().describe("Additional action buttons. macOS shows them in the chevron menu next to the primary button (or all inline in Alert style). Each has its own label and link — clicking the button triggers that link, independent of the primary `link`."),
}),
isAvailable: async () => {
try {
return container.resolve<INotificationService>('notificationService').isSupported();
} catch {
return false;
}
},
execute: async ({ title, message, link, actionLabel, secondaryActions }: { title?: string; message: string; link?: string; actionLabel?: string; secondaryActions?: Array<{ label: string; link: string }> }) => {
try {
const service = container.resolve<INotificationService>('notificationService');
if (!service.isSupported()) {
return { success: false, error: 'Notifications are not supported on this system' };
}
service.notify({ title, message, link, actionLabel, secondaryActions });
return { success: true };
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
};
}
},
},
};

View file

@ -0,0 +1,12 @@
export interface NotifyInput {
title?: string;
message: string;
link?: string;
actionLabel?: string;
secondaryActions?: Array<{ label: string; link: string }>;
}
export interface INotificationService {
isSupported(): boolean;
notify(input: NotifyInput): void;
}

View file

@ -16,6 +16,7 @@ import { FSAgentScheduleRepo, IAgentScheduleRepo } from "../agent-schedule/repo.
import { FSAgentScheduleStateRepo, IAgentScheduleStateRepo } from "../agent-schedule/state-repo.js";
import { FSSlackConfigRepo, ISlackConfigRepo } from "../slack/repo.js";
import type { IBrowserControlService } from "../application/browser-control/service.js";
import type { INotificationService } from "../application/notification/service.js";
const container = createContainer({
injectionMode: InjectionMode.PROXY,
@ -49,3 +50,9 @@ export function registerBrowserControlService(service: IBrowserControlService):
browserControlService: asValue(service),
});
}
export function registerNotificationService(service: INotificationService): void {
container.register({
notificationService: asValue(service),
});
}

View file

@ -0,0 +1,180 @@
import path from "node:path";
import fs from "node:fs/promises";
import type { Dirent } from "node:fs";
import { WorkDir } from "../config/config.js";
import container from "../di/container.js";
import type { INotificationService } from "../application/notification/service.js";
const TICK_INTERVAL_MS = 30_000;
// Notify when an event is between 30s in the past (started just now) and
// 90s in the future (about to start). The window is wider than 60s so we
// don't miss an event if the tick lands slightly off the start time.
const NOTIFY_LEAD_MS = 90_000;
const NOTIFY_GRACE_MS = 30_000;
// Drop state entries older than 24h so the file doesn't grow forever.
const STATE_TTL_MS = 24 * 60 * 60 * 1000;
const CALENDAR_SYNC_DIR = path.join(WorkDir, "calendar_sync");
const STATE_FILE = path.join(WorkDir, "calendar_notifications_state.json");
interface NotificationState {
notifiedEventIds: Record<string, { notifiedAt: string; startTime: string }>;
}
interface CalendarEvent {
id?: string;
summary?: string;
status?: string;
start?: { dateTime?: string; date?: string; timeZone?: string };
end?: { dateTime?: string; date?: string };
attendees?: Array<{ email?: string; self?: boolean; responseStatus?: string }>;
hangoutLink?: string;
conferenceData?: {
entryPoints?: Array<{ entryPointType?: string; uri?: string }>;
};
}
async function loadState(): Promise<NotificationState> {
try {
const raw = await fs.readFile(STATE_FILE, "utf-8");
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object" && parsed.notifiedEventIds) {
return parsed as NotificationState;
}
} catch {
// No state file yet, or corrupt — start fresh.
}
return { notifiedEventIds: {} };
}
async function saveState(state: NotificationState): Promise<void> {
// Write to a sibling tmp file then rename so a mid-write crash can't leave
// the JSON corrupt — a corrupt state file would make every event in the
// 90s notify window re-fire on next start.
const tmp = `${STATE_FILE}.tmp`;
await fs.writeFile(tmp, JSON.stringify(state, null, 2), "utf-8");
await fs.rename(tmp, STATE_FILE);
}
function gcState(state: NotificationState): NotificationState {
const cutoff = Date.now() - STATE_TTL_MS;
const fresh: NotificationState["notifiedEventIds"] = {};
for (const [id, entry] of Object.entries(state.notifiedEventIds)) {
const ts = Date.parse(entry.notifiedAt);
if (Number.isFinite(ts) && ts >= cutoff) fresh[id] = entry;
}
return { notifiedEventIds: fresh };
}
function isAllDay(event: CalendarEvent): boolean {
// Google Calendar all-day events have `date` (YYYY-MM-DD) on start, not `dateTime`.
return !event.start?.dateTime;
}
function isDeclinedBySelf(event: CalendarEvent): boolean {
if (!event.attendees) return false;
const self = event.attendees.find((a) => a.self);
return self?.responseStatus === "declined";
}
async function tick(state: NotificationState): Promise<{ state: NotificationState; dirty: boolean }> {
let entries: Dirent[];
try {
entries = await fs.readdir(CALENDAR_SYNC_DIR, { withFileTypes: true });
} catch {
return { state, dirty: false };
}
let service: INotificationService;
try {
service = container.resolve<INotificationService>("notificationService");
} catch {
// Notification service not registered yet (very early startup) — skip this tick.
return { state, dirty: false };
}
if (!service.isSupported()) return { state, dirty: false };
const now = Date.now();
let dirty = false;
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
if (entry.name === "sync_state.json" || entry.name.startsWith("sync_state")) continue;
const eventId = entry.name.replace(/\.json$/, "");
if (state.notifiedEventIds[eventId]) continue;
const filePath = path.join(CALENDAR_SYNC_DIR, entry.name);
let event: CalendarEvent;
try {
event = JSON.parse(await fs.readFile(filePath, "utf-8"));
} catch {
continue;
}
if (event.status === "cancelled") continue;
if (isAllDay(event)) continue;
if (isDeclinedBySelf(event)) continue;
const startStr = event.start?.dateTime;
if (!startStr) continue;
const startMs = Date.parse(startStr);
if (!Number.isFinite(startMs)) continue;
const msUntilStart = startMs - now;
if (msUntilStart > NOTIFY_LEAD_MS) continue;
if (msUntilStart < -NOTIFY_GRACE_MS) continue;
const summary = event.summary?.trim() || "Untitled meeting";
const eid = encodeURIComponent(eventId);
try {
service.notify({
title: "Upcoming meeting",
message: `${summary} starts in 1 minute. Click to join and take notes.`,
// Single labeled button — adding a secondary action would force
// macOS to bundle them into an "Options" dropdown, hiding the
// primary label.
link: `rowboat://action?type=join-and-take-meeting-notes&eventId=${eid}`,
actionLabel: "Join & Notes",
});
console.log(`[CalendarNotify] notified for "${summary}" (${eventId})`);
} catch (err) {
console.error(`[CalendarNotify] notify failed for ${eventId}:`, err);
continue;
}
state.notifiedEventIds[eventId] = {
notifiedAt: new Date().toISOString(),
startTime: startStr,
};
dirty = true;
}
return { state, dirty };
}
export async function init(): Promise<void> {
console.log("[CalendarNotify] starting calendar notification service");
console.log(`[CalendarNotify] tick every ${TICK_INTERVAL_MS / 1000}s`);
let state = gcState(await loadState());
while (true) {
try {
const result = await tick(state);
state = result.state;
if (result.dirty) {
state = gcState(state);
try {
await saveState(state);
} catch (err) {
console.error("[CalendarNotify] failed to save state:", err);
}
}
} catch (err) {
console.error("[CalendarNotify] tick failed:", err);
}
await new Promise((resolve) => setTimeout(resolve, TICK_INTERVAL_MS));
}
}

View file

@ -263,6 +263,7 @@ You have the full workspace toolkit. Quick reference for common cases:
- **\`parseFile\`, \`LLMParse\`** — parse PDFs, spreadsheets, Word docs if a track aggregates from attached files.
- **\`composio-*\`, \`listMcpTools\`, \`executeMcpTool\`** — user-connected integrations (Gmail, Calendar, etc.). Prefer these when a track needs structured data from a connected service the user has authorized.
- **\`browser-control\`** — only when a required source has no API / search alternative and requires JS rendering.
- **\`notify-user\`** — send a native desktop notification when this run produces something time-sensitive (threshold breach, urgent change, "the thing the user asked you to watch for just happened"). Skip it for routine refreshes — the note itself is the artifact. Load the \`notify-user\` skill via \`loadSkill\` for parameters and \`rowboat://\` deep-link shapes (so the click lands on the right note/view).
# The Knowledge Graph