Island indicator + proactive nudges — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use
superpowers:executing-plansto implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.
Goal: Ship two prototype desktop surfaces on Polaris — a faux Dynamic-Island pill that lights up on voice turns, and a proactive context-switch monitor that surfaces silent nudges via a cheap gateway proactive-glance action.
Architecture: Part 1 is pure client (a new NSPanel + SwiftUI view observing the existing CompanionManager published state — no dispatch changes). The backend adds a sibling gateway entry point POST /proactive-glance backed by a one-shot Haiku graph in agent-runtime (clone of desktop-ack). Part 2 is a client ProactiveContextMonitor (app-switch + settle timer + interval-capped governor) that screenshots and calls the new action, surfacing surface:true results into the island silently.
Tech Stack: Swift/SwiftUI/AppKit (NSPanel, NSHostingView, Combine, NSWorkspace), TypeScript (Hono gateway, LangGraph 1.3, AI SDK), Bun/Vitest.
Split: Backend (Tasks 1–4) is built and tested in this session. Swift (Tasks 5–9) is written here, compiled by Christophe in Xcode (xcodebuild barred — TCC). Plan is sequenced backend-first so the client has a live endpoint to call.
File structure
Backend (this session):
- Create
packages/agent-runtime/src/tools/finalize-glance.ts— terminal structured tool +harvestFinalizeGlance. - Create
packages/agent-runtime/src/agents/proactive-glance/{prompt,graph,index}.ts— one-shot Haiku glance graph. - Modify
packages/agent-runtime/src/runtime.ts— register the new agent (side-effect import). - Create
packages/chat-runtime/src/glance.ts—runProactiveGlance(...); exported from the package index. - Modify
apps/clicky-gateway/src/routes/router.ts—POST /proactive-glance(bearer-gated, sibling to/dispatch). - Tests:
proactive-glance/graph.test.ts,glance.test.ts,proactive-glance.test.ts.
Client (written here, compiled by Christophe):
- Create
IslandState.swift— the state enum. - Create
IslandPanel.swift— borderless top-center panel + manager. - Create
IslandView.swift— the SwiftUI pill. - Create
ProactiveContextMonitor.swift— app-switch monitor + governor. - Modify
NorthDispatchAPI.swift— addproactiveGlance(...). - Modify
CompanionManager.swift— own the island manager + monitor, publish ack text, wire state.
Task 1: finalize_glance structured tool
Files: Create packages/agent-runtime/src/tools/finalize-glance.ts; Test packages/agent-runtime/src/tools/finalize-glance.test.ts
Step 1: Write the failing test
// packages/agent-runtime/src/tools/finalize-glance.test.ts
import { AIMessage, HumanMessage } from "@langchain/core/messages"
import { describe, expect, it } from "vitest"
import { harvestFinalizeGlance, FINALIZE_GLANCE_TOOL_NAME } from "./finalize-glance"
describe("harvestFinalizeGlance", () => {
it("returns the structured glance from the last AIMessage tool call", () => {
const msg = new AIMessage({
content: "",
tool_calls: [
{ type: "tool_call", name: FINALIZE_GLANCE_TOOL_NAME, id: "g1",
args: { surface: true, message: "Lease renewal due Friday.", openInBrowser: true } },
],
})
expect(harvestFinalizeGlance([new HumanMessage("ctx"), msg])).toEqual({
surface: true, message: "Lease renewal due Friday.", openInBrowser: true,
})
})
it("returns a surface:false result when nothing is worth surfacing", () => {
const msg = new AIMessage({ content: "", tool_calls: [
{ type: "tool_call", name: FINALIZE_GLANCE_TOOL_NAME, id: "g2", args: { surface: false } } ] })
expect(harvestFinalizeGlance([msg])).toEqual({ surface: false })
})
it("returns null when no finalize_glance call is present", () => {
expect(harvestFinalizeGlance([new HumanMessage("x")])).toBeNull()
})
})
Step 2: Run test to verify it fails — cd packages/agent-runtime && bun run vitest run src/tools/finalize-glance.test.ts → FAIL (Cannot find module './finalize-glance').
Step 3: Write the tool
// packages/agent-runtime/src/tools/finalize-glance.ts
import { AIMessage, type BaseMessage } from "@langchain/core/messages"
import { tool } from "@langchain/core/tools"
import { z } from "zod"
export const FINALIZE_GLANCE_TOOL_NAME = "finalize_glance"
export const finalizeGlanceArgsSchema = z.object({
surface: z.boolean(),
message: z.string().min(1).optional(),
openInBrowser: z.boolean().optional(),
})
export type FinalizeGlanceArgs = z.infer<typeof finalizeGlanceArgsSchema>
export const finalizeGlanceTool = tool(
async (args): Promise<string> =>
args.surface ? `Surfacing: ${args.message ?? ""}` : "Nothing to surface.",
{
name: FINALIZE_GLANCE_TOOL_NAME,
description:
"Call EXACTLY ONCE as your only action. `surface`: true only if the " +
"screen shows something the lawyer would genuinely thank you for " +
"noticing (a deadline, a conflict, an email that needs a reply, a risk). " +
"`message`: one short English sentence (required when surface=true). " +
"`openInBrowser`: true when tapping the nudge should open a thread with more detail.",
schema: finalizeGlanceArgsSchema,
},
)
export function harvestFinalizeGlance(messages: BaseMessage[]): FinalizeGlanceArgs | null {
for (let i = messages.length - 1; i >= 0; i--) {
const message = messages[i]
if (!AIMessage.isInstance(message)) continue
const call = message.tool_calls?.find((c) => c.name === FINALIZE_GLANCE_TOOL_NAME)
if (!call) continue
const parsed = finalizeGlanceArgsSchema.safeParse(call.args)
return parsed.success ? parsed.data : null
}
return null
}
Step 4: Run test to verify it passes — same command → PASS (3 tests).
Step 5: Commit — git add both files; feat(agent): finalize_glance tool for proactive screen nudges.
Task 2: proactive-glance graph + prompt + registration
Files: Create prompt.ts, graph.ts, index.ts under packages/agent-runtime/src/agents/proactive-glance/; Modify runtime.ts; Test graph.test.ts.
Step 1: Write the prompt
// packages/agent-runtime/src/agents/proactive-glance/prompt.ts
export const PROACTIVE_GLANCE_PROMPT = `You are a senior legal assistant glancing — UNINVITED — at a single screenshot of a lawyer's screen the moment they switched into an app.
You were NOT asked anything. Your job is to decide whether ONE thing on this screen is worth a single, silent, glanceable nudge.
For this prototype, lean toward speaking up: surface anything the lawyer would plausibly thank you for noticing — an approaching deadline, a conflict, an email that clearly needs a reply, a risk, a next action on a matter. When in doubt and there is a plausibly-useful observation, surface it.
If the screen is mundane (settings, a blank doc, personal browsing, nothing actionable), call finalize_glance with surface=false. That is a fine and common answer.
Always answer in English. Always call finalize_glance EXACTLY ONCE as your only action. When surface=true, write ONE short sentence (≤ 120 chars) a lawyer can read in a glance.`
Step 2: Write the failing graph test
// packages/agent-runtime/src/agents/proactive-glance/graph.test.ts
import { AIMessage } from "@langchain/core/messages"
import { describe, expect, it } from "vitest"
import { buildProactiveGlanceGraph } from "./graph"
function fakeModel(args: Record<string, unknown>) {
return { bindTools() { return { async invoke() {
return new AIMessage({ content: "", tool_calls: [
{ type: "tool_call", name: "finalize_glance", id: "g", args } ] }) } } } }
}
describe("proactive-glance graph", () => {
it("runs one turn and leaves a finalize_glance call in the final state", async () => {
const graph = buildProactiveGlanceGraph({
model: fakeModel({ surface: true, message: "Reply to opposing counsel by EOD." }) as never })
const result = await graph.invoke({ messages: [] })
const last = result.messages[result.messages.length - 1] as AIMessage
const call = last.tool_calls?.find((c) => c.name === "finalize_glance")
expect(call?.args).toMatchObject({ surface: true })
})
})
Step 3: Run test to verify it fails — cd packages/agent-runtime && bun run vitest run src/agents/proactive-glance/graph.test.ts → FAIL (Cannot find module './graph').
Step 4: Write the graph (clone of desktop-ack/graph.ts: one llmCall → terminal finalize → END)
// packages/agent-runtime/src/agents/proactive-glance/graph.ts
import type { BaseChatModel } from "@langchain/core/language_models/chat_models"
import { AIMessage, SystemMessage, ToolMessage } from "@langchain/core/messages"
import { END, START, StateGraph, type GraphNode } from "@langchain/langgraph"
import { createModel } from "../../model"
import { AgentState } from "../../state"
import { finalizeGlanceTool, FINALIZE_GLANCE_TOOL_NAME } from "../../tools/finalize-glance"
import { PROACTIVE_GLANCE_PROMPT } from "./prompt"
export interface ProactiveGlanceDeps { model?: BaseChatModel }
export function buildProactiveGlanceGraph(deps: ProactiveGlanceDeps = {}) {
const model = deps.model ?? createModel("claude-haiku-4-5")
const tools = [finalizeGlanceTool]
const llmCall: GraphNode<typeof AgentState> = async (state) => {
const modelWithTools = model.bindTools?.(tools) ?? model
const response = await modelWithTools.invoke([
new SystemMessage(PROACTIVE_GLANCE_PROMPT), ...state.messages ])
return { messages: [response] }
}
const finalize: GraphNode<typeof AgentState> = async (state) => {
const last = state.messages[state.messages.length - 1] as AIMessage
const call = last.tool_calls?.find((c) => c.name === FINALIZE_GLANCE_TOOL_NAME)
if (!call) return {}
const output = await finalizeGlanceTool.invoke(call.args as never)
return { messages: [new ToolMessage({ content: String(output), tool_call_id: call.id! })] }
}
const route = (state: typeof AgentState.State): "finalize" | typeof END => {
const last = state.messages[state.messages.length - 1] as AIMessage
return last.tool_calls?.length ? "finalize" : END
}
return new StateGraph(AgentState)
.addNode("llmCall", llmCall)
.addNode("finalize", finalize)
.addEdge(START, "llmCall")
.addConditionalEdges("llmCall", route, { finalize: "finalize", [END]: END })
.addEdge("finalize", END)
.compile()
}
Step 5: Write index + registration
// packages/agent-runtime/src/agents/proactive-glance/index.ts
import { registerAgent } from "../../registry"
import { buildProactiveGlanceGraph } from "./graph"
export { buildProactiveGlanceGraph, type ProactiveGlanceDeps } from "./graph"
export { PROACTIVE_GLANCE_PROMPT } from "./prompt"
registerAgent({
id: "proactive-glance",
title: "Proactive glance",
description: "One-shot uninvited screen glance — decides whether to surface a silent nudge.",
buildGraph: () => buildProactiveGlanceGraph(),
})
NOTE: confirm
registerAgent's exactAgentDefinitionshape by readingregistry.tsanddesktop-ack/index.tsbefore writing — match its field names. Do not invent fields.
Step 6: Register the side-effect import — in runtime.ts, after import "./agents/router/index", add import "./agents/proactive-glance/index".
Step 7: Run the graph test → PASS.
Step 8: Commit — feat(agent): proactive-glance one-shot graph + registration.
Task 3: runProactiveGlance run helper in chat-runtime
Files: Create packages/chat-runtime/src/glance.ts; Modify packages/chat-runtime/src/index.ts; Test glance.test.ts.
Step 1: Write the failing test
// packages/chat-runtime/src/glance.test.ts
import { AIMessage } from "@langchain/core/messages"
import { describe, expect, it, vi } from "vitest"
import { runProactiveGlance } from "./glance"
function fakeGlanceGraph(args: Record<string, unknown>) {
return { async invoke() { return { messages: [
new AIMessage({ content: "", tool_calls: [
{ type: "tool_call", name: "finalize_glance", id: "g", args } ] }) ] } } }
}
describe("runProactiveGlance", () => {
it("returns the structured glance with a /tasks/ deep-link when surface=true", async () => {
const result = await runProactiveGlance({
organizationId: "org_1", userId: "user_1", screenshot: "BASE64",
foregroundApp: "Mail", webBase: "https://web",
getAgentImpl: async () =>
fakeGlanceGraph({ surface: true, message: "Reply due.", openInBrowser: true }) as never,
createThreadImpl: vi.fn(async () => "thr_x") as never,
})
expect(result.surface).toBe(true)
expect(result.message).toBe("Reply due.")
expect(result.deepLink).toBe("https://web/tasks/chats/thr_x")
})
it("returns surface:false and creates no thread when nothing is worth surfacing", async () => {
const createThread = vi.fn(async () => "thr_y")
const result = await runProactiveGlance({
organizationId: "org_1", userId: "user_1", screenshot: "BASE64",
foregroundApp: "Finder", webBase: "https://web",
getAgentImpl: async () => fakeGlanceGraph({ surface: false }) as never,
createThreadImpl: createThread as never,
})
expect(result.surface).toBe(false)
expect(result.deepLink).toBeUndefined()
expect(createThread).not.toHaveBeenCalled()
})
})
Step 2: Run test to verify it fails — cd packages/chat-runtime && bun run vitest run src/glance.test.ts → FAIL.
Step 3: Write the run helper
Read the top of
packages/chat-runtime/src/run.tsfirst to copy the EXACTgetAgentimport path, thebuildDeepLinkhelper, and the screenshot-as-HumanMessage construction (a content array with animage_url/base64 part). Reuse the always-/tasks/link (no auto-filing). Match the real signatures.
// packages/chat-runtime/src/glance.ts
import { HumanMessage } from "@langchain/core/messages"
import { getAgent, harvestFinalizeGlance } from "@workspace/agent-runtime"
import { createThread } from "./threads"
export interface RunProactiveGlanceInput {
organizationId: string
userId: string
screenshot: string // base64
foregroundApp: string
webBase: string
getAgentImpl?: typeof getAgent
createThreadImpl?: typeof createThread
}
export interface ProactiveGlanceResult { surface: boolean; message?: string; deepLink?: string }
function buildTasksDeepLink(webBase: string, threadId: string): string {
return `${webBase.replace(/\/$/, "")}/tasks/chats/${threadId}`
}
export async function runProactiveGlance(
input: RunProactiveGlanceInput,
): Promise<ProactiveGlanceResult> {
const getAgentFn = input.getAgentImpl ?? getAgent
const createThreadFn = input.createThreadImpl ?? createThread
const graph = await getAgentFn("proactive-glance", {})
const userMessage = new HumanMessage({ content: [
{ type: "text", text: `The lawyer just switched into "${input.foregroundApp}". Here is their screen.` },
{ type: "image_url", image_url: { url: `data:image/png;base64,${input.screenshot}` } },
] })
const final = await graph.invoke({ messages: [userMessage] })
const glance = harvestFinalizeGlance(final.messages)
if (!glance || !glance.surface) return { surface: false }
let deepLink: string | undefined
if (glance.openInBrowser) {
const threadId = await createThreadFn({
organizationId: input.organizationId, userId: input.userId,
agentId: "screen-companion", title: glance.message ?? "Proactive nudge",
})
deepLink = buildTasksDeepLink(input.webBase, threadId)
}
return { surface: true, message: glance.message, deepLink }
}
Step 4: Export from the package index — in packages/chat-runtime/src/index.ts add export { runProactiveGlance } from "./glance" + the result/input types. Confirm harvestFinalizeGlance is re-exported from packages/agent-runtime/src/index.ts; if not, add it there (no deep imports).
Step 5: Run test to verify it passes → PASS (2 tests).
Step 6: Commit — feat(chat-runtime): runProactiveGlance one-shot run helper.
Task 4: POST /proactive-glance gateway route
Files: Modify apps/clicky-gateway/src/routes/router.ts; Test apps/clicky-gateway/src/proactive-glance.test.ts.
Step 1: Write the failing route test
// apps/clicky-gateway/src/proactive-glance.test.ts
import { describe, expect, it, vi } from "vitest"
vi.mock("@workspace/chat-runtime", async (orig) => {
const actual = await orig<typeof import("@workspace/chat-runtime")>()
return { ...actual, runProactiveGlance: vi.fn(async () => ({
surface: true, message: "Reply due Friday.", deepLink: "https://web/tasks/chats/thr_x" })) }
})
import { clickyRouter } from "./routes/router"
function authedRequest(body: unknown) {
return new Request("http://local/proactive-glance", {
method: "POST",
headers: { "content-type": "application/json", authorization: "Bearer test" },
body: JSON.stringify(body),
})
}
describe("POST /proactive-glance", () => {
it("400s on an empty body (or 401 if auth blocks first)", async () => {
const res = await clickyRouter.request(authedRequest({}))
expect([400, 401]).toContain(res.status)
})
})
NOTE: read
dispatch.test.tsto copy how it satisfies/bypassesclickyAuth(it stubsc.var.session). Mirror that exact setup so this asserts the body contract, not auth.
Step 2: Run test to verify it fails — route returns 404 (not yet defined).
Step 3: Add the route — in router.ts: (a) clickyRouter.use("/proactive-glance", clickyAuth) next to the others; (b) import { runProactiveGlance } from "@workspace/chat-runtime"; (c) the handler:
const proactiveGlanceBodySchema = z.object({
screenshot: z.string().min(1),
foregroundApp: z.string().min(1),
})
clickyRouter.post("/proactive-glance", async (c) => {
let raw: unknown
try { raw = await c.req.json() } catch {
return c.json({ error: "invalid_request", message: "Body must be JSON." }, 400) }
const parsed = proactiveGlanceBodySchema.safeParse(raw)
if (!parsed.success) {
return c.json({ error: "invalid_request",
message: parsed.error.issues[0]?.message ?? "Invalid body." }, 400) }
const { organizationId, userId } = c.var.session
const webBase = process.env.WEB_BASE_URL ?? "https://north-os-web.dev.cosmictaco.dev"
const result = await runProactiveGlance({
organizationId, userId,
screenshot: parsed.data.screenshot, foregroundApp: parsed.data.foregroundApp, webBase })
return c.json(result)
})
Confirm the
webBaseenv name the dispatch path already uses (grepapps/clicky-gateway+run.ts); reuse THAT, don't introduce a new one. Ifturbo.jsonstrict-env applies, add it toglobalEnv.
Step 4: Run test to verify it passes → PASS.
Step 5: Typecheck + lint + commit — bun run typecheck && bun run lint; feat(clicky-gateway): POST /proactive-glance route.
Task 5: Island state enum (Swift)
Files: Create apps/polaris/leanring-buddy/IslandState.swift.
Step 1: Write the enum
// apps/polaris/leanring-buddy/IslandState.swift
import Foundation
/// The single source of truth for what the faux Dynamic-Island pill shows.
enum IslandState: Equatable {
case hidden
case working(label: String)
case message(text: String, deepLink: URL?)
}
Step 2: Commit — feat(polaris): IslandState enum for the notch pill (leaf type, no build needed).
Task 6: Island panel + manager (Swift)
Files: Create apps/polaris/leanring-buddy/IslandPanel.swift (clone the OverlayWindow/OverlayWindowManager lifecycle).
Step 1: Write the panel + manager
// apps/polaris/leanring-buddy/IslandPanel.swift
import AppKit
import SwiftUI
import Combine
final class IslandPanel: NSPanel {
init() {
super.init(contentRect: NSRect(x: 0, y: 0, width: 360, height: 44),
styleMask: [.borderless, .nonactivatingPanel],
backing: .buffered, defer: false)
isFloatingPanel = true
level = .statusBar
backgroundColor = .clear
isOpaque = false
hasShadow = true
hidesOnDeactivate = false
collectionBehavior = [.canJoinAllSpaces, .stationary, .fullScreenAuxiliary]
ignoresMouseEvents = false
}
override var canBecomeKey: Bool { false }
override var canBecomeMain: Bool { false }
}
@MainActor
final class IslandManager: ObservableObject {
private var panel: IslandPanel?
private var hostingView: NSHostingView<IslandView>?
private var cancellables = Set<AnyCancellable>()
@Published var state: IslandState = .hidden
func show() {
guard panel == nil else { return }
let panel = IslandPanel()
let view = IslandView(manager: self)
let hosting = NSHostingView(rootView: view)
hosting.frame = panel.contentLayoutRect
panel.contentView = hosting
self.panel = panel
self.hostingView = hosting
reposition()
panel.orderFrontRegardless()
NotificationCenter.default
.publisher(for: NSApplication.didChangeScreenParametersNotification)
.sink { [weak self] _ in self?.reposition() }
.store(in: &cancellables)
}
private func reposition() {
guard let panel, let screen = NSScreen.main else { return }
let panelSize = panel.frame.size
let visible = screen.frame
let notchTopInset = screen.safeAreaInsets.top // 0 on non-notched displays
let x = visible.midX - panelSize.width / 2
let y = visible.maxY - panelSize.height - max(notchTopInset, 2)
panel.setFrameOrigin(NSPoint(x: x, y: y))
}
func update(_ newState: IslandState) {
state = newState
if case .message(_, let link) = newState, link != nil {
panel?.ignoresMouseEvents = false
} else {
panel?.ignoresMouseEvents = true
}
}
}
Step 2: Commit — feat(polaris): IslandPanel + IslandManager (top-center notch panel).
Task 7: Island SwiftUI view (Swift)
Files: Create apps/polaris/leanring-buddy/IslandView.swift (use DS tokens from DesignSystem.swift).
Step 1: Write the pill view
// apps/polaris/leanring-buddy/IslandView.swift
import SwiftUI
struct IslandView: View {
@ObservedObject var manager: IslandManager
@Namespace private var pillNamespace
@State private var autoCollapseTask: Task<Void, Never>?
var body: some View {
content
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top)
.animation(.spring(response: 0.4, dampingFraction: 0.8), value: manager.state)
.onChange(of: manager.state) { _, newState in
scheduleAutoCollapseIfNeeded(for: newState)
}
}
@ViewBuilder
private var content: some View {
switch manager.state {
case .hidden:
Capsule().fill(Color.black.opacity(0.001))
.matchedGeometryEffect(id: "pill", in: pillNamespace)
.frame(width: 6, height: 6)
case .working(let label):
HStack(spacing: 8) {
ProgressView().controlSize(.small).tint(.white)
Text(label).font(.system(size: 12, weight: .medium))
.foregroundStyle(.white).lineLimit(1)
}
.padding(.horizontal, 14).frame(height: 32)
.background(Capsule().fill(.black.opacity(0.85))
.matchedGeometryEffect(id: "pill", in: pillNamespace))
case .message(let text, let deepLink):
Button {
if let deepLink { NSWorkspace.shared.open(deepLink) }
manager.update(.hidden)
} label: {
Text(text).font(.system(size: 12, weight: .medium))
.foregroundStyle(.white).lineLimit(2)
.padding(.horizontal, 14).frame(minHeight: 32)
.background(Capsule().fill(.black.opacity(0.85))
.matchedGeometryEffect(id: "pill", in: pillNamespace))
}
.buttonStyle(.plain)
.allowsHitTesting(deepLink != nil)
}
}
private func scheduleAutoCollapseIfNeeded(for newState: IslandState) {
autoCollapseTask?.cancel()
guard case .message = newState else { return }
autoCollapseTask = Task { @MainActor in
try? await Task.sleep(for: .seconds(6))
if case .message = manager.state { manager.update(.hidden) }
}
}
}
Step 2: Commit — feat(polaris): IslandView SwiftUI pill with spring morph.
Task 8: Proactive context monitor + governor (Swift)
Files: Create apps/polaris/leanring-buddy/ProactiveContextMonitor.swift. The governor is a static pure function — the one piece worth unit-testing later.
Step 1: Write the monitor + the pure governor
// apps/polaris/leanring-buddy/ProactiveContextMonitor.swift
import AppKit
import Combine
import Foundation
@MainActor
final class ProactiveContextMonitor {
static let settleSeconds: TimeInterval = 4.5
static let minIntervalSeconds: TimeInterval = 240 // 4 min
static let ignoredAppNames: Set<String> = [
"leanring-buddy", "Clicky", "Finder", "loginwindow", "ScreenSaverEngine" ]
private var settleWorkItem: DispatchWorkItem?
private var lastPassAt: Date?
private var lastPassApp: String?
private var cancellable: AnyCancellable?
private let isIslandFree: () -> Bool
private let captureScreenshot: () async -> String?
private let runGlance: (_ screenshot: String, _ app: String) async -> ProactiveGlanceClientResult?
private let surface: (_ message: String, _ deepLink: URL?) -> Void
init(isIslandFree: @escaping () -> Bool,
captureScreenshot: @escaping () async -> String?,
runGlance: @escaping (String, String) async -> ProactiveGlanceClientResult?,
surface: @escaping (String, URL?) -> Void) {
self.isIslandFree = isIslandFree
self.captureScreenshot = captureScreenshot
self.runGlance = runGlance
self.surface = surface
}
func start() {
cancellable = NSWorkspace.shared.notificationCenter
.publisher(for: NSWorkspace.didActivateApplicationNotification)
.sink { [weak self] note in
let app = (note.userInfo?[NSWorkspace.applicationUserInfoKey] as? NSRunningApplication)
self?.handleActivation(appName: app?.localizedName)
}
}
private func handleActivation(appName: String?) {
guard let appName else { return }
settleWorkItem?.cancel()
let work = DispatchWorkItem { [weak self] in
Task { @MainActor in await self?.onSettled(appName: appName) } }
settleWorkItem = work
DispatchQueue.main.asyncAfter(deadline: .now() + Self.settleSeconds, execute: work)
}
private func onSettled(appName: String) async {
guard Self.shouldPass(now: Date(), lastPassAt: lastPassAt, lastPassApp: lastPassApp,
currentApp: appName, islandIsFree: isIslandFree(),
ignored: Self.ignoredAppNames,
minIntervalSeconds: Self.minIntervalSeconds) else { return }
// A pass counts even if it fails downstream, so a hard-down backend can't hammer.
lastPassAt = Date(); lastPassApp = appName
guard let screenshot = await captureScreenshot() else { return }
guard let result = await runGlance(screenshot, appName), result.surface,
let message = result.message else { return }
surface(message, result.deepLink)
}
/// PURE governor — the noise-control logic, isolated for testing.
static func shouldPass(now: Date, lastPassAt: Date?, lastPassApp: String?,
currentApp: String, islandIsFree: Bool,
ignored: Set<String>, minIntervalSeconds: TimeInterval) -> Bool {
if ignored.contains(currentApp) { return false }
if !islandIsFree { return false }
if currentApp == lastPassApp { return false }
if let lastPassAt, now.timeIntervalSince(lastPassAt) < minIntervalSeconds { return false }
return true
}
}
struct ProactiveGlanceClientResult {
let surface: Bool
let message: String?
let deepLink: URL?
}
Step 2: Commit — feat(polaris): ProactiveContextMonitor with pure interval-capped governor.
Task 9: Client glance API + CompanionManager wiring (Swift)
Files: Modify NorthDispatchAPI.swift, CompanionManager.swift, apps/polaris/CLAUDE.md.
Step 1: Add proactiveGlance to NorthDispatchAPI — mirror the existing dispatch(...) URL/bearer/refresh/error handling.
struct GlanceResponse: Decodable {
let surface: Bool
let message: String?
let deepLink: String?
}
func proactiveGlance(screenshot: String, foregroundApp: String) async throws -> ProactiveGlanceClientResult {
let url = baseURL.appendingPathComponent("proactive-glance")
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
applyAuthHeaders(to: &request) // use the SAME helper the dispatch method uses
request.httpBody = try JSONEncoder().encode([
"screenshot": screenshot, "foregroundApp": foregroundApp ])
let (data, response) = try await session.data(for: request)
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
throw DispatchError.requestFailed }
let decoded = try JSONDecoder().decode(GlanceResponse.self, from: data)
return ProactiveGlanceClientResult(
surface: decoded.surface, message: decoded.message,
deepLink: decoded.deepLink.flatMap(URL.init(string:)))
}
applyAuthHeaders/session/baseURL/DispatchErrorare PLACEHOLDERS for whatever the existingdispatchmethod uses — read it and use the real members. Do not introduce a second auth path.
Step 2: Wire the island + monitor into CompanionManager
(a) stored properties:
let islandManager = IslandManager()
private var proactiveMonitor: ProactiveContextMonitor?
@Published private(set) var currentAckLabel: String = "Working…"
(b) in start(), after existing setup:
islandManager.show()
proactiveMonitor = ProactiveContextMonitor(
isIslandFree: { [weak self] in
if case .hidden = self?.islandManager.state { return true }
return false
},
captureScreenshot: { [weak self] in
await self?.captureForegroundScreenshotBase64() // reuse the existing capture path
},
runGlance: { [weak self] screenshot, app in
guard let self else { return nil }
let api = NorthDispatchAPI(gatewayHost: AppBundleConfiguration.gatewayHost)
return try? await api.proactiveGlance(screenshot: screenshot, foregroundApp: app)
},
surface: { [weak self] message, deepLink in
self?.islandManager.update(.message(text: message, deepLink: deepLink))
}
)
proactiveMonitor?.start()
(c) drive the island from the existing voiceState sink (~line 459–478):
switch self.voiceState {
case .processing, .responding:
self.islandManager.update(.working(label: self.currentAckLabel))
case .idle:
if case .working = self.islandManager.state { self.islandManager.update(.hidden) }
default: break
}
(d) capture the ack text in the dispatch onAck closure (where the spoken-ack already arrives):
self.currentAckLabel = ackEnvelope.spokenAck
self.islandManager.update(.working(label: ackEnvelope.spokenAck))
(e) surface the full answer where the turn ends (reuse the turnResult.deepLink already in scope at the closing NSWorkspace.shared.open):
self.islandManager.update(.message(text: finalSpokenText, deepLink: turnResult.deepLink))
Step 3: Update apps/polaris/CLAUDE.md Key Files table — add the four new Swift files with one-line purposes + approx line counts (per that file's self-update rule).
Step 4: Commit — feat(polaris): wire island + proactive monitor into CompanionManager.
Verification
- Backend gate (this session): from repo root,
bun run typecheck && bun run lint && bun run test(or the three touched-package test files). All green. - Client (Christophe, Xcode Cmd+R): island pill at top-center resting invisible; a voice turn expands to
.workingwith the ack sentence, morphs to.message, tap opens/tasks/…, then collapses; switch to Mail + wait the settle → at most one proactive pass per 4 min,surface:falsestays silent, asurface:trueshows a SILENT nudge. - Spec status: flip
2026-06-11-island-proactive-nudges-design.mdtoverified (prototype)once the above pass, noting as-built deltas.
Self-review
- Spec coverage: island on voice turns (Tasks 5–7, 9) ✓; ack-text-in-pill, no "Working…" end-state (Task 9d) ✓; proactive trigger+settle+governor (Task 8) ✓; interval-cap + island-free + app-changed + ignore-list gates (Task 8
shouldPass) ✓; cleanproactive-glanceseam not in dispatch (Task 4 sibling route) ✓; cheap Haiku one-shot (Tasks 1–3) ✓; silent nudge, no TTS (Task 9surfaceclosure sets.messageonly) ✓; always-/tasks/deep-link (Task 3) ✓. - Type consistency:
ProactiveGlanceClientResultdefined once (Task 8), consumed by Task 9;FinalizeGlanceArgs(surface/message/openInBrowser) consistent Tasks 1→3; gatewayProactiveGlanceResult(surface/message/deepLink) consistent Tasks 3→4→9. - Adjust-on-contact anchors:
registerAgentfields (T2.5),clickyAuthtest setup (T4.1),NorthDispatchAPIauth members (T9.1),webBaseenv (T4.3) — flagged inline to confirm against the real files; integration anchors, not invented APIs.