Skip to content

Commit 16c3779

Browse files
authored
Fix cross-host task identity and prepare v0.7.0.1 (#7)
* fix: merge new-thread mirrors without titles * fix: harden cross-host thread identity * release: prepare v0.7.0.1 * fix: make release checksums portable
1 parent 0ff3ebb commit 16c3779

16 files changed

Lines changed: 203 additions & 24 deletions

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# Changelog
22

3+
## 0.7.0.1 - 2026-07-22
4+
5+
- 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.
6+
- Remember resolved temporary thread aliases across later task selections and relay reconnects so mirrored tasks remain de-duplicated.
7+
- Preserve the available title and context usage from either host when the rollout owner temporarily publishes incomplete renderer metadata.
8+
- Reserve `Not assigned` for genuinely empty agent slots; assigned titleless threads now use `New chat` while their native title is pending.
9+
- Keep a neutral context ring visible until a new thread publishes its first token-count event.
10+
- Added bidirectional Windows-to-Mac and Mac-to-Windows regression coverage for task identity, title fallback, selection changes, and reconnects.
11+
312
## 0.7.0 - 2026-07-21
413

514
- Added a source-distributed native SwiftUI iPhone companion that merges authenticated Mac and Windows snapshots while leaving the Stream Deck plugin independent.

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -178,6 +178,11 @@ npm run audit:release
178178

179179
`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 ...`.
180180

181+
For a four-component Stream Deck hotfix version, set
182+
`CODEX_DECK_RELEASE_VERSION=0.7.0.1` while running the macOS packager and pass
183+
`-ReleaseVersion 0.7.0.1` to `prepare-release.ps1`. The npm package keeps its
184+
SemVer-compatible prerelease form.
185+
181186
Nothing is published automatically. See [CONTRIBUTING.md](CONTRIBUTING.md).
182187

183188
## Acknowledgements

docs/IOS_INSTALL.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ For a released build, either download **Source code (zip)** from that GitHub
3434
release and extract it, or clone the matching tag in Terminal:
3535

3636
```zsh
37-
git clone --branch v0.7.0 --depth 1 https://github.com/dazer1234/codex-stream-deck.git
37+
git clone --branch v0.7.0.1 --depth 1 https://github.com/dazer1234/codex-stream-deck.git
3838
cd codex-stream-deck
3939
```
4040

docs/RELEASE_0.7.0.1.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
# Codex Deck v0.7.0.1
2+
3+
This hotfix hardens mirrored task identity between Windows and macOS Codex
4+
instances. It does not change the launcher, watcher, iPhone pairing, or native
5+
Codex Micro command surface introduced in v0.7.0.
6+
7+
## Fixes
8+
9+
- De-duplicates a new task while Codex transitions from its temporary renderer
10+
ID to the stable rollout ID visible on the other host.
11+
- Remembers that mapping across later task selections and relay reconnects in
12+
both Windows-to-Mac and Mac-to-Windows directions.
13+
- Uses the title and context usage available from either mirror when one host
14+
temporarily publishes incomplete metadata.
15+
- Shows `New chat` for an assigned titleless thread and reserves `Not assigned`
16+
for a genuinely empty agent slot.
17+
- Keeps a neutral context ring visible until the first token-count event arrives.
18+
19+
## Downloads
20+
21+
- Stream Deck: `com.simeo.codex-deck.streamDeckPlugin`
22+
- Windows launcher: `codex-deck-launcher-windows-v0.7.0.1.zip`
23+
- macOS launcher: `codex-deck-launcher-macos-v0.7.0.1.zip`
24+
- iPhone source: use the Source code archive or clone tag `v0.7.0.1`.
25+
- Checksums: `SHA256SUMS.txt`
26+
27+
Existing v0.7.0 launcher and watcher installations remain compatible. Stream
28+
Deck users only need to install the updated plugin. Reload the Codex Deck
29+
Stream Deck plugin after updating; Codex itself does not need to restart.
30+
31+
Codex Deck is an independent community project and is not made, supported, or
32+
endorsed by OpenAI or Elgato.

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "codex-stream-deck",
3-
"version": "0.7.0",
3+
"version": "0.7.0-hotfix.1",
44
"private": false,
55
"type": "module",
66
"description": "Unofficial Codex Micro bridge for Stream Deck on Windows and macOS, with optional multi-host relay",

scripts/package-macos-release.sh

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
set -euo pipefail
33

44
ROOT="${0:A:h:h}"
5-
VERSION="$(node -p "require('$ROOT/package.json').version")"
5+
PACKAGE_VERSION="$(node -p "require('$ROOT/package.json').version")"
6+
VERSION="${CODEX_DECK_RELEASE_VERSION:-$PACKAGE_VERSION}"
67
OUTPUT="${1:-$ROOT/outputs/codex-deck-launcher-macos-v$VERSION.zip}"
78
mkdir -p "${OUTPUT:h}"
89
rm -f "$OUTPUT"

scripts/prepare-release.ps1

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
1-
param([string]$MacArchivePath)
1+
param(
2+
[string]$MacArchivePath,
3+
[string]$ReleaseVersion
4+
)
25

36
$ErrorActionPreference = 'Stop'
47

@@ -14,7 +17,10 @@ function Get-Sha256Hex([string]$Path) {
1417

1518
$root = [IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
1619
$package = Get-Content -LiteralPath (Join-Path $root 'package.json') -Raw | ConvertFrom-Json
17-
$version = [string]$package.version
20+
$version = if ([string]::IsNullOrWhiteSpace($ReleaseVersion)) { [string]$package.version } else { $ReleaseVersion.Trim() }
21+
if ($version -notmatch '^\d+\.\d+\.\d+(?:\.\d+)?(?:-[0-9A-Za-z.-]+)?$') {
22+
throw "Invalid release version: $version"
23+
}
1824
$output = Join-Path $root "outputs\release-v$version"
1925

2026
Push-Location $root
@@ -42,7 +48,8 @@ try {
4248
}
4349
$artifacts = Get-ChildItem -LiteralPath $output -File | Sort-Object Name
4450
$checksums = @($artifacts | ForEach-Object { "{0} {1}" -f (Get-Sha256Hex $_.FullName), $_.Name })
45-
[IO.File]::WriteAllLines((Join-Path $output 'SHA256SUMS.txt'), $checksums, [Text.UTF8Encoding]::new($false))
51+
$checksumText = ($checksums -join "`n") + "`n"
52+
[IO.File]::WriteAllText((Join-Path $output 'SHA256SUMS.txt'), $checksumText, [Text.UTF8Encoding]::new($false))
4653
Write-Host "Release candidate prepared at: $output"
4754
} finally {
4855
Pop-Location

src/controller.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -415,7 +415,7 @@ export class DeckController {
415415
const unavailableTitle = health.state === "degraded" ? "Signals uncertain"
416416
: health.state === "offline" ? "Host offline"
417417
: health.state === "connecting" ? "Connecting" : "Not assigned";
418-
const title = agent?.title ?? unavailableTitle;
418+
const title = agent?.title ?? (agent?.threadKey && health.state === "ready" ? "New chat" : unavailableTitle);
419419
const status = agent ? visualStatusFromMicro(agent.status) : "empty";
420420
const theme = this.targetSnapshot()?.theme ?? this.localSnapshot?.snapshot.theme ?? "dark";
421421
const hostBadge = agent && this.relayClient ? (agent.host.platform === "darwin" ? "M" : "W") : undefined;

src/relay-protocol.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -48,15 +48,18 @@ export type HostSnapshot = { host: CodexHost; snapshot: MicroSnapshot; observedA
4848

4949
type ActivityRecord = { activityAt: number; signature: string; lastSeenAt: number };
5050
type SessionOwner = { input: HostSnapshot; session: HostSessionPresence };
51+
type TemporaryAliasRecord = { identity: string; lastSeenAt: number };
5152
const MIRROR_STATUS_FRESHNESS_MS = 5_000;
5253
const SESSION_COMPLETION_FALLBACK_MS = 5 * 60_000;
54+
const TEMPORARY_ALIAS_RETENTION_MS = 24 * 60 * 60_000;
5355

5456
export class HostActivityIndex {
5557
private readonly activity = new Map<string, ActivityRecord>();
5658
private readonly acknowledgedCompletions = new Map<string, number>();
59+
private readonly temporaryAliases = new Map<string, TemporaryAliasRecord>();
5760

5861
merge(inputs: HostSnapshot[], now = Date.now(), authoritativeHostId?: string): RoutedAgentSlot[] {
59-
const aliases = temporaryThreadAliases(inputs);
62+
const aliases = temporaryThreadAliases(inputs, this.temporaryAliases, now);
6063
const routed: RoutedAgentSlot[] = [];
6164
for (const input of inputs) {
6265
for (const slot of input.snapshot.slots) {
@@ -334,10 +337,12 @@ function mergeMirrors(
334337
const contextCandidate = candidates.find((candidate) =>
335338
candidate.ownedByHost === true && candidate.contextUsedPercent != null)
336339
?? candidates.find((candidate) => candidate.contextUsedPercent != null);
340+
const titleCandidate = candidates.find((candidate) => normalizedTitle(candidate.title));
337341
return {
338342
...owner,
339343
host: routedOwner,
340344
ownedByHost: sessionOwner ? true : owner.ownedByHost,
345+
title: normalizedTitle(owner.title) ? owner.title : titleCandidate?.title ?? null,
341346
status,
342347
selected: statusCandidates.some((candidate) => candidate.selected),
343348
contextUsedPercent: sessionOwner?.session.contextUsedPercent ?? contextCandidate?.contextUsedPercent,
@@ -406,7 +411,11 @@ function threadIdentity(value: string): string {
406411
return value.match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)?.[0]?.toLowerCase() ?? value;
407412
}
408413

409-
function temporaryThreadAliases(inputs: HostSnapshot[]): Map<string, string> {
414+
function temporaryThreadAliases(
415+
inputs: HostSnapshot[],
416+
remembered: Map<string, TemporaryAliasRecord>,
417+
now: number
418+
): Map<string, string> {
410419
const aliases = new Map<string, string>();
411420
for (const input of inputs) {
412421
const ownedSessions = new Set(
@@ -415,6 +424,20 @@ function temporaryThreadAliases(inputs: HostSnapshot[]): Map<string, string> {
415424

416425
for (const slot of input.snapshot.slots) {
417426
if (!slot.threadKey?.toLowerCase().includes(":client-new-thread:")) continue;
427+
const temporaryKey = aliasKey(input.host, threadIdentity(slot.threadKey));
428+
const prior = remembered.get(temporaryKey);
429+
if (prior && now - prior.lastSeenAt <= TEMPORARY_ALIAS_RETENTION_MS) {
430+
aliases.set(temporaryKey, prior.identity);
431+
prior.lastSeenAt = now;
432+
}
433+
const activeIdentity = input.snapshot.activeThreadKey
434+
? threadIdentity(input.snapshot.activeThreadKey) : null;
435+
if (slot.selected && activeIdentity && ownedSessions.has(activeIdentity) &&
436+
!input.snapshot.activeThreadKey?.toLowerCase().includes(":client-new-thread:")) {
437+
aliases.set(temporaryKey, activeIdentity);
438+
remembered.set(temporaryKey, { identity: activeIdentity, lastSeenAt: now });
439+
continue;
440+
}
418441
const title = normalizedTitle(slot.title);
419442
if (!title) continue;
420443
const matches = new Set<string>();
@@ -427,10 +450,14 @@ function temporaryThreadAliases(inputs: HostSnapshot[]): Map<string, string> {
427450
}
428451
}
429452
if (matches.size !== 1) continue;
430-
aliases.set(
431-
aliasKey(input.host, threadIdentity(slot.threadKey)), [...matches][0]!);
453+
const identity = [...matches][0]!;
454+
aliases.set(temporaryKey, identity);
455+
remembered.set(temporaryKey, { identity, lastSeenAt: now });
432456
}
433457
}
458+
for (const [key, record] of remembered) {
459+
if (now - record.lastSeenAt > TEMPORARY_ALIAS_RETENTION_MS) remembered.delete(key);
460+
}
434461
return aliases;
435462
}
436463

0 commit comments

Comments
 (0)