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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
# Changelog

## 0.7.0.2 - 2026-07-22

- Normalize remote snapshot timestamps to local receipt time so ordinary Mac/Windows clock differences cannot hide working, selected, approval, or usage state.
- Reset a stale opposite-platform control target when the plugin starts without a configured second host, while preserving intentional remote targeting during temporary relay outages.
- Keep unique last-known tasks visible when one iPhone node is offline, clearly mark them offline in the app and widgets, and prevent offline agent keys from dispatching commands.
- Prefer the live connection when duplicate iPhone profiles authenticate as the same computer, including command delivery and displayed connection health.
- Added regression coverage for Mac-only, Windows-only, mixed online/offline, duplicate-profile, and cross-host clock-skew behavior.

## 0.7.0.1 - 2026-07-22

- Fixed one cross-host Codex task appearing twice when a new Windows or Mac thread transitioned from its temporary renderer ID to its stable rollout ID.
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,8 +179,8 @@ npm run audit:release
`npm run release:prepare` creates a versioned local release-candidate directory with the plugin package, Windows launcher ZIP, and SHA-256 checksums. The macOS ZIP must be created on macOS with `scripts/package-macos-release.sh` so executable bits survive; pass that ZIP to `scripts/prepare-release.ps1 -MacArchivePath ...`.

For a four-component Stream Deck hotfix version, set
`CODEX_DECK_RELEASE_VERSION=0.7.0.1` while running the macOS packager and pass
`-ReleaseVersion 0.7.0.1` to `prepare-release.ps1`. The npm package keeps its
`CODEX_DECK_RELEASE_VERSION=0.7.0.2` while running the macOS packager and pass
`-ReleaseVersion 0.7.0.2` to `prepare-release.ps1`. The npm package keeps its
SemVer-compatible prerelease form.

Nothing is published automatically. See [CONTRIBUTING.md](CONTRIBUTING.md).
Expand Down
2 changes: 1 addition & 1 deletion docs/IOS_INSTALL.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ For a released build, either download **Source code (zip)** from that GitHub
release and extract it, or clone the matching tag in Terminal:

```zsh
git clone --branch v0.7.0.1 --depth 1 https://github.com/dazer1234/codex-stream-deck.git
git clone --branch v0.7.0.2 --depth 1 https://github.com/dazer1234/codex-stream-deck.git
cd codex-stream-deck
```

Expand Down
34 changes: 34 additions & 0 deletions docs/RELEASE_0.7.0.2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Codex Deck v0.7.0.2

This focused stability hotfix improves single-computer and mixed Mac/Windows
operation. It does not include the in-development task timer or StandBy
dashboard work.

## Fixes

- Normalizes remote timestamps when a snapshot arrives, preventing normal clock
differences between Mac and Windows from hiding working, selected, approval,
context, or usage state.
- Returns controls to the local computer when a previously configured second
host has been removed. A configured but temporarily offline relay still keeps
the user's explicit remote selection.
- Keeps unique last-known tasks visible on iPhone when one computer disconnects,
marks them offline in the app and widgets, and blocks taps that cannot be
delivered.
- Routes iPhone commands through the healthy connection when duplicate profiles
authenticate as the same computer.

## Downloads

- Stream Deck: `com.simeo.codex-deck.streamDeckPlugin`
- Windows launcher: `codex-deck-launcher-windows-v0.7.0.2.zip`
- macOS launcher: `codex-deck-launcher-macos-v0.7.0.2.zip`
- iPhone source: use the Source code archive or clone tag `v0.7.0.2`.
- Checksums: `SHA256SUMS.txt`

Existing v0.7.0 and v0.7.0.1 launcher/watcher installations remain compatible.
Stream Deck users only need to install the updated plugin. Codex itself does not
need to restart.

Codex Deck is an independent community project and is not made, supported, or
endorsed by OpenAI or Elgato.
56 changes: 56 additions & 0 deletions ios/CodexDeckMobile/Models/RelayModels.swift
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,62 @@ struct HostSnapshot: Codable, Hashable, Sendable {
let host: CodexHost
let observedAt: Double
let snapshot: MicroSnapshot

func normalizedToReceiptTime(_ receivedAt: Double) -> HostSnapshot {
guard receivedAt.isFinite, receivedAt > 0, observedAt.isFinite, observedAt > 0 else {
return self
}
let offset = receivedAt - observedAt
func shifted(_ value: Double?) -> Double? {
guard let value, value.isFinite, value > 0 else { return value }
return max(1, value + offset)
}
let normalizedUsage = snapshot.usage.map { usage in
UsageSnapshot(
windows: usage.windows.map { window in
UsageWindow(
id: window.id,
kind: window.kind,
usedPercent: window.usedPercent,
remainingPercent: window.remainingPercent,
windowDurationMins: window.windowDurationMins,
resetsAt: shifted(window.resetsAt))
},
observedAt: shifted(usage.observedAt) ?? receivedAt,
resetCreditsAvailable: usage.resetCreditsAvailable,
resetCreditsApplicable: usage.resetCreditsApplicable)
}
return HostSnapshot(
host: host,
observedAt: receivedAt,
snapshot: MicroSnapshot(
slots: snapshot.slots.map { slot in
AgentSlot(
id: slot.id,
threadKey: slot.threadKey,
title: slot.title,
status: slot.status,
selected: slot.selected,
activityAt: shifted(slot.activityAt),
ownedByHost: slot.ownedByHost,
contextUsedPercent: slot.contextUsedPercent)
},
activeThreadKey: snapshot.activeThreadKey,
activeThreadTitle: snapshot.activeThreadTitle,
layout: snapshot.layout,
agentSource: snapshot.agentSource,
lightingAutoOff: snapshot.lightingAutoOff,
theme: snapshot.theme,
usage: normalizedUsage,
hostSessions: snapshot.hostSessions?.map { session in
HostSessionPresence(
threadId: session.threadId,
activityAt: shifted(session.activityAt) ?? receivedAt,
status: session.status,
completionRevision: session.completionRevision,
contextUsedPercent: session.contextUsedPercent)
}))
}
}

enum ThreadIdentity {
Expand Down
6 changes: 4 additions & 2 deletions ios/CodexDeckMobile/Networking/RelayNodeConnection.swift
Original file line number Diff line number Diff line change
Expand Up @@ -171,9 +171,11 @@ final class RelayNodeConnection: RelayNodeConnecting {
status.requiresRepair = false
publish(state: .ready, detail: nil)
case .snapshot(let snapshot):
let receivedAt = Date()
status.host = snapshot.host
status.snapshot = snapshot
status.lastSnapshotReceivedAt = .now
status.snapshot = snapshot.normalizedToReceiptTime(
receivedAt.timeIntervalSince1970 * 1_000)
status.lastSnapshotReceivedAt = receivedAt
status.requiresRepair = false
publish(state: .ready, detail: nil)
case .health(let host, let reason, _):
Expand Down
92 changes: 76 additions & 16 deletions ios/CodexDeckMobile/Store/DashboardStore.swift
Original file line number Diff line number Diff line change
Expand Up @@ -110,18 +110,63 @@ final class DashboardStore {
publishWidgetState()
}

var snapshots: [HostSnapshot] {
let live = nodes.values.filter { $0.state == .ready || $0.state == .degraded }
.compactMap(\.snapshot)
let snapshots = live.isEmpty ? nodes.values.compactMap(\.snapshot) : live
return Dictionary(grouping: snapshots, by: \.host.hostId)
private var liveSnapshots: [HostSnapshot] {
latestSnapshots(nodes.compactMap { profileID, status in
guard connections[profileID] != nil,
status.state == .ready || status.state == .degraded
else { return nil }
return status.snapshot
})
}

private func latestSnapshots(_ values: [HostSnapshot]) -> [HostSnapshot] {
Dictionary(grouping: values, by: \.host.hostId)
.values
.compactMap { $0.max(by: { $0.observedAt < $1.observedAt }) }
}

private func connectionPriority(_ state: NodeConnectionState) -> Int {
switch state {
case .ready: 3
case .degraded: 2
case .connecting: 1
case .offline: 0
}
}

var snapshots: [HostSnapshot] {
liveSnapshots.isEmpty ? latestSnapshots(nodes.values.compactMap(\.snapshot)) : liveSnapshots
}

var agents: [RoutedAgent] {
MobileMerge.agents(
from: snapshots, acknowledgedCompletions: acknowledgedCompletionRevisions)
let live = liveSnapshots
guard !live.isEmpty else {
return MobileMerge.agents(
from: snapshots, acknowledgedCompletions: acknowledgedCompletionRevisions)
}
let liveAgents = MobileMerge.agents(
from: live, acknowledgedCompletions: acknowledgedCompletionRevisions)
let liveIdentities = Set(liveAgents.map { ThreadIdentity.canonical($0.threadKey) })
let liveHostIDs = Set(live.map { $0.host.hostId })
let cached = latestSnapshots(
nodes.values.compactMap(\.snapshot).filter { !liveHostIDs.contains($0.host.hostId) })
let cachedOnly = MobileMerge.agents(
from: cached, acknowledgedCompletions: acknowledgedCompletionRevisions
).filter { !liveIdentities.contains(ThreadIdentity.canonical($0.threadKey)) }
Comment on lines +153 to +155

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve aliases before splitting cached and live agents

When one node is offline with a cached client-new-thread key while the live node exposes the same task under its stable rollout ID, these separate MobileMerge.agents calls prevent temporaryThreadAliases from seeing both representations. The canonical-ID filter then treats them as different tasks, so the dashboard regresses to showing the cross-host task twice and may consume a slot with the stale offline alias. Merge the live and cached snapshots together for identity resolution before excluding cached duplicates.

Useful? React with 👍 / 👎.

return Array((liveAgents + cachedOnly).prefix(6)).enumerated().map { index, agent in
RoutedAgent(
id: index,
threadKey: agent.threadKey,
title: agent.title,
status: agent.status,
selected: agent.selected,
activityAt: agent.activityAt,
host: agent.host,
sourceSlot: agent.sourceSlot,
originPlatform: agent.originPlatform,
ownedByHost: agent.ownedByHost,
contextUsedPercent: agent.contextUsedPercent)
}
}
var mobileAgentPlacements: [MobileAgentPlacement] {
(0..<6).map { position in
Expand All @@ -142,7 +187,9 @@ final class DashboardStore {
}
}
var connectedCount: Int {
Set(nodes.values.filter { $0.state == .ready }.compactMap(\.host?.hostId)).count
Set(nodes.compactMap { profileID, status in
connections[profileID] != nil && status.state == .ready ? status.host?.hostId : nil
}).count
}
var expectedCount: Int {
let discovered = Set(nodes.values.compactMap(\.host?.hostId)).count
Expand Down Expand Up @@ -193,7 +240,11 @@ final class DashboardStore {
}

func connectionState(for hostID: String) -> NodeConnectionState {
nodes.values.first(where: { $0.host?.hostId == hostID })?.state ?? .offline
nodes.compactMap { profileID, status in
status.host?.hostId == hostID && connections[profileID] != nil ? status.state : nil
}.max {
connectionPriority($0) < connectionPriority($1)
} ?? .offline
}

func agent(for reference: AgentReference) -> RoutedAgent? {
Expand Down Expand Up @@ -700,12 +751,20 @@ final class DashboardStore {
}

private func deliver(_ command: RelayCommand, to hostID: String) async throws -> RelayDelivery {
guard let pair = nodes.first(where: {
$0.value.host?.hostId == hostID
&& ($0.value.state == .ready || $0.value.state == .degraded)
}),
let connection = connections[pair.key]
else {
let candidates = nodes.compactMap { profileID, status -> (NodeStatus, any RelayNodeConnecting)? in
guard status.host?.hostId == hostID,
status.state == .ready || status.state == .degraded,
let connection = connections[profileID]
else { return nil }
return (status, connection)
}.sorted { left, right in
if connectionPriority(left.0.state) != connectionPriority(right.0.state) {
return connectionPriority(left.0.state) > connectionPriority(right.0.state)
}
return (left.0.lastSnapshotReceivedAt ?? .distantPast)
> (right.0.lastSnapshotReceivedAt ?? .distantPast)
}
guard let connection = candidates.first?.1 else {
throw CommandTransactionError.hostOffline
}
return try await connection.send(command)
Expand Down Expand Up @@ -1172,7 +1231,8 @@ final class DashboardStore {
hostLabel: agent.originPlatform.shortLabel,
activityAt: Date(timeIntervalSince1970: agent.activityAt / 1_000),
selected: agent.selected,
contextUsedPercent: agent.contextUsedPercent)
contextUsedPercent: agent.contextUsedPercent,
hostConnected: [.ready, .degraded].contains(connectionState(for: agent.host.hostId)))
}
let activeStatuses = Set([
"working", "thinking", "approval", "awaiting-approval", "awaiting-response", "error",
Expand Down
2 changes: 2 additions & 0 deletions ios/CodexDeckMobile/Views/DashboardView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -513,6 +513,8 @@ private struct AgentCard: View {
}
}
.buttonStyle(.plain)
.disabled(hostState == .offline || hostState == .connecting)
.opacity(hostState == .offline || hostState == .connecting ? 0.68 : 1)
.accessibilityHint("Opens this task on \(agent.host.hostName)")
}

Expand Down
21 changes: 17 additions & 4 deletions ios/CodexDeckMobile/Views/MicroDeviceView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -246,28 +246,38 @@ private struct MicroAgentKey: View {

private var agent: RoutedAgent? { placement.agent }
private var reference: AgentReference? { placement.reference }
private var hostState: NodeConnectionState {
agent.map { store.connectionState(for: $0.host.hostId) } ?? .offline
}
private var hostConnected: Bool { hostState == .ready || hostState == .degraded }

var body: some View {
ZStack {
if let agent {
VStack(spacing: 2) {
HStack(spacing: 3) {
if let context = agent.contextUsedPercent, store.showContextRings {
ContextUsageIndicator(percent: context, status: agent.status)
ContextUsageIndicator(
percent: context, status: hostConnected ? agent.status : "offline")
} else {
Circle().fill(CodexTheme.statusColor(agent.status))
Circle().fill(
hostConnected ? CodexTheme.statusColor(agent.status) : CodexTheme.secondary)
.frame(width: 5, height: 5)
}
Spacer(minLength: 0)
Text(agent.originPlatform.shortLabel)
.font(.system(size: 6.5, weight: .black))
.foregroundStyle(hostConnected ? CodexTheme.ink : CodexTheme.red)
}
Spacer(minLength: 0)
Text(agent.title)
.font(.system(size: 7.1, weight: .semibold))
.lineLimit(2)
.multilineTextAlignment(.center)
.foregroundStyle(CodexTheme.ink.opacity(agent.selected ? 0.92 : 0.68))
.foregroundStyle(
hostConnected
? CodexTheme.ink.opacity(agent.selected ? 0.92 : 0.68)
: CodexTheme.secondary)
Spacer(minLength: 0)
}
} else {
Expand All @@ -291,10 +301,13 @@ private struct MicroAgentKey: View {
}
}
.scaleEffect(pressing ? 1.035 : 1)
.opacity(agent != nil && !hostConnected ? 0.62 : 1)
.animation(.smooth(duration: 0.18), value: pressing)
.contentShape(RoundedRectangle(cornerRadius: 15, style: .continuous))
.onTapGesture {
guard let agent, Date().timeIntervalSince(lastLongPressAt) > 0.35 else { return }
guard let agent, hostConnected, Date().timeIntervalSince(lastLongPressAt) > 0.35 else {
return
}
Task { await store.activate(agent) }
}
.onLongPressGesture(
Expand Down
Loading
Loading