Merge branch 'dev' into slack3

This commit is contained in:
Gagancreates 2026-06-11 23:34:02 +05:30
commit 3e2ffa9eb0
12 changed files with 330 additions and 5 deletions

View file

@ -17,6 +17,7 @@ import { isBlocked, extractCommandNames } from "../application/lib/command-execu
import { getFileAccessAllowList, type FileAccessGrant, type FileAccessOperation } from "../config/security.js";
import { resolveFilePathForPermission } from "../filesystem/files.js";
import container from "../di/container.js";
import { notifyIfEnabled } from "../application/notification/notifier.js";
import { IModelConfigRepo } from "../models/repo.js";
import { createProvider } from "../models/models.js";
import { resolveProviderConfig } from "../models/defaults.js";
@ -377,6 +378,7 @@ export class AgentRuntime implements IAgentRuntime {
type: "run-processing-start",
subflow: [],
});
let totalEvents = 0;
while (true) {
// Check for abort before each iteration
if (signal.aborted) {
@ -417,6 +419,7 @@ export class AgentRuntime implements IAgentRuntime {
throw error;
}
totalEvents += eventCount;
// if no events, break
if (!eventCount) {
break;
@ -433,6 +436,27 @@ export class AgentRuntime implements IAgentRuntime {
};
await this.runsRepo.appendEvents(runId, [stoppedEvent]);
await this.bus.publish(stoppedEvent);
} else if (totalEvents > 0) {
// The run reached a natural stopping point and actually did
// something this cycle. Notify "chat completion" — unless it
// paused on a permission request, which surfaces its own
// notification (distinguish by inspecting the final state).
const finalRun = await this.runsRepo.fetch(runId);
if (finalRun) {
const finalState = new AgentState();
for (const event of finalRun.log) {
finalState.ingest(event);
}
if (finalState.getPendingPermissions().length === 0) {
void notifyIfEnabled("chat_completion", {
title: "Response ready",
message: "Your agent finished responding.",
link: `rowboat://open?type=chat&runId=${runId}`,
actionLabel: "Open",
onlyWhenBackground: true,
});
}
}
}
} catch (error) {
console.error(`Run ${runId} failed:`, error);
@ -1545,6 +1569,16 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
}
if (permissionCandidates.length > 0) {
// Permission prompts block the run, so they surface even when the
// app is focused (no onlyWhenBackground gate).
const notifyPermissionPrompt = (toolCall: typeof permissionCandidates[number]["toolCall"]) => {
void notifyIfEnabled("agent_permission", {
title: "Permission needed",
message: `${agent.name} wants to run "${toolCall.toolName}". Review to continue.`,
link: `rowboat://open?type=chat&runId=${runId}`,
actionLabel: "Review",
});
};
if (state.permissionMode === "auto") {
let decisionsByToolCallId = new Map<string, { decision: "allow" | "deny"; reason: string }>();
try {
@ -1578,6 +1612,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
continue;
}
@ -1609,6 +1644,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
}
}
} else {
@ -1621,6 +1657,7 @@ If the user's message is clearly NOT a coding request (small talk, an unrelated
permission: candidate.permission,
subflow: [],
});
notifyPermissionPrompt(candidate.toolCall);
}
}
}

View file

@ -0,0 +1,29 @@
import type { NotificationCategory } from '@x/shared/dist/notification-settings.js';
import { isNotificationCategoryEnabled } from '../../config/notification_config.js';
import type { INotificationService, NotifyInput } from './service.js';
/**
* Fire a notification for `category`, but only if the user has that category
* enabled and the platform supports notifications.
*
* Resolution of the notification service is done via a *dynamic* import of the
* DI container so that callers like the agent runtime which the container
* itself imports don't create a circular module dependency. The whole thing
* is wrapped so a missing service (very early startup), an unsupported
* platform, or a config read error can never disrupt the run/sync that
* triggered it. Callers should fire-and-forget (`void notifyIfEnabled(...)`).
*/
export async function notifyIfEnabled(
category: NotificationCategory,
input: NotifyInput,
): Promise<void> {
try {
if (!isNotificationCategoryEnabled(category)) return;
const { default: container } = await import('../../di/container.js');
const service = container.resolve<INotificationService>('notificationService');
if (!service.isSupported()) return;
service.notify(input);
} catch (err) {
console.error(`[notifier] failed to notify (category=${category}):`, err);
}
}

View file

@ -4,6 +4,14 @@ export interface NotifyInput {
link?: string;
actionLabel?: string;
secondaryActions?: Array<{ label: string; link: string }>;
/**
* When true, the notification is suppressed if the app is currently in the
* foreground (any window focused). Use for ambient notifications the user
* doesn't need while actively looking at the app (e.g. chat completion, new
* email). Leave unset/false for notifications that must always surface
* regardless of focus (e.g. an agent permission request that blocks a run).
*/
onlyWhenBackground?: boolean;
}
export interface INotificationService {

View file

@ -0,0 +1,52 @@
import fs from 'fs';
import path from 'path';
import {
NotificationSettingsSchema,
DEFAULT_NOTIFICATION_SETTINGS,
type NotificationSettings,
type NotificationCategory,
} from '@x/shared/dist/notification-settings.js';
import { WorkDir } from './config.js';
const NOTIFICATION_CONFIG_PATH = path.join(WorkDir, 'config', 'notification_settings.json');
/**
* Load notification settings, merging any persisted values over the defaults.
*
* Merging (rather than a strict parse) keeps the file forward/backward
* compatible: a category added in a newer build is filled in from defaults
* when an older file omits it, and a malformed file falls back to defaults
* instead of disabling notifications entirely.
*/
export function loadNotificationSettings(): NotificationSettings {
try {
if (fs.existsSync(NOTIFICATION_CONFIG_PATH)) {
const content = fs.readFileSync(NOTIFICATION_CONFIG_PATH, 'utf-8');
const parsed = JSON.parse(content);
const categories = parsed?.categories ?? {};
return NotificationSettingsSchema.parse({
categories: {
...DEFAULT_NOTIFICATION_SETTINGS.categories,
...categories,
},
});
}
} catch (error) {
console.error('[NotificationConfig] Error loading notification settings:', error);
}
return DEFAULT_NOTIFICATION_SETTINGS;
}
export function saveNotificationSettings(settings: NotificationSettings): void {
const dir = path.dirname(NOTIFICATION_CONFIG_PATH);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const validated = NotificationSettingsSchema.parse(settings);
fs.writeFileSync(NOTIFICATION_CONFIG_PATH, JSON.stringify(validated, null, 2));
}
/** Convenience: is a single notification category currently enabled? */
export function isNotificationCategoryEnabled(category: NotificationCategory): boolean {
return loadNotificationSettings().categories[category];
}

View file

@ -9,6 +9,7 @@ import { serviceLogger, type ServiceRunContext } from '../services/service_logge
import { limitEventItems } from './limit_event_items.js';
import { createEvent } from '../events/producer.js';
import { classifyThread, getUserEmail } from './classify_thread.js';
import { notifyIfEnabled } from '../application/notification/notifier.js';
// Configuration
const SYNC_DIR = path.join(WorkDir, 'gmail_sync');
@ -220,6 +221,26 @@ function summarizeGmailSync(threads: SyncedThread[]): string {
return lines.join('\n');
}
/**
* Fire one OS notification per genuinely-new email thread. Only ever called
* from the partial-sync (incremental) path, so the first-time connect which
* goes through fullSync never notifies. Suppressed while the app is focused.
*/
function notifyNewEmails(threads: SyncedThread[]): void {
for (const { threadId } of threads) {
const snapshot = readCachedSnapshot(threadId)?.snapshot;
const subject = snapshot?.subject?.trim() || '(no subject)';
const from = snapshot?.from?.trim();
void notifyIfEnabled('new_email', {
title: from ? `New email from ${from}` : 'New email',
message: subject,
link: 'rowboat://open?type=chat',
actionLabel: 'Open',
onlyWhenBackground: true,
});
}
}
async function publishGmailSyncEvent(threads: SyncedThread[]): Promise<void> {
if (threads.length === 0) return;
try {
@ -1260,6 +1281,9 @@ async function partialSync(auth: OAuth2Client, startHistoryId: string, syncDir:
const result = await processThread(auth, tid, syncDir, attachmentsDir);
if (result) synced.push(result);
}
// Notify for the history-derived new threads only — before the older
// backfilled threads are merged in below, so backfill stays silent.
notifyNewEmails(synced);
const backfilled = await backfillMissingRecentThreads(auth, syncDir, attachmentsDir, stateFile, lookbackDays);
synced.push(...backfilled);

View file

@ -17,4 +17,5 @@ export * as frontmatter from './frontmatter.js';
export * as bases from './bases.js';
export * as browserControl from './browser-control.js';
export * as billing from './billing.js';
export * as notificationSettings from './notification-settings.js';
export { PrefixLogger };

View file

@ -20,6 +20,7 @@ import { BrowserStateSchema } from './browser-control.js';
import { BillingInfoSchema } from './billing.js';
import { EmailBlockSchema, GmailThreadSchema } from './blocks.js';
import { PermissionDecision, ApprovalPolicy } from './code-mode.js';
import { NotificationSettingsSchema } from './notification-settings.js';
// ============================================================================
// Runtime Validation Schemas (Single Source of Truth)
@ -1093,6 +1094,17 @@ const ipcSchemas = {
req: z.null(),
res: BillingInfoSchema,
},
// Notification settings channels
'notifications:getSettings': {
req: z.null(),
res: NotificationSettingsSchema,
},
'notifications:setSettings': {
req: NotificationSettingsSchema,
res: z.object({
success: z.literal(true),
}),
},
} as const;
// ============================================================================

View file

@ -0,0 +1,36 @@
import { z } from 'zod';
/**
* Notification categories the user can independently toggle.
*
* - chat_completion: an agent finished generating a response
* - new_email: a new email arrived during incremental Gmail sync
* - agent_permission: an agent is requesting permission to run a tool
*/
export const NotificationCategorySchema = z.enum([
'chat_completion',
'new_email',
'agent_permission',
]);
export const NotificationCategoriesSchema = z.object({
chat_completion: z.boolean(),
new_email: z.boolean(),
agent_permission: z.boolean(),
});
export const NotificationSettingsSchema = z.object({
categories: NotificationCategoriesSchema,
});
export const DEFAULT_NOTIFICATION_SETTINGS: NotificationSettings = {
categories: {
chat_completion: true,
new_email: true,
agent_permission: true,
},
};
export type NotificationCategory = z.infer<typeof NotificationCategorySchema>;
export type NotificationCategories = z.infer<typeof NotificationCategoriesSchema>;
export type NotificationSettings = z.infer<typeof NotificationSettingsSchema>;