Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ Programa is a fork of [cmux](https://github.com/manaflow-ai/cmux); for history p
## [Unreleased]

### Added
- An app connected to the socket can show the macOS banner for agent questions itself, so you get one banner per question instead of two. The new `notification.claim_banners` method holds Programa's own banner only while that app stays connected; the notification list, sidebar and Dock badge keep updating, and the banner comes back by itself when the app quits.
- `programa usage` shows how many tokens each Claude Code and Codex session used, grouped by workspace, read from the agents' own transcripts. It shows no cost, because the transcripts don't record one per session. See `docs/agent-usage.md`.
- Start work from a GitHub issue: `programa worktree create --from-issue 42` reads the issue with `gh`, names the branch after it and opens the worktree as a workspace. Add `--agent claude` (or `codex`, `opencode`) to start that agent on the issue right away.
- The review panel shows whether the workspace's pull request is ready to merge: its state, which checks are failing or still running, how many review conversations are unresolved, and whether merge conflicts or a missing review block it, with a link to the PR. It reads from the GitHub CLI (`gh`) when the panel opens or you press its refresh button.
Expand Down
3 changes: 3 additions & 0 deletions CLI/V2MethodNames.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ enum V2MethodNames {
static let debugFlashReset = "debug.flash.reset"
static let debugGlassSet = "debug.glass.set"
static let debugLayout = "debug.layout"
static let debugNotificationBannerCount = "debug.notification.banner_count"
static let debugNotificationBannerReset = "debug.notification.banner_reset"
static let debugNotificationFocus = "debug.notification.focus"
static let debugPanelSnapshot = "debug.panel_snapshot"
static let debugPanelSnapshotReset = "debug.panel_snapshot.reset"
Expand All @@ -64,6 +66,7 @@ enum V2MethodNames {
static let layoutList = "layout.list"
static let layoutSave = "layout.save"
static let markdownOpen = "markdown.open"
static let notificationClaimBanners = "notification.claim_banners"
static let notificationClear = "notification.clear"
static let notificationCreate = "notification.create"
static let notificationCreateForSurface = "notification.create_for_surface"
Expand Down
11 changes: 11 additions & 0 deletions Sources/TerminalController+Debug.swift
Original file line number Diff line number Diff line change
Expand Up @@ -416,6 +416,17 @@ extension TerminalController {
}
return .ok([:])
}

// The counter lives on the main-actor notification store, so both methods take the main hop.
nonisolated func v2DebugNotificationBannerCount() -> V2CallResult {
let count: Int = v2MainSync { TerminalNotificationStore.shared.bannerHandoffCountForTesting }
return .ok(["count": count])
}

nonisolated func v2DebugNotificationBannerReset() -> V2CallResult {
v2MainSync { TerminalNotificationStore.shared.bannerHandoffCountForTesting = 0 }
return .ok([:])
}
#endif

nonisolated func v2DebugFocusNotification(params: [String: Any]) -> V2CallResult {
Expand Down
63 changes: 63 additions & 0 deletions Sources/TerminalController+Subscriptions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,35 @@ enum SocketEventClass: String, CaseIterable {
case needsInput = "needs_input"
}

/// Notification kinds whose macOS banner a socket client can take over with
/// `notification.claim_banners`.
enum NotificationBannerClaimKind: String, CaseIterable {
/// Notifications posted by `agent.needs_input`.
case needsInput = "needs_input"
}

/// The banner kinds each live connection claims. A claim ends with its connection:
/// `SocketConnection.teardown()` releases it on every close path, so a client that quits or
/// crashes hands the banners back to Programa without having to say so.
final class NotificationBannerClaims: @unchecked Sendable {
static let shared = NotificationBannerClaims()

private let lock = NSLock()
private var kindsByConnection: [ObjectIdentifier: Set<NotificationBannerClaimKind>] = [:]

func set(_ kinds: Set<NotificationBannerClaimKind>, for connection: SocketConnection) {
lock.lock()
defer { lock.unlock() }
kindsByConnection[ObjectIdentifier(connection)] = kinds.isEmpty ? nil : kinds
}

func isClaimed(_ kind: NotificationBannerClaimKind) -> Bool {
lock.lock()
defer { lock.unlock() }
return kindsByConnection.values.contains { $0.contains(kind) }
}
}

/// Wraps a single accepted v2 socket connection (one per `TerminalController.handleClient`
/// invocation) so that ordinary request/response writes and subscription event pushes -- which
/// can originate from different threads -- serialize onto the same underlying fd through one
Expand Down Expand Up @@ -149,6 +178,16 @@ final class SocketConnection: @unchecked Sendable {
}
}

/// Replaces this connection's banner claim (`notification.claim_banners`); an empty set
/// releases it. Checked against `closed` under `stateLock` so a claim can never be recorded
/// after `teardown()` has released it.
func claimBanners(_ kinds: Set<NotificationBannerClaimKind>) {
stateLock.lock()
defer { stateLock.unlock() }
guard !closed else { return }
NotificationBannerClaims.shared.set(kinds, for: self)
}

/// Called from `handleClient`'s `defer`, in addition to (and before) closing the raw fd.
func teardown() {
writeLock.lock()
Expand All @@ -160,6 +199,7 @@ final class SocketConnection: @unchecked Sendable {
pendingSubscription = nil
stateLock.unlock()
writeLock.unlock()
NotificationBannerClaims.shared.set([], for: self)
if previousActive != nil || previousPending != nil {
dilog("socket.conn", "teardown hadSubscription=true")
}
Expand Down Expand Up @@ -526,6 +566,29 @@ extension TerminalController {
return .ok(["unsubscribed": true])
}

/// `notification.claim_banners`: the calling connection shows the macOS banner for `kinds`
/// itself while it stays connected. Replaces the connection's previous claim; `[]` releases
/// it. Runs off-main: it only writes `NotificationBannerClaims`, which is lock-protected.
nonisolated func v2NotificationClaimBanners(params: [String: Any], connection: SocketConnection) -> V2CallResult {
let validKinds = NotificationBannerClaimKind.allCases.map(\.rawValue).joined(separator: ", ")
guard let rawKinds = params["kinds"] as? [Any] else {
return .err(code: "invalid_params", message: "kinds must be an array of: \(validKinds)", data: nil)
}
var kinds: Set<NotificationBannerClaimKind> = []
for raw in rawKinds {
guard let name = raw as? String, let kind = NotificationBannerClaimKind(rawValue: name) else {
return .err(
code: "invalid_params",
message: "Unknown notification kind '\(raw)' -- use: \(validKinds)",
data: nil
)
}
kinds.insert(kind)
}
connection.claimBanners(kinds)
return .ok(["claimed": kinds.map(\.rawValue).sorted()])
}

// MARK: - Output event polling (#167 task 3)

private nonisolated static let outputPollInterval: TimeInterval = 0.1
Expand Down
3 changes: 2 additions & 1 deletion Sources/TerminalController+Telemetry.swift
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,8 @@ extension TerminalController {
surfaceId: sid,
title: resolvedTitle,
subtitle: subtitle,
body: body
body: body,
deliversBanner: !NotificationBannerClaims.shared.isClaimed(.needsInput)
)
SocketEventBroadcaster.shared.publishNeedsInput(
workspaceId: workspaceId,
Expand Down
6 changes: 6 additions & 0 deletions Sources/TerminalController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -1975,6 +1975,8 @@ class TerminalController {
return v2Ok(id: id, result: self.v2NotificationList())
case "notification.clear":
return v2Result(id: id, self.v2NotificationClear(params: params))
case "notification.claim_banners":
return v2Result(id: id, self.v2NotificationClaimBanners(params: params, connection: connection))

case "app.reload_config":
return v2Result(id: id, self.v2AppReloadConfig(params: params))
Expand Down Expand Up @@ -2072,6 +2074,10 @@ class TerminalController {
return v2Result(id: id, self.v2DebugSamplesReset(params: params))
case "debug.notification.focus":
return v2Result(id: id, self.v2DebugFocusNotification(params: params))
case "debug.notification.banner_count":
return v2Result(id: id, self.v2DebugNotificationBannerCount())
case "debug.notification.banner_reset":
return v2Result(id: id, self.v2DebugNotificationBannerReset())
case "debug.flash.count":
return v2Result(id: id, self.v2DebugFlashCount(params: params))
case "debug.flash.reset":
Expand Down
14 changes: 12 additions & 2 deletions Sources/TerminalNotificationStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,10 @@ final class TerminalNotificationStore: ObservableObject {
subtitle: String,
body: String,
cooldownKey: String? = nil,
cooldownInterval: TimeInterval? = nil
cooldownInterval: TimeInterval? = nil,
// false while a socket client shows this kind's banner itself (`notification.claim_banners`);
// the list, sidebar and Dock badge still update.
deliversBanner: Bool = true
) {
let now = Date()
let resolvedCooldownInterval: TimeInterval?
Expand Down Expand Up @@ -390,7 +393,10 @@ final class TerminalNotificationStore: ObservableObject {
}
if shouldSuppressExternalDelivery {
suppressedNotificationFeedbackHandler(self, notification)
} else {
} else if deliversBanner {
#if DEBUG
bannerHandoffCountForTesting += 1
#endif
notificationDeliveryHandler(self, notification)
}
}
Expand Down Expand Up @@ -848,6 +854,10 @@ final class TerminalNotificationStore: ObservableObject {
}

#if DEBUG
/// Notifications handed to `notificationDeliveryHandler`, counted before authorization so a
/// test runner without notification permission still sees them (`debug.notification.banner_count`).
var bannerHandoffCountForTesting = 0

func configureNotificationSettingsPromptHooksForTesting(
windowProvider: @escaping () -> NSWindow?,
alertFactory: @escaping () -> NSAlert,
Expand Down
3 changes: 3 additions & 0 deletions Sources/V2CommandCatalog.swift
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ enum V2CommandCatalog {
"layout.list",
"layout.save",
"markdown.open",
"notification.claim_banners",
"notification.clear",
"notification.create",
"notification.create_for_surface",
Expand Down Expand Up @@ -158,6 +159,8 @@ enum V2CommandCatalog {
"debug.flash.reset",
"debug.glass.set",
"debug.layout",
"debug.notification.banner_count",
"debug.notification.banner_reset",
"debug.notification.focus",
"debug.panel_snapshot",
"debug.panel_snapshot.reset",
Expand Down
68 changes: 68 additions & 0 deletions contracts/v2/methods.json
Original file line number Diff line number Diff line change
Expand Up @@ -924,6 +924,42 @@
},
"threading": "off_main"
},
"debug.notification.banner_count": {
"auth_required": true,
"debug_only": true,
"description": "DEBUG-build-only introspection and simulation. Number of notifications handed to the macOS banner path since launch or the last debug.notification.banner_reset.",
"errors": [],
"focus_intent": false,
"handler": "v2DebugNotificationBannerCount",
"params": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object"
},
"result": {
"type": "object"
},
"threading": "main"
},
"debug.notification.banner_reset": {
"auth_required": true,
"debug_only": true,
"description": "DEBUG-build-only introspection and simulation. Resets debug.notification.banner_count to zero.",
"errors": [],
"focus_intent": false,
"handler": "v2DebugNotificationBannerReset",
"params": {
"additionalProperties": false,
"properties": {},
"required": [],
"type": "object"
},
"result": {
"type": "object"
},
"threading": "main"
},
"debug.notification.focus": {
"auth_required": true,
"debug_only": true,
Expand Down Expand Up @@ -1537,6 +1573,38 @@
},
"threading": "main"
},
"notification.claim_banners": {
"auth_required": true,
"debug_only": false,
"description": "Lets the calling connection take over the macOS banners for the listed notification kinds while it stays connected (docs/socket-api.md \"notification.claim_banners\").",
"errors": [
"invalid_params"
],
"focus_intent": false,
"handler": "v2NotificationClaimBanners",
"params": {
"additionalProperties": false,
"properties": {
"kinds": {
"items": {
"enum": [
"needs_input"
],
"type": "string"
},
"type": "array"
}
},
"required": [
"kinds"
],
"type": "object"
},
"result": {
"type": "object"
},
"threading": "off_main"
},
"notification.clear": {
"auth_required": true,
"debug_only": false,
Expand Down
33 changes: 33 additions & 0 deletions docs/socket-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,34 @@ updates `AgentSupervisionRegistry` in the same handler call. Existing methods
hook configuration on user machines doesn't need reinstalling to pick it up. Errors:
`invalid_params`, `not_found`.

## `notification.claim_banners`

Lets a connected client show the macOS banner for agent questions itself, so the user gets one
banner per question instead of one from Programa and one from the client. Mesinyer uses it to
replace Programa's banner with its own.

| Field | Type | Required | Notes |
|---|---|---|---|
| `kinds` | array of string | yes | Notification kinds to claim. The only kind is `needs_input`, the notification `agent.needs_input` posts. `[]` releases the claim. |

Response (`ok: true`): `{"claimed": ["needs_input"]}`, the kinds this connection now claims,
sorted. An unknown kind or a missing `kinds` returns `invalid_params` and leaves the existing
claim as it was. Requires the same authentication as any other method.

The claim belongs to the connection that made it. Calling again on the same connection replaces
its kinds, and the claim ends when the connection closes, however it closes (the client quits
or crashes, or the app shuts the listener down). Banners stay claimed while at least one live
connection claims the kind, so a client keeps its connection open for as long as it shows the
banners.

While `needs_input` is claimed, a notification from `agent.needs_input` still goes into the
notification list (`notification.list`), the sidebar and the Dock badge, the surface still
reports `blocked`, and subscribers still get the `needs_input` event frame. Only the macOS
banner, and the sound that comes with it, is skipped. The claim is read when each notification
is posted, so after the last claiming connection closes, the next `agent.needs_input` shows a
banner again. Notifications from `notification.create*`, terminal escape sequences and OSC 7501
are not affected.

## Workspace `agent_state` and `helper_outcomes`

`workspace.list` and `system.tree` entries carry an aggregate `agent_state` of `idle`,
Expand Down Expand Up @@ -980,6 +1008,11 @@ Services call. Both are omitted if resolution fails.
of every terminal), exist only in Debug builds. Release builds answer them with
`method_not_found`.

`debug.notification.banner_count` returns `{"count": N}`, the number of notifications handed to
the macOS banner path since launch or the last `debug.notification.banner_reset`. It counts
before the notification-permission check, so tests on a runner without permission still see
each hand-off.

## Tests

`tests_v2/` contains socket integration tests, using `tests_v2/programa_client.py` where a client
Expand Down
1 change: 1 addition & 0 deletions tests_v2/ci_subset.txt
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ test_program_status_osc7501.py
test_agent_event_socket.py
test_agent_state_wait_and_prompt.py
test_socket_event_subscriptions.py
test_notification_claim_banners.py
test_app_browsers.py
test_v2_contract_matches_capabilities.py
# Safety set: worktree create/list/remove (remove must never delete the branch), undoable
Expand Down
Loading
Loading