Compare commits

...
Author SHA1 Message Date
lawrencecchen 42ce1e5200 Add offline agent notes CLI 2026-06-19 22:40:27 -07:00
5 changed files with 538 additions and 1 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
# cmux-owned Swift file length budget.
# Format: max_lines<TAB>relative path
# Reduce counts as files shrink. CI fails if tracked files exceed this budget.
34337 CLI/cmux.swift
34354 CLI/cmux.swift
17606 Sources/AppDelegate.swift
16038 Sources/ContentView.swift
14100 Sources/TerminalController.swift
1 # cmux-owned Swift file length budget.
2 # Format: max_lines<TAB>relative path
3 # Reduce counts as files shrink. CI fails if tracked files exceed this budget.
4 34337 34354
5 17606
6 16038
7 14100
+382
View File
@@ -0,0 +1,382 @@
import Foundation
struct OfflineAgentNoteRecord: Codable, Equatable {
var id: String
var text: String
var agent: String?
var cwd: String?
var workspaceId: String?
var surfaceId: String?
var createdAt: TimeInterval
var flushedAt: TimeInterval?
}
struct OfflineAgentNotesState: Codable, Equatable {
var version: Int = 1
var notes: [OfflineAgentNoteRecord] = []
}
final class OfflineAgentNotesStore {
private static let defaultStorePath = "~/.cmuxterm/offline-agent-notes.json"
let storeURL: URL
private let fileManager: FileManager
private let decoder = JSONDecoder()
private let encoder: JSONEncoder
init(
environment: [String: String] = ProcessInfo.processInfo.environment,
fileManager: FileManager = .default
) {
if let overridePath = Self.nonEmpty(environment["CMUX_OFFLINE_AGENT_NOTES_PATH"]) {
self.storeURL = URL(fileURLWithPath: NSString(string: overridePath).expandingTildeInPath)
} else {
self.storeURL = URL(fileURLWithPath: NSString(string: Self.defaultStorePath).expandingTildeInPath)
}
self.fileManager = fileManager
self.encoder = JSONEncoder()
self.encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
}
func add(
text: String,
agent: String?,
cwd: String?,
workspaceId: String?,
surfaceId: String?,
now: Date = Date()
) throws -> OfflineAgentNoteRecord {
let note = OfflineAgentNoteRecord(
id: UUID().uuidString.lowercased(),
text: text,
agent: Self.nonEmpty(agent),
cwd: Self.nonEmpty(cwd),
workspaceId: Self.nonEmpty(workspaceId),
surfaceId: Self.nonEmpty(surfaceId),
createdAt: now.timeIntervalSince1970,
flushedAt: nil
)
var state = try load()
state.notes.append(note)
try save(state)
return note
}
func notes(includeFlushed: Bool = false, agent: String? = nil) throws -> [OfflineAgentNoteRecord] {
let normalizedAgent = Self.nonEmpty(agent)?.lowercased()
return try load().notes
.filter { includeFlushed || $0.flushedAt == nil }
.filter { note in
guard let normalizedAgent else { return true }
return note.agent?.lowercased() == normalizedAgent
}
.sorted { lhs, rhs in
if lhs.createdAt == rhs.createdAt {
return lhs.id < rhs.id
}
return lhs.createdAt < rhs.createdAt
}
}
func clear(includeFlushed: Bool = false, agent: String? = nil) throws -> Int {
let normalizedAgent = Self.nonEmpty(agent)?.lowercased()
var state = try load()
let before = state.notes.count
state.notes.removeAll { note in
if !includeFlushed, note.flushedAt != nil {
return false
}
if let normalizedAgent {
return note.agent?.lowercased() == normalizedAgent
}
return true
}
try save(state)
return before - state.notes.count
}
func markFlushed(ids: Set<String>, now: Date = Date()) throws {
guard !ids.isEmpty else { return }
var state = try load()
for index in state.notes.indices where ids.contains(state.notes[index].id) {
state.notes[index].flushedAt = now.timeIntervalSince1970
}
try save(state)
}
private func load() throws -> OfflineAgentNotesState {
guard fileManager.fileExists(atPath: storeURL.path) else {
return OfflineAgentNotesState()
}
let data = try Data(contentsOf: storeURL)
return try decoder.decode(OfflineAgentNotesState.self, from: data)
}
private func save(_ state: OfflineAgentNotesState) throws {
let directory = storeURL.deletingLastPathComponent()
try fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
try? fileManager.setAttributes([.posixPermissions: 0o700], ofItemAtPath: directory.path)
let data = try encoder.encode(state)
try data.write(to: storeURL, options: .atomic)
try? fileManager.setAttributes([.posixPermissions: 0o600], ofItemAtPath: storeURL.path)
}
private static func nonEmpty(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
}
extension CMUXCLI {
func offlineNotesUsageHelp() -> String {
"""
Usage: cmux notes <add|list|flush|clear|path> [options]
Store notes while cmux or the network is unavailable, then submit them
to an agent terminal later.
Subcommands:
add [--agent <name>] [--cwd <path>] [--workspace <id>] [--surface <id>] <text>
list [--agent <name>] [--all]
flush [--agent <name>] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>] [--all] [--dry-run]
clear [--agent <name>] [--all]
path
Examples:
cmux notes add --agent codex "After online, ask an agent to tighten the release notes"
cmux notes list
cmux notes flush --surface surface:2
"""
}
func offlineNotesCommandDoesNotNeedSocket(_ commandArgs: [String]) -> Bool {
let subcommand = commandArgs.first?.lowercased() ?? "list"
return subcommand != "flush"
}
func runOfflineNotesCommandWithoutSocket(commandArgs: [String], jsonOutput: Bool) throws {
try runOfflineNotesCommand(
commandArgs: commandArgs,
client: nil,
jsonOutput: jsonOutput,
idFormat: .refs,
windowOverride: nil
)
}
func runOfflineNotesCommand(
commandArgs: [String],
client: SocketClient?,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let subcommand = commandArgs.first?.lowercased() ?? "list"
let args = commandArgs.isEmpty ? [] : Array(commandArgs.dropFirst())
let store = OfflineAgentNotesStore()
switch subcommand {
case "add":
try runOfflineNotesAdd(args: args, store: store, jsonOutput: jsonOutput)
case "list":
try runOfflineNotesList(args: args, store: store, jsonOutput: jsonOutput)
case "clear":
try runOfflineNotesClear(args: args, store: store, jsonOutput: jsonOutput)
case "path":
guard args.isEmpty else {
throw CLIError(message: "notes path does not accept arguments")
}
if jsonOutput {
print(jsonString(["path": store.storeURL.path]))
} else {
print(store.storeURL.path)
}
case "flush":
guard let client else {
throw CLIError(message: "notes flush requires cmux to be running")
}
try runOfflineNotesFlush(
args: args,
store: store,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowOverride
)
default:
throw CLIError(message: "notes: unknown subcommand '\(subcommand)'")
}
}
private func runOfflineNotesAdd(
args: [String],
store: OfflineAgentNotesStore,
jsonOutput: Bool
) throws {
let (agent, rem0) = parseOption(args, name: "--agent")
let (cwd, rem1) = parseOption(rem0, name: "--cwd")
let (workspace, rem2) = parseOption(rem1, name: "--workspace")
let (surface, rem3) = parseOption(rem2, name: "--surface")
let trailing = rem3.dropFirst(rem3.first == "--" ? 1 : 0)
let rawText = trailing.joined(separator: " ")
let text = rawText.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else {
throw CLIError(message: "notes add requires text")
}
let environment = ProcessInfo.processInfo.environment
let note = try store.add(
text: text,
agent: agent ?? environment["CMUX_AGENT_LAUNCH_KIND"],
cwd: cwd ?? environment["PWD"],
workspaceId: workspace ?? environment["CMUX_WORKSPACE_ID"],
surfaceId: surface ?? environment["CMUX_SURFACE_ID"]
)
let pendingCount = try store.notes().count
if jsonOutput {
print(jsonString(["note": offlineNotePayload(note), "pending_count": pendingCount]))
} else {
print("OK note=\(shortOfflineNoteID(note.id)) pending=\(pendingCount)")
}
}
private func runOfflineNotesList(
args: [String],
store: OfflineAgentNotesStore,
jsonOutput: Bool
) throws {
let (agent, rem0) = parseOption(args, name: "--agent")
let includeFlushed = rem0.contains("--all")
let trailing = rem0.filter { $0 != "--all" }
if let unknown = trailing.first {
throw CLIError(message: "notes list: unexpected argument '\(unknown)'")
}
let notes = try store.notes(includeFlushed: includeFlushed, agent: agent)
if jsonOutput {
print(jsonString(["notes": notes.map(offlineNotePayload)]))
return
}
if notes.isEmpty {
print(includeFlushed ? "No notes" : "No pending notes")
return
}
for note in notes {
let status = note.flushedAt == nil ? "pending" : "flushed"
let agent = note.agent ?? "agent"
print("\(shortOfflineNoteID(note.id)) \(status) \(agent) \(note.text)")
}
}
private func runOfflineNotesClear(
args: [String],
store: OfflineAgentNotesStore,
jsonOutput: Bool
) throws {
let (agent, rem0) = parseOption(args, name: "--agent")
let includeFlushed = rem0.contains("--all")
let trailing = rem0.filter { $0 != "--all" }
if let unknown = trailing.first {
throw CLIError(message: "notes clear: unexpected argument '\(unknown)'")
}
let count = try store.clear(includeFlushed: includeFlushed, agent: agent)
if jsonOutput {
print(jsonString(["cleared": count]))
} else {
print("OK cleared=\(count)")
}
}
private func runOfflineNotesFlush(
args: [String],
store: OfflineAgentNotesStore,
client: SocketClient,
jsonOutput: Bool,
idFormat: CLIIDFormat,
windowOverride: String?
) throws {
let (agent, rem0) = parseOption(args, name: "--agent")
let (workspace, rem1) = parseOption(rem0, name: "--workspace")
let (surface, rem2) = parseOption(rem1, name: "--surface")
let (windowOpt, rem3) = parseOption(rem2, name: "--window")
let includeFlushed = rem3.contains("--all")
let dryRun = rem3.contains("--dry-run")
let trailing = rem3.filter { $0 != "--all" && $0 != "--dry-run" }
if let unknown = trailing.first {
throw CLIError(message: "notes flush: unexpected argument '\(unknown)'")
}
let notes = try store.notes(includeFlushed: includeFlushed, agent: agent)
guard !notes.isEmpty else {
if jsonOutput {
print(jsonString(["flushed": 0, "notes": []]))
} else {
print(includeFlushed ? "No notes" : "No pending notes")
}
return
}
let text = offlineNotesPrompt(notes: notes)
if !dryRun {
var params: [String: Any] = ["text": text]
let windowRaw = windowOpt ?? windowOverride
let workspaceArg = workspace ?? (windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_WORKSPACE_ID"] : nil)
let surfaceArg = surface ?? (workspace == nil && windowRaw == nil ? ProcessInfo.processInfo.environment["CMUX_SURFACE_ID"] : nil)
let winId = try normalizeWindowHandle(windowRaw, client: client)
if let winId { params["window_id"] = winId }
let wsId = try normalizeWorkspaceHandle(workspaceArg, client: client, windowHandle: winId)
if let wsId { params["workspace_id"] = wsId }
let sfId = try normalizeSurfaceHandle(surfaceArg, client: client, workspaceHandle: wsId, windowHandle: winId)
if let sfId { params["surface_id"] = sfId }
_ = try client.sendV2(method: "surface.send_text", params: params)
try store.markFlushed(ids: Set(notes.map(\.id)))
}
if jsonOutput {
print(jsonString([
"flushed": dryRun ? 0 : notes.count,
"dry_run": dryRun,
"notes": notes.map(offlineNotePayload),
"text": text,
]))
} else if dryRun {
print(text)
} else {
print("OK flushed=\(notes.count)")
}
}
private func offlineNotesPrompt(notes: [OfflineAgentNoteRecord]) -> String {
var lines = [
"Offline cmux notes queued while you were away:",
"",
]
for note in notes {
lines.append("- [\(shortOfflineNoteID(note.id))] \(note.text)")
}
lines.append("")
lines.append("Please turn these into concrete next actions and start on the highest-impact one.")
return lines.joined(separator: "\n") + "\r"
}
private func offlineNotePayload(_ note: OfflineAgentNoteRecord) -> [String: Any] {
[
"id": note.id,
"text": note.text,
"agent": note.agent ?? NSNull(),
"cwd": note.cwd ?? NSNull(),
"workspace_id": note.workspaceId ?? NSNull(),
"surface_id": note.surfaceId ?? NSNull(),
"created_at": note.createdAt,
"flushed_at": note.flushedAt ?? NSNull(),
]
}
private func shortOfflineNoteID(_ id: String) -> String {
String(id.prefix(8))
}
}
+19
View File
@@ -3172,6 +3172,11 @@ struct CMUXCLI {
if command == "__sigpipe-stdin-pipe-probe" { try runSIGPIPEStdinPipeProbe(); return }
if command == "__sigpipe-inspect" { try runSIGPIPEInspect(commandArgs: commandArgs); return }
if command == "diff-viewer-server" { try runDiffViewerServerCommand(commandArgs: commandArgs); return }
if (command == "notes" || command == "note"),
offlineNotesCommandDoesNotNeedSocket(commandArgs) {
try runOfflineNotesCommandWithoutSocket(commandArgs: commandArgs, jsonOutput: jsonOutput)
return
}
if command == "settings",
settingsCommandDoesNotNeedSocket(commandArgs) {
@@ -4162,6 +4167,15 @@ struct CMUXCLI {
case "memory":
try runMemoryCommand(commandArgs: commandArgs, client: client, jsonOutput: jsonOutput, idFormat: idFormat)
case "notes", "note":
try runOfflineNotesCommand(
commandArgs: commandArgs,
client: client,
jsonOutput: jsonOutput,
idFormat: idFormat,
windowOverride: windowId
)
case "focus-pane":
let workspaceArg = workspaceFromArgsOrEnv(commandArgs, windowOverride: windowId)
guard let paneRaw = optionValue(commandArgs, name: "--pane") ?? commandArgs.first else {
@@ -5292,6 +5306,8 @@ struct CMUXCLI {
"new-window",
"new-workspace",
"next-window",
"note",
"notes",
"notify",
"omc",
"omo",
@@ -16020,6 +16036,8 @@ struct CMUXCLI {
cmux markdown open ./docs/design.md --workspace 0
cmux markdown open plan.md --direction down
"""
case "notes", "note":
return offlineNotesUsageHelp()
default:
return nil
}
@@ -34166,6 +34184,7 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) {
tree [--all] [--workspace <id|ref|index>] [--window <id|ref|index>]
top [--all] [--workspace <id|ref|index>] [--window <id|ref|index>] [--processes] [--sort <cpu|mem|proc>] [--flat] [--format <tree|tsv>]
memory [--all] [--workspace <id|ref|index>] [--groups <count>]
notes <add|list|flush|clear|path> [--agent <name>] [--workspace <id|ref|index>] [--surface <id|ref|index>] [--window <id|ref|index>]
focus-pane --pane <id|ref|index> [--workspace <id|ref|index>] [--window <id|ref|index>]
new-pane [--type <terminal|browser>] [--direction <left|right|up|down>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] [--focus <true|false>]
new-surface [--type <terminal|browser|agent-session>] [--pane <id|ref|index>] [--workspace <id|ref|index>] [--window <id|ref|index>] [--url <url>] [--provider <codex|claude|opencode>] [--renderer <react|solid>] [--focus <true|false>]
+8
View File
@@ -214,6 +214,7 @@
B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */; };
B9000071A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */; };
D7AB0000000000000000000D /* CMUXCLI+MoveTabToNewWorkspace.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */; };
C0DE0FF10000000000000003 /* CMUXCLI+OfflineAgentNotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE0FF10000000000000002 /* CMUXCLI+OfflineAgentNotes.swift */; };
B9000072A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000073A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift */; };
B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */; };
REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */ = {isa = PBXBuildFile; fileRef = REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */; };
@@ -263,6 +264,7 @@
EFB18E3C0000000000000001 /* CMUXMobileCore in Frameworks */ = {isa = PBXBuildFile; productRef = EFB18E3B3099DFE2ECA3C263 /* CMUXMobileCore */; };
CA1F0A01CA1F0A01CA1F0A01 /* CmuxModalAlertPresentation.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */; };
E3B7A30000000000000000D3 /* CmuxNotifications in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000D2 /* CmuxNotifications */; };
C0DEF6480000000000000001 /* CMUXOfflineAgentNotesCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DEF6480000000000000002 /* CMUXOfflineAgentNotesCommandTests.swift */; };
C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */; };
E3B7A30000000000000000F3 /* CmuxPanes in Frameworks */ = {isa = PBXBuildFile; productRef = E3B7A30000000000000000F2 /* CmuxPanes */; };
A5C0DE0000000000000000A3 /* CMUXProjectModel in Frameworks */ = {isa = PBXBuildFile; productRef = A5C0DE0000000000000000A2 /* CMUXProjectModel */; };
@@ -1272,6 +1274,7 @@
B9000053A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+InstallPreview.swift"; sourceTree = "<group>"; };
B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Memory.swift"; sourceTree = "<group>"; };
D7AB0000000000000000000E /* CMUXCLI+MoveTabToNewWorkspace.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+MoveTabToNewWorkspace.swift"; sourceTree = "<group>"; };
C0DE0FF10000000000000002 /* CMUXCLI+OfflineAgentNotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+OfflineAgentNotes.swift"; sourceTree = "<group>"; };
B9000073A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+OmpExtension.swift"; sourceTree = "<group>"; };
B9000055A1B2C3D4E5F60719 /* CMUXCLI+Process.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Process.swift"; sourceTree = "<group>"; };
REE0CA0000000000000000C1 /* CMUXCLI+Remotes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Remotes.swift"; sourceTree = "<group>"; };
@@ -1306,6 +1309,7 @@
2F0C07000000000000000001 /* CmuxMainWindow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = App/CmuxMainWindow.swift; sourceTree = "<group>"; };
D36090010000000000000006 /* CmuxMainWindowConstrainFrameTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxMainWindowConstrainFrameTests.swift; sourceTree = "<group>"; };
CA1F0A02CA1F0A02CA1F0A02 /* CmuxModalAlertPresentation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxModalAlertPresentation.swift; sourceTree = "<group>"; };
C0DEF6480000000000000002 /* CMUXOfflineAgentNotesCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOfflineAgentNotesCommandTests.swift; sourceTree = "<group>"; };
C0DE31390000000000000102 /* CMUXOpenCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CMUXOpenCommandTests.swift; sourceTree = "<group>"; };
A9F100000000000000000005 /* CmuxRuntimeDebugCapture.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCapture.swift; sourceTree = "<group>"; };
A9F100000000000000000006 /* CmuxRuntimeDebugCaptureConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxRuntimeDebugCaptureConfiguration.swift; sourceTree = "<group>"; };
@@ -2857,6 +2861,7 @@
5257257034CA4729B1211169 /* CMUXCLI+AutoNamingSummarizers.swift */,
B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */,
C0D3F1F00000000000000102 /* CMUXCLI+CodexFireAndForgetHooks.swift */,
C0DE0FF10000000000000002 /* CMUXCLI+OfflineAgentNotes.swift */,
B9000073A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift */,
B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */,
B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */,
@@ -3114,6 +3119,7 @@
A5D4120AA1B2C3D4E5F60718 /* CLIRovoDevHookPersistenceTests.swift */,
A5D4120CA1B2C3D4E5F60718 /* CLILegacyHookAliasTests.swift */,
A5D4120EA1B2C3D4E5F60718 /* CLIAuthAliasTests.swift */,
C0DEF6480000000000000002 /* CMUXOfflineAgentNotesCommandTests.swift */,
C0DE35530000000000000102 /* BundledCLILinkageTests.swift */,
C37800000000000000000002 /* VMSSHCommandTests.swift */,
0A0F00550000000000000002 /* OmpSupportTests.swift */,
@@ -4163,6 +4169,7 @@
B9000052A1B2C3D4E5F60719 /* CMUXCLI+InstallPreview.swift in Sources */,
B9000071A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift in Sources */,
D7AB0000000000000000000D /* CMUXCLI+MoveTabToNewWorkspace.swift in Sources */,
C0DE0FF10000000000000003 /* CMUXCLI+OfflineAgentNotes.swift in Sources */,
B9000072A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift in Sources */,
B9000054A1B2C3D4E5F60719 /* CMUXCLI+Process.swift in Sources */,
REE0CA0000000000000000C2 /* CMUXCLI+Remotes.swift in Sources */,
@@ -4306,6 +4313,7 @@
C1A2B3C4D5E6F70800000001 /* CmuxConfigTests.swift in Sources */,
E7E000000000000000000003 /* CmuxEventBusTests.swift in Sources */,
D36090010000000000000005 /* CmuxMainWindowConstrainFrameTests.swift in Sources */,
C0DEF6480000000000000001 /* CMUXOfflineAgentNotesCommandTests.swift in Sources */,
C0DE31390000000000000101 /* CMUXOpenCommandTests.swift in Sources */,
C3677001000000000000001 /* CmuxSSHURLRequestTests.swift in Sources */,
C7A50C000000000000000002 /* CmuxTopProcessCPUTests.swift in Sources */,
@@ -0,0 +1,128 @@
import Darwin
import Foundation
import XCTest
extension CLINotifyProcessIntegrationRegressionTests {
func testOfflineNotesAddAndListWorkWithoutSocket() throws {
let cliPath = try bundledCLIPath()
let rootURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let notesURL = rootURL.appendingPathComponent("offline-agent-notes.json", isDirectory: false)
defer { try? FileManager.default.removeItem(at: rootURL) }
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = makeSocketPath("notes-add")
environment["CMUX_OFFLINE_AGENT_NOTES_PATH"] = notesURL.path
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
let add = runProcess(
executablePath: cliPath,
arguments: ["notes", "add", "--agent", "codex", "--", "Check the release notes when online"],
environment: environment,
timeout: 5
)
XCTAssertFalse(add.timedOut, add.stderr)
XCTAssertEqual(add.status, 0, add.stderr)
XCTAssertTrue(add.stdout.hasPrefix("OK note="), add.stdout)
let list = runProcess(
executablePath: cliPath,
arguments: ["notes", "list", "--json"],
environment: environment,
timeout: 5
)
XCTAssertFalse(list.timedOut, list.stderr)
XCTAssertEqual(list.status, 0, list.stderr)
let payload = try XCTUnwrap(jsonObject(list.stdout))
let notes = try XCTUnwrap(payload["notes"] as? [[String: Any]])
XCTAssertEqual(notes.count, 1)
XCTAssertEqual(notes.first?["agent"] as? String, "codex")
XCTAssertEqual(notes.first?["text"] as? String, "Check the release notes when online")
XCTAssertNil(notes.first?["flushed_at"] as? Double)
}
func testOfflineNotesFlushSubmitsPendingNotesAndMarksThemFlushed() throws {
let cliPath = try bundledCLIPath()
let socketPath = makeSocketPath("notes-flush")
let listenerFD = try bindUnixSocket(at: socketPath)
let rootURL = FileManager.default.temporaryDirectory
.appendingPathComponent(UUID().uuidString, isDirectory: true)
let notesURL = rootURL.appendingPathComponent("offline-agent-notes.json", isDirectory: false)
let workspaceId = UUID().uuidString.lowercased()
let surfaceId = UUID().uuidString.lowercased()
let state = MockSocketServerState()
defer {
Darwin.close(listenerFD)
unlink(socketPath)
try? FileManager.default.removeItem(at: rootURL)
}
var environment = ProcessInfo.processInfo.environment
environment["CMUX_SOCKET_PATH"] = socketPath
environment["CMUX_OFFLINE_AGENT_NOTES_PATH"] = notesURL.path
environment["CMUX_WORKSPACE_ID"] = workspaceId
environment["CMUX_SURFACE_ID"] = surfaceId
environment["CMUX_CLI_SENTRY_DISABLED"] = "1"
for text in ["Audit the auth retry path", "Hand this to an agent after online"] {
let add = runProcess(
executablePath: cliPath,
arguments: ["notes", "add", "--agent", "codex", "--", text],
environment: environment,
timeout: 5
)
XCTAssertEqual(add.status, 0, add.stderr)
}
let serverHandled = startMockServer(listenerFD: listenerFD, state: state) { line in
guard let payload = self.jsonObject(line),
let id = payload["id"] as? String,
payload["method"] as? String == "surface.send_text",
let params = payload["params"] as? [String: Any],
params["workspace_id"] as? String == workspaceId,
params["surface_id"] as? String == surfaceId,
let text = params["text"] as? String,
text.contains("Offline cmux notes queued while you were away:"),
text.contains("Audit the auth retry path"),
text.contains("Hand this to an agent after online"),
text.hasSuffix("\r") else {
return self.malformedRequestResponse(raw: line)
}
return self.v2Response(id: id, ok: true, result: ["surface_id": surfaceId, "workspace_id": workspaceId])
}
let flush = runProcess(
executablePath: cliPath,
arguments: ["notes", "flush", "--agent", "codex"],
environment: environment,
timeout: 5
)
wait(for: [serverHandled], timeout: 5)
XCTAssertFalse(flush.timedOut, flush.stderr)
XCTAssertEqual(flush.status, 0, flush.stderr)
XCTAssertEqual(flush.stdout, "OK flushed=2\n")
XCTAssertEqual(state.snapshot().compactMap { jsonObject($0)?["method"] as? String }, ["surface.send_text"])
let pending = runProcess(
executablePath: cliPath,
arguments: ["notes", "list", "--json"],
environment: environment,
timeout: 5
)
let pendingPayload = try XCTUnwrap(jsonObject(pending.stdout))
XCTAssertEqual((pendingPayload["notes"] as? [[String: Any]])?.count, 0)
let all = runProcess(
executablePath: cliPath,
arguments: ["notes", "list", "--all", "--json"],
environment: environment,
timeout: 5
)
let allPayload = try XCTUnwrap(jsonObject(all.stdout))
let allNotes = try XCTUnwrap(allPayload["notes"] as? [[String: Any]])
XCTAssertEqual(allNotes.count, 2)
XCTAssertNotNil(allNotes.first?["flushed_at"] as? Double)
}
}