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
9 changes: 9 additions & 0 deletions docs/adr/0012-installation-runs-at-first-launch.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,12 @@ At the time of writing there is one item, `claude_hooks.py`. The Codex login `La
**There is no uninstall in the menu bar**, because the panel is view-only in v1.0. Uninstall is `bridge-install uninstall`, a console script that lands in the bundle's `engine/bin/` beside `bridgectl` by the same pipeline rule that needs no list of script names.

**A test may not reach the machine it is running on, and one line of design was not enough to hold that (#83).** Two drafts of the Codex item loaded a real login job into the author's own launchd — the second started the real shared Codex daemon — because `Launchd` and `home` had defaults that resolved to the real ones and a test that omitted them passed. Defaults were removed and `bridge-install`'s test-only keyword arguments grew to five, but the guard that actually holds is in `tests/conftest.py`: the real `launchctl` runner is taken away from the whole suite, so a test that forgets fails loudly instead of silently changing the machine. Every item added to this boundary that runs a subprocess belongs behind that same fixture.


## Upgrade exception — #383 (2026-09-22)

The app-owned upgrade module may stop the identified shared login job after
validating a user-installed newer candidate. Reconcile and uninstall keep their
non-stopping behavior, and installation still imports no adapter or Bridge Core.
The direct-switch, recovery, and storage rules live in
[ADR 0022's upgrade amendment](0022-the-shared-app-server-is-the-users-own-codex.md#upgrade-exception--383-2026-09-22).
30 changes: 30 additions & 0 deletions docs/adr/0022-the-shared-app-server-is-the-users-own-codex.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,3 +117,33 @@ Stated rather than implied, because this ADR is written on measurements and thes
3. **Homebrew.** The `PATH` rule is argued for a native binary, not measured against one.

And one for whoever re-runs the acceptance harness's codex lane: **`approvalPolicy: "on-request"` with a `readOnly` sandbox no longer forces an approval request** on 0.153.4. #82's gate prompt produced none twice (90 s and 120 s waits) — the model answered in prose and never called the shell. `"untrusted"` produces the request immediately. A gate written on the old assumption **hangs rather than fails**.


## Upgrade exception — #383 (2026-09-22)

The app checks for a user-installed Codex upgrade at startup and on filesystem
notifications. It validates the candidate on an isolated socket, then directly
switches only its identified shared login job. Active turns may be interrupted;
neither task completion nor approval/input waits delay the switch. The socket
peer must belong to the launchd job's process group. That group is stopped,
launchd's old definition is removed, and the group must be gone before loading
the replacement. `bootout` returning zero alone does not prove process exit.

The app-owned update module composes installation and existing Codex discovery;
installation still imports no adapter or Bridge Core. Reconcile and uninstall
retain their previous non-stopping behavior. The updater reuses terminal and
VoiceCoding reconnect, reads the original live Session identities back, and
never resumes historical Sessions or replays commands. Connection recovery does
not mean an interrupted task continued. Closed terminals cease to be required.

Using one executable path does not imply one running version: npm replaces
files underneath a still-running process. The server's initialize userAgent
contains its build version (the originator prefix can vary by client). The
update check compares that version with the runnable candidate, without adding
a version pin to the agent adapter.

Only an old executable that remains present and passes validation can be used
for recovery. No version is copied, cached, or downloaded. Failed attempts are
remembered only for the app's lifetime to prevent repeated interruption from
duplicate file events. Shutdown stops listening and discards queued checks;
an already-started bounded switch finishes before the app exits.
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "gpt-voicecoding"
version = "1.0.5"
version = "1.0.6"
description = "Voice-controlling terminal coding agents through a realtime voice call."
readme = "README.md"
license = { file = "LICENSE" }
Expand Down
21 changes: 21 additions & 0 deletions shell/Sources/GPTVoiceCodingShell/ShellModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ final class ShellModel {
private var telegramRequest: Task<TelegramBindingReading?, Never>?
private var telegramCancellation: Task<Void, Never>?
private let runCommand: @Sendable (EngineCommand) async -> InstallationReport
private let runCodexUpdate: CodexUpdate.Check
private var codexUpdate: CodexUpdate?
private(set) var codexCheck: CodexCheck = .checking
private(set) var codexCheckFailure: String?
private(set) var installationReport: InstallationReport?
Expand Down Expand Up @@ -378,6 +380,17 @@ final class ShellModel {
runCommand ?? { command in
await InstallationRunner(report: { pathOutcomes.record($0) }).run(command)
}
self.runCodexUpdate = { attempted in
do {
let command = try EngineCommand.resolveCodexUpdate(
resources: Bundle.main.resourceURL, attempted: attempted)
if let runCommand { return await runCommand(command) }
return await InstallationRunner(report: { pathOutcomes.record($0) }).run(
command, deadline: CodexUpdate.deadline, separateOutput: true)
} catch {
return InstallationReport(ok: false, lines: ["\(error)"])
}
}
self.wallNow = wallNow
self.messageNow = wallNow()
self.now = now
Expand Down Expand Up @@ -507,6 +520,13 @@ final class ShellModel {
// launchd hands an app opened from Finder (#272, ADR 0022).
installationReport = await runCommand(command)
installationFailure = installationReport?.failure
guard !Task.isCancelled && !stopping else { return }
if codexUpdate == nil {
codexUpdate = CodexUpdate(
check: runCodexUpdate,
report: { [weak self] failure in self?.installationFailure = failure })
}
codexUpdate?.checkForUpdate()
}

func checkCodex() async {
Expand Down Expand Up @@ -883,6 +903,7 @@ final class ShellModel {
preparation?.cancel()
credentialStartRecovery.cancel()
stopCredentialObservation()
await codexUpdate?.stop()
await supervisor.shutDown()
}

Expand Down
179 changes: 179 additions & 0 deletions shell/Sources/ShellCore/CodexUpdate.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import CoreServices
import Foundation

/// The app owns this event source and one serialized check. Python owns all
/// update decisions; file events only request another reading of the facts.
@MainActor
public final class CodexUpdate {
public static let deadline = 4 * Installation.deadline
public typealias Check = @Sendable (String) async -> InstallationReport

private struct Result: Decodable {
var status: String
var failure: String
var attempted: String
var watchPaths: [String]
enum CodingKeys: String, CodingKey {
case status, failure, attempted
case watchPaths = "watch_paths"
}
}

private let check: Check
private let report: (String?) -> Void
private var observer: InstallationChanges?
private var work: Task<Void, Never>?
private var pending = false
private var stopped = false
private var attempted = ""
private var lastFailure: String?
private var watchedPaths: [String] = []

public init(check: @escaping Check, report: @escaping (String?) -> Void) {
self.check = check
self.report = report
}

/// Startup and filesystem notifications enter through the same method.
public func checkForUpdate() {
guard !stopped else { return }
pending = true
guard work == nil else { return }
work = Task { [weak self] in
guard let self else { return }
while pending && !stopped {
pending = false
let response = await check(attempted)
guard !stopped else { break }
do {
let output = (response.standardOutput ?? response.lines).joined(separator: "\n")
let result = try JSONDecoder().decode(Result.self, from: Data(output.utf8))
attempted = result.attempted
if watchedPaths != result.watchPaths {
watchedPaths = result.watchPaths
try watch()
// Close the startup/retarget registration gap once. This
// is event reconciliation, not a periodic timer.
pending = true
}
if result.status == "failed" {
showFailure(result.failure)
} else if result.status == "updated" {
showFailure(nil)
}
} catch {
showFailure(response.failure ?? "Codex update check failed: \(error)")
}
}
work = nil
}
}

/// Cancel queued checks and listening, but finish an already-started switch
/// before the app exits. Its runner and Python operations both have bounds.
public func stop() async {
stopped = true
pending = false
observer?.cancel()
observer = nil
await work?.value
work = nil
}

private func watch() throws {
observer?.cancel()
observer = try InstallationChanges(paths: watchedPaths) { [weak self] in
Task { @MainActor in
guard let self, !self.stopped else { return }
do { try self.watch() } catch { self.showFailure("Codex watch failed: \(error)") }
self.checkForUpdate()
}
}
}

private func showFailure(_ failure: String?) {
guard failure != lastFailure else { return }
lastFailure = failure
report(failure)
}
}

/// FSEvents follows package contents as well as directory replacement. A watch
/// on only the npm shim misses a replaced native dependency below the package.
private final class InstallationChanges: @unchecked Sendable {
private final class Callback: @unchecked Sendable {
let paths: [String]
let changed: @Sendable () -> Void

init(paths: [String], changed: @escaping @Sendable () -> Void) {
self.paths = paths
self.changed = changed
}

func receive(_ events: [String]) {
let normalized = events.flatMap { event in
[event, URL(fileURLWithPath: event).resolvingSymlinksInPath().path]
}
if normalized.contains(where: { event in
paths.contains { path in
event == path || event.hasPrefix(path + "/") || path.hasPrefix(event + "/")
}
}) {
changed()
}
}
}

private let callback: Callback
private var stream: FSEventStreamRef?

init(paths: [String], changed: @escaping @Sendable () -> Void) throws {
let normalized = Array(
Set(
paths.flatMap { path in
let url = URL(fileURLWithPath: path).standardizedFileURL
return [url.path, url.resolvingSymlinksInPath().path]
}))
callback = Callback(paths: normalized, changed: changed)
guard !paths.isEmpty else { return }
let roots = Set(
normalized.map { path in
var parent = URL(fileURLWithPath: path).deletingLastPathComponent()
while !FileManager.default.fileExists(atPath: parent.path) && parent.path != "/" {
parent.deleteLastPathComponent()
}
return parent.path
})
var context = FSEventStreamContext(
version: 0, info: Unmanaged.passUnretained(callback).toOpaque(),
retain: nil, release: nil, copyDescription: nil)
stream = FSEventStreamCreate(
nil,
{ _, info, _, rawPaths, _, _ in
guard let info else { return }
let callback = Unmanaged<Callback>.fromOpaque(info).takeUnretainedValue()
let events = unsafeBitCast(rawPaths, to: NSArray.self) as? [String] ?? []
callback.receive(events)
}, &context, Array(roots) as CFArray,
FSEventStreamEventId(kFSEventStreamEventIdSinceNow), 0.2,
FSEventStreamCreateFlags(
kFSEventStreamCreateFlagUseCFTypes | kFSEventStreamCreateFlagFileEvents
| kFSEventStreamCreateFlagWatchRoot))
guard let stream else { throw CocoaError(.fileReadUnknown) }
FSEventStreamSetDispatchQueue(stream, .main)
guard FSEventStreamStart(stream) else {
cancel()
throw CocoaError(.fileReadUnknown)
}
}

func cancel() {
guard let stream else { return }
FSEventStreamStop(stream)
FSEventStreamInvalidate(stream)
FSEventStreamRelease(stream)
self.stream = nil
}

deinit { cancel() }
}
10 changes: 10 additions & 0 deletions shell/Sources/ShellCore/EngineCommand.swift
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,16 @@ public struct EngineCommand: Equatable, Sendable {
searchPath: searchPath)
}

public static func resolveCodexUpdate(
resources: URL?, attempted: String,
environment: [String: String] = ProcessInfo.processInfo.environment
) throws -> EngineCommand {
try resolve(
resources: resources,
arguments: ["-m", "gpt_voicecoding.codex_update", "--attempted", attempted],
environment: environment, searchPath: nil)
}

private static func resolve(
resources: URL?,
arguments: [String],
Expand Down
Loading
Loading