← plans · north_os · DESIGN.md11 juin 2026 à 18:36

Island indicator + proactive nudges — Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:executing-plans to 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):

Client (written here, compiled by Christophe):

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 failscd 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: Commitgit 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 failscd 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 exact AgentDefinition shape by reading registry.ts and desktop-ack/index.ts before 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: Commitfeat(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 failscd 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.ts first to copy the EXACT getAgent import path, the buildDeepLink helper, and the screenshot-as-HumanMessage construction (a content array with an image_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: Commitfeat(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.ts to copy how it satisfies/bypasses clickyAuth (it stubs c.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 webBase env name the dispatch path already uses (grep apps/clicky-gateway + run.ts); reuse THAT, don't introduce a new one. If turbo.json strict-env applies, add it to globalEnv.

Step 4: Run test to verify it passes → PASS.

Step 5: Typecheck + lint + commitbun 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: Commitfeat(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: Commitfeat(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: Commitfeat(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: Commitfeat(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/DispatchError are PLACEHOLDERS for whatever the existing dispatch method 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: Commitfeat(polaris): wire island + proactive monitor into CompanionManager.

Verification

Self-review