Skip to content
Draft
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
17 changes: 10 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ It does not replace Codex, scrape its sidebar, or mirror its interface.
| Input | Default action | Scope |
|---|---|---|
| `LB` | Hold to preview recent tasks; release to open one | Codex |
| `L3` | Hold to preview project roots; release to start a task there | Codex |
| `L3` | Hold to preview project roots; release to open a new task there | Codex |
| `RB` | Hold to talk; release to finish dictation | Codex |
| `X` | Stop the managed or frontmost task | Codex |
| Right stick | Proportional vertical scrolling | Codex |
Expand Down Expand Up @@ -67,16 +67,19 @@ and open that exact task.
### Project-based Session Starter

Hold `L3` to freeze recent project roots discovered from real Codex sessions.
Release on a project to start one persistent task with that exact working
directory. Cancel, focus drift, disconnect, or an invalid directory creates
nothing.
Release on a project to invoke Codex Desktop's app-scoped `switchToMode2`
command, then open its native new-session page with that exact working directory
selected. Agent Controller follows Codex's configured shortcut for that command
(default `Control+2`) and therefore requires Accessibility for this step. The
task remains uncreated until you submit its first prompt. Cancel, focus drift,
disconnect, or an invalid directory opens nothing.

### Push to Talk, Stop, and scrolling

`RB` delegates audio capture and transcription to Codex, leaving the result
editable and unsent. `X` stops the exact task last opened through `LB` or
created through `L3`; a bounded current-task fallback is available when no
managed target exists. The right stick sends proportional scroll events only
editable and unsent. `X` stops the exact task last opened through `LB`; a
bounded current-task fallback covers a task started from the `L3` composer or
any other frontmost task. The right stick sends proportional scroll events only
after revalidating the same frontmost Codex window.

## Safety model
Expand Down
16 changes: 9 additions & 7 deletions README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ Agent Controller 是一个原生 macOS 控制层,把实体手柄的输入变
| 输入 | 默认动作 | 生效范围 |
|---|---|---|
| `LB` | 按住查看最近任务,松开后打开所选任务 | Codex |
| `L3` | 按住选择项目目录,松开后在该项目中创建任务 | Codex |
| `L3` | 按住选择项目目录,松开后进入该项目的新任务页面 | Codex |
| `RB` | 按住说话,松开结束语音输入 | Codex |
| `X` | Stop 当前受管任务或前台任务 | Codex |
| 右摇杆 | 按推动幅度纵向滚动 | Codex |
Expand Down Expand Up @@ -64,17 +64,19 @@ flowchart LR
用方向键或左摇杆预览,期间 Codex 不会切换;松开 `LB` 后,它会再次校验并
只打开你选中的那条任务。

### 从项目目录创建任务
### 从项目目录开始新任务

按住 `L3` 会显示从真实 Codex 历史中整理出的近期项目目录。选中项目并松开
后,Agent Controller 会以所选目录作为准确的 working directory,创建一个持久化
任务。按 `B` 取消、焦点变化、手柄断开、目录失效,都会直接放弃,不创建任务。
按住 `L3` 可预览从真实 Codex 历史中整理出的近期项目目录;松开后,Codex 会在
先执行应用内的 `switchToMode2` 命令,再到准确选中的 working directory 打开原生
新任务页面。Agent Controller 会跟随 Codex 为该命令保存的键位(默认
`Control+2`),因此这一步需要 Accessibility 权限。在你提交第一条 prompt 前,
不会创建空任务;按 `B` 取消、焦点变化、手柄断开或目录失效时,页面都不会打开。

### Push to Talk、Stop 与滚动

按住 `RB` 时,录音和转写仍由 Codex 完成;松开后文字留在 Codex 中供你确认,
不会自动发送。`X` 会优先停止最近一次通过 `LB` 打开或通过 `L3` 创建的目标
任务;没有受管目标时,才使用范围受限的当前任务回退方案。右摇杆每次发送
不会自动发送。`X` 会精确停止最近一次通过 `LB` 打开的目标任务;从 `L3`
页面开始的任务及其他前台任务,则使用范围受限的当前任务回退方案。右摇杆每次发送
滚动事件前,都会重新确认前台仍是同一个 Codex 窗口。

## 安全边界
Expand Down
100 changes: 65 additions & 35 deletions Sources/AgentControllerApp/AppModel+ProjectStarter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,11 @@ extension AppModel {
detail: "Session Starter cancelled."
)
case .commitSelection(let path, let frozenContext):
startSession(path: path, frozenContext: frozenContext, monitorEventID: monitorEventID)
openNewSessionPage(
path: path,
frozenContext: frozenContext,
monitorEventID: monitorEventID
)
}
}

Expand All @@ -97,7 +101,7 @@ extension AppModel {
)
}

private func startSession(
private func openNewSessionPage(
path: String,
frozenContext: ApplicationContext,
monitorEventID: UUID?
Expand All @@ -114,12 +118,12 @@ extension AppModel {
return
}
hideSelector()
publishEvent("Starting a new Codex session…")
finishInputMonitorEvent(monitorEventID, status: .routed, detail: "Starting selected project.")
publishEvent("Opening a new Codex session page…")
finishInputMonitorEvent(monitorEventID, status: .routed, detail: "Opening selected project.")
commitTask?.cancel()
let token = commitGate.begin()
commitTask = Task { [weak self] in
await self?.performSessionStart(
await self?.performNewSessionOpen(
project: selectedProject,
frozenContext: frozenContext,
token: token,
Expand All @@ -128,7 +132,7 @@ extension AppModel {
}
}

private func performSessionStart(
private func performNewSessionOpen(
project: CodexProjectSummary,
frozenContext: ApplicationContext,
token: UInt64,
Expand All @@ -146,63 +150,89 @@ extension AppModel {
)
return
}
let startedSession = try await sessionClient.startSession(in: project)
let modeShortcut = try codexModeShortcutResolver.resolve()
try keyboardPulseAdapter.pulse(
modeShortcut,
frozenContext: frozenContext,
targetBundleIdentifier: CodexModeShortcutResolver.contract.targetBundleIdentifier
)
accessibilityAuthorized = true
try await Task.sleep(for: .milliseconds(180))
guard canCommit(token: token, frozenContext: frozenContext) else {
publishEvent("Session created, but Codex focus changed before it could open.")
publishEvent("Cancelled: Codex focus changed while switching modes.")
finishInputMonitorEvent(
monitorEventID,
status: .cancelled,
detail: "Session created; focus changed before opening."
)
refreshSessions()
return
}
do {
try sessionOpener.open(threadID: startedSession.id)
} catch {
guard commitGate.isCurrent(token) else { return }
publishEvent("Session started, but Codex did not open its task link.")
finishInputMonitorEvent(
monitorEventID,
status: .failed,
detail: "Session created; task link was rejected."
detail: "Focus changed while switching to Codex mode."
)
refreshSessions()
return
}
try sessionOpener.openNewSession(projectPath: project.path)
guard !Task.isCancelled, commitGate.isCurrent(token) else { return }
managedStopSession = startedSession
publishEvent("Started a new Codex session in the selected project.")
managedStopSession = nil
publishEvent("Opened Codex's new session page in the selected project.")
finishInputMonitorEvent(
monitorEventID,
status: .completed,
detail: "New project session opened."
detail: "New project session page requested."
)
refreshSessions()
} catch is CancellationError {
} catch {
handleNewSessionOpenError(error, token: token, monitorEventID: monitorEventID)
}
}

private func handleNewSessionOpenError(
_ error: Error,
token: UInt64,
monitorEventID: UUID?
) {
if error is CancellationError {
finishInputMonitorEvent(
monitorEventID,
status: .cancelled,
detail: "Session start cancelled."
)
} catch CodexSessionClientError.projectUnavailable {
guard commitGate.isCurrent(token) else { return }
return
}
guard commitGate.isCurrent(token) else { return }
switch error {
case CodexSessionClientError.projectUnavailable:
publishEvent("Blocked: that Codex project folder is no longer available.")
finishInputMonitorEvent(
monitorEventID,
status: .blocked,
detail: "Selected project is unavailable."
)
refreshSessions()
} catch {
guard commitGate.isCurrent(token) else { return }
publishEvent("Blocked: Codex could not start a session for that project.")
case let pulseError as KeyboardChordPulseError:
synchronizeGlobalCommandState()
handlePulseError(
pulseError,
actionName: "Session Starter",
monitorEventID: monitorEventID
)
case CodexModeShortcutError.commandDisabled:
publishEvent("Blocked: Codex's mode-switch shortcut is disabled.")
finishInputMonitorEvent(
monitorEventID,
status: .blocked,
detail: "Codex mode-switch shortcut is disabled."
)
case CodexModeShortcutError.unsupportedShortcut:
publishEvent("Blocked: Codex's mode-switch shortcut is not supported.")
finishInputMonitorEvent(
monitorEventID,
status: .blocked,
detail: "Codex mode-switch shortcut is unsupported."
)
default:
publishEvent("Blocked: Codex did not open a new task page for that project.")
finishInputMonitorEvent(
monitorEventID,
status: .failed,
detail: "Session start adapter rejected the request."
detail: "New-session link was rejected."
)
refreshSessions()
}
refreshSessions()
}
}
4 changes: 2 additions & 2 deletions Sources/AgentControllerApp/AppModel+Stop.swift
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,7 @@ extension AppModel {
}
isStoppingCodex = true
finishInputMonitorEvent(monitorEventID, status: .routed, detail: "Exact-session Stop routed.")
publishStopEvent("X received. Stopping the exact session opened by LB or L3…")
publishStopEvent("X received. Stopping the exact session opened by LB…")
let stopAdapter = stopAdapter
stopTask = Task { [weak self] in
guard let self else { return }
Expand Down Expand Up @@ -184,7 +184,7 @@ extension AppModel {
guard !Task.isCancelled else { return }
switch result {
case .interrupted:
publishStopEvent("X stopped the exact session opened by LB or L3.")
publishStopEvent("X stopped the exact session opened by LB.")
finishInputMonitorEvent(monitorEventID, status: .completed, detail: "Exact session stopped.")
case .idle:
publishStopEvent("X target was already idle.")
Expand Down
1 change: 1 addition & 0 deletions Sources/AgentControllerApp/AppModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ final class AppModel: ObservableObject {
let sessionOpener = CodexSessionOpener()
let voiceAdapter = CodexDictationShortcutAdapter()
let keyboardPulseAdapter = KeyboardChordPulseAdapter()
let codexModeShortcutResolver = CodexModeShortcutResolver()
let scrollAdapter = CodexScrollAdapter()
let stopAdapter = CodexDesktopStopAdapter()
let mappingStore: ControllerMappingStore
Expand Down
11 changes: 6 additions & 5 deletions Sources/AgentControllerApp/CodexSettingsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ struct CodexSettingsView: View {
MappingSummaryRow(
input: "L3",
title: "Session Starter",
detail: "Choose a project before starting a new task.",
detail: "Choose a project, switch to Codex, then start a new task.",
value: projectCountValue,
actionLabel: "Details",
selected: selectedMapping == .projectStarter,
Expand All @@ -56,7 +56,7 @@ struct CodexSettingsView: View {
MappingSummaryRow(
input: "X",
title: "Stop",
detail: "Stop the current task, or the exact task opened by LB or L3.",
detail: "Stop the current task, or the exact task opened by LB.",
value: stopMappingValue,
actionLabel: "Details",
selected: selectedMapping == .stop,
Expand Down Expand Up @@ -148,7 +148,7 @@ struct CodexSettingsView: View {
LabeledContent("Commit", value: "Release L3")
LabeledContent("Projects", value: "Recent Codex working folders")
HStack {
Text("The project list freezes for each hold. Release starts one new task in the exact selected folder.")
Text("The project list freezes for each hold. Release switches to Codex mode, then opens its new-task page in the exact selected folder.")
.font(.caption)
.foregroundStyle(.secondary)
Spacer()
Expand All @@ -170,7 +170,7 @@ struct CodexSettingsView: View {
.lineLimit(1)
.help(model.managedStopSession?.title ?? "No exact selector target")
}
Text("With no selector target, X stops the current Codex response. After LB opens or L3 starts a task, X stops that exact task.")
Text("With no LB selector target, X stops the current Codex response, including tasks started from the L3 composer.")
.font(.caption)
.foregroundStyle(.secondary)
}
Expand Down Expand Up @@ -231,7 +231,8 @@ struct CodexSettingsView: View {
}

private var projectCountValue: String {
model.recentProjects.isEmpty ? "No projects" : "\(model.recentProjects.count) ready"
if !model.accessibilityAuthorized { return "Needs Accessibility" }
return model.recentProjects.isEmpty ? "No projects" : "\(model.recentProjects.count) ready"
}

private var sessionInventoryValue: String {
Expand Down
17 changes: 0 additions & 17 deletions Sources/AgentControllerMac/CodexAppServerClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ public enum CodexSessionClientError: LocalizedError, Equatable, Sendable {
case invalidResponse
case sessionUnavailable
case projectUnavailable
case sessionStartRejected
case invalidThreadIdentifier
case deepLinkRejected

Expand All @@ -23,8 +22,6 @@ public enum CodexSessionClientError: LocalizedError, Equatable, Sendable {
"That Codex session is no longer available."
case .projectUnavailable:
"That Codex project folder is no longer available."
case .sessionStartRejected:
"Codex did not start a session for that project."
case .invalidThreadIdentifier:
"Codex returned an invalid session identifier."
case .deepLinkRejected:
Expand Down Expand Up @@ -70,20 +67,6 @@ public struct CodexAppServerClient: Sendable {
return CodexInventorySnapshot(sessions: sessions, projects: projects)
}

public func startSession(in project: CodexProjectSummary) async throws -> CodexSessionSummary {
guard let normalizedPath = Self.normalizedProjectPath(project.path),
normalizedPath == project.path,
Self.isAvailableProjectDirectory(normalizedPath) else {
throw CodexSessionClientError.projectUnavailable
}
let response = try await request(.start(cwd: normalizedPath))
do {
return try Self.decodeStartedSession(from: response, expectedCwd: normalizedPath)
} catch CodexSessionClientError.sessionUnavailable {
throw CodexSessionClientError.sessionStartRejected
}
}

public func sessionExists(_ threadID: String) async throws -> Bool {
guard Self.isValidThreadID(threadID) else {
throw CodexSessionClientError.invalidThreadIdentifier
Expand Down
37 changes: 0 additions & 37 deletions Sources/AgentControllerMac/CodexAppServerResponseDecoder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -56,35 +56,6 @@ extension CodexAppServerClient {
return projects
}

static func decodeStartedSession(
from response: Data,
expectedCwd: String
) throws -> CodexSessionSummary {
let result = try resultObject(from: response)
guard let responseCwd = result["cwd"] as? String,
normalizedProjectPath(responseCwd) == expectedCwd,
let thread = result["thread"] as? [String: Any],
let threadID = thread["id"] as? String,
let ephemeral = thread["ephemeral"] as? Bool,
!ephemeral,
isValidThreadID(threadID),
let threadCwd = thread["cwd"] as? String,
normalizedProjectPath(threadCwd) == expectedCwd else {
throw CodexSessionClientError.invalidResponse
}
return CodexSessionSummary(
id: threadID,
title: displayTitle(
name: thread["name"] as? String,
preview: thread["preview"] as? String ?? ""
),
project: projectName(for: expectedCwd),
workingDirectory: expectedCwd,
updatedAt: threadDate(thread) ?? Date(),
status: sessionStatus(thread["status"] as? [String: Any] ?? [:])
)
}

static func decodeThreadID(from response: Data) throws -> String {
let result = try resultObject(from: response)
guard let thread = result["thread"] as? [String: Any],
Expand Down Expand Up @@ -118,14 +89,6 @@ extension CodexAppServerClient {
return normalized == "/" ? nil : normalized
}

static func startRequestParams(cwd: String) -> [String: Any] {
CodexAppServerRequest.start(cwd: cwd).params
}

static var startRequestMethod: String {
CodexAppServerRequest.start(cwd: "/").method
}

static func isAvailableProjectDirectory(_ path: String) -> Bool {
var isDirectory: ObjCBool = false
return FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
Expand Down
Loading