Skip to content

Commit 2c8eb6d

Browse files
committed
api: add read-only workspace files
1 parent d8d866f commit 2c8eb6d

6 files changed

Lines changed: 519 additions & 8 deletions

File tree

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,8 @@ SwiftUI apps should not need to replay every raw event to show useful state. The
179179
- `CodexThread.makeRecentFiles(limit:cachePolicy:)` provides a file-change-centric view with selection-aware payload slimming and rehydration.
180180
- `CodexThread.makeRecentCommands(limit:cachePolicy:)` provides a command-centric view with output-aware slimming and rehydration.
181181

182+
For read-only workspace browsing and preview UI, `CodexThread.listWorkspaceFiles(...)` and `CodexThread.readWorkspaceFile(...)` inspect files under the thread's `currentDirectoryPath` without asking Codex to run commands. Paths are resolved under the workspace root, absolute paths must still remain inside that root, and oversized or non-UTF-8 file reads are rejected with descriptive `WorkspaceFileError` values.
183+
182184
For inspector-style UI that needs completed history without binding to an observable, `CodexThread.HistoryWindow` and the `readRecent...`, `readOlder...`, `readNewer...`, `windowAroundTurn(...)`, and `windowAroundItem(...)` helpers expose narrow local-history reads.
183185

184186
Recent observable startup is intentionally UI-friendly around known live app-server history boundaries. If a thread is ephemeral, or if a non-ephemeral thread has not materialized stored turn history yet, `makeRecentTurns(...)`, `makeRecentFiles(...)`, and `makeRecentCommands(...)` start as empty local-only views instead of surfacing raw `thread/turns/list` protocol text. Direct calls to `CodexAppServer.listThreadTurns(...)` still report the underlying app-server failure so lower-level callers can handle remote paging errors explicitly.
@@ -188,7 +190,7 @@ Recent observable startup is intentionally UI-friendly around known live app-ser
188190
The current public lifecycle contract is intentionally narrow and explicit:
189191

190192
- `CodexAppServer` starts and stops the subprocess, initializes the session, starts threads and turns, lists stored threads, reads/resumes/forks threads, pages stored turns, lists models, lists MCP server statuses, and lists configured hook diagnostics.
191-
- `CodexThread` owns thread-scoped turn creation, thread events, thread-management actions, local-history reads, and thread-scoped observable companions.
193+
- `CodexThread` owns thread-scoped turn creation, thread events, thread-management actions, read-only workspace file inspection, local-history reads, and thread-scoped observable companions.
192194
- `CodexTurnHandle` owns active-turn events and active-turn controls such as response handling, steering, interruption, minimap observation, and explicit completion snapshot handoff.
193195
- Approval and elicitation requests use hand-owned public models, including command approval, file-change approval, permissions approval, tool user input, and MCP server elicitation.
194196
- Diagnostics for warnings, guardian warnings, model reroutes, and model verification surface through hand-owned public events rather than generated wire payloads.

ROADMAP.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
| Stored thread resume flow | `Shipped` | `resumeThread(...)` wraps `thread/resume`, returns a normal `CodexThread`, restores thread defaults, clears stale archived state for the reopened thread, and hydrates any resumed persisted turns into the same local history store without resetting completeness to a fresh-thread state. Callers can set `excludeTurns` when they plan to page history separately through `thread/turns/list`. |
5858
| Stored thread fork flow | `Shipped` | `forkThread(...)` wraps `thread/fork`, returns a normal `CodexThread`, persists copied fork history into thread-scoped local turn rows, and records explicit fork lineage through the source thread id plus the last shared turn id. Callers can set `excludeTurns` when they want the fork metadata first and copied turn history through paged reads afterward. |
5959
| Thread management actions | `Partially shipped` | `CodexThread.setName(...)` wraps `thread/name/set`, `CodexThread.updateMetadata(...)` wraps `thread/metadata/update`, and `CodexThread.rollbackLastTurns(...)` wraps `thread/rollback`. Metadata patches use an explicit replace/clear/unchanged field model so callers can express upstream null-vs-omitted semantics. Rollback reconciles visible local history to the app-server response, records a rollback marker, and now has opt-in live coverage against a disposable non-ephemeral thread, but it does not preserve full removed turn payloads as forensic archive data yet. |
60+
| Thread-scoped workspace file inspection | `Shipped` | `CodexThread.listWorkspaceFiles(...)` and `CodexThread.readWorkspaceFile(...)` provide local read-only workspace directory listing and UTF-8 file preview under the thread's current directory. The surface is Foundation-backed rather than app-server RPC-backed, rejects traversal and symlink escapes outside the resolved workspace root, and leaves mutation plus command execution to later explicitly approved surfaces. |
6061
| Paged turn-history flow | `Shipped` | `listThreadTurns(...)` wraps `thread/turns/list`, returns typed paged turn values, and can now seed the local history cache even before that thread has been loaded locally. |
6162
| Typed async thread event stream | `Partially shipped` | `CodexThread.events` now streams `thread/started`, `thread/status/changed`, `thread/archived`, `thread/unarchived`, `thread/name/updated`, `thread/tokenUsage/updated`, and `thread/closed`, but broader thread lifecycle coverage is still pending. |
6263
| Turn start flow | `Shipped` | `startTurn(...)` returns `CodexTurnHandle`. |
@@ -132,9 +133,9 @@ That means the current priority order is:
132133
2. Finish targeted public API release prep before v1. The connected surface
133134
review keeps the current owner model: `CodexAppServer` owns subprocess and
134135
app-wide operations, `CodexThread` owns conversation-scoped actions, history,
135-
and companions, and `CodexTurnHandle` owns active-turn control. Remaining
136-
curation is limited to targeted symbol comments and any final name/default
137-
issues found during release prep.
136+
read-only workspace file inspection, and companions, and `CodexTurnHandle`
137+
owns active-turn control. Remaining curation is limited to targeted symbol
138+
comments and any final name/default issues found during release prep.
138139
3. Finish the DocC release-readiness pass before v1: keep the first
139140
`SwiftASB.docc` catalog current, keep the new startup/progress/approval/
140141
diagnostics/history/SwiftUI walkthroughs accurate, add any remaining symbol

Sources/SwiftASB/Public/CodexAppServer+Hooks.swift

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,15 +39,17 @@ public extension CodexAppServer {
3939

4040
/// Hook warnings and errors combined into one list for diagnostics UI.
4141
public var diagnostics: [HookDiagnostic] {
42-
let errorDiagnostics = errors.map { error in
42+
let errorDiagnostics = errors.enumerated().map { offset, error in
4343
HookDiagnostic(
44+
id: "error:\(offset):\(error.path):\(error.message)",
4445
severity: .error,
4546
message: error.message,
4647
path: error.path
4748
)
4849
}
49-
let warningDiagnostics = warnings.map { warning in
50+
let warningDiagnostics = warnings.enumerated().map { offset, warning in
5051
HookDiagnostic(
52+
id: "warning:\(offset):\(warning)",
5153
severity: .warning,
5254
message: warning,
5355
path: nil
@@ -86,8 +88,7 @@ public extension CodexAppServer {
8688
case warning
8789
}
8890

89-
public var id: String { "\(severity.rawValue):\(path ?? ""):\(message)" }
90-
91+
public let id: String
9192
public let severity: Severity
9293
public let message: String
9394
public let path: String?
Lines changed: 328 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,328 @@
1+
import Foundation
2+
3+
public extension CodexThread {
4+
/// Request used to list one directory under this thread's workspace root.
5+
struct WorkspaceFileListRequest: Sendable, Equatable {
6+
public var includeHidden: Bool
7+
public var path: String?
8+
9+
/// Creates a workspace file-list request.
10+
///
11+
/// Nil or empty `path` lists the workspace root. Relative paths are
12+
/// resolved under ``CodexThread/currentDirectoryPath``. Absolute paths
13+
/// are accepted only when they stay inside the same resolved workspace.
14+
public init(path: String? = nil, includeHidden: Bool = false) {
15+
self.includeHidden = includeHidden
16+
self.path = path
17+
}
18+
}
19+
20+
/// Request used to read one UTF-8 text file under this thread's workspace root.
21+
struct WorkspaceFileReadRequest: Sendable, Equatable {
22+
public var maximumBytes: Int
23+
public var path: String
24+
25+
/// Creates a workspace file-read request.
26+
///
27+
/// `maximumBytes` is normalized to at least 1 byte. SwiftASB rejects
28+
/// larger files instead of truncating them so callers do not render
29+
/// incomplete source text as if it were complete.
30+
public init(path: String, maximumBytes: Int = 1_048_576) {
31+
self.maximumBytes = max(1, maximumBytes)
32+
self.path = path
33+
}
34+
}
35+
36+
/// Directory listing rooted in this thread's workspace.
37+
struct WorkspaceFileList: Sendable, Equatable {
38+
public let directoryRelativePath: String
39+
public let entries: [WorkspaceFileEntry]
40+
public let workspacePath: String
41+
}
42+
43+
/// One visible filesystem entry under this thread's workspace.
44+
struct WorkspaceFileEntry: Sendable, Equatable, Identifiable {
45+
/// Basic filesystem kind for a listed workspace entry.
46+
public enum Kind: String, Sendable, Equatable {
47+
case directory
48+
case file
49+
case other
50+
case symbolicLink
51+
}
52+
53+
public var id: String { relativePath }
54+
55+
public let byteCount: Int64?
56+
public let isHidden: Bool
57+
public let kind: Kind
58+
public let modifiedAt: Date?
59+
public let name: String
60+
public let relativePath: String
61+
}
62+
63+
/// UTF-8 text contents read from a file under this thread's workspace.
64+
struct WorkspaceFileContents: Sendable, Equatable {
65+
public let byteCount: Int
66+
public let contents: String
67+
public let relativePath: String
68+
public let workspacePath: String
69+
}
70+
71+
/// Error raised while resolving, listing, or reading workspace files.
72+
enum WorkspaceFileError: Error, Sendable, LocalizedError, Equatable {
73+
case fileSystemFailure(operation: String, path: String, reason: String)
74+
case fileTooLarge(path: String, byteCount: Int64, maximumBytes: Int)
75+
case missingPath(path: String)
76+
case missingWorkspaceRoot(path: String)
77+
case notDirectory(path: String)
78+
case notFile(path: String)
79+
case outsideWorkspace(path: String, workspacePath: String)
80+
case unsupportedTextEncoding(path: String)
81+
82+
public var errorDescription: String? {
83+
switch self {
84+
case let .fileSystemFailure(operation, path, reason):
85+
return "Workspace file \(operation) failed for \(path): \(reason)"
86+
case let .fileTooLarge(path, byteCount, maximumBytes):
87+
return "Workspace file read rejected \(path) because it is \(byteCount) bytes, which exceeds the configured \(maximumBytes)-byte limit."
88+
case let .missingPath(path):
89+
return "Workspace file path does not exist: \(path)."
90+
case let .missingWorkspaceRoot(path):
91+
return "Workspace root does not exist or is not a directory: \(path)."
92+
case let .notDirectory(path):
93+
return "Workspace file listing requires a directory, but \(path) is not a directory."
94+
case let .notFile(path):
95+
return "Workspace file read requires a regular file, but \(path) is not a file."
96+
case let .outsideWorkspace(path, workspacePath):
97+
return "Workspace file path \(path) resolves outside the thread workspace root \(workspacePath)."
98+
case let .unsupportedTextEncoding(path):
99+
return "Workspace file read supports UTF-8 text files, but \(path) could not be decoded as UTF-8."
100+
}
101+
}
102+
}
103+
104+
/// Lists one workspace directory without asking the app-server to run a command.
105+
func listWorkspaceFiles(_ request: WorkspaceFileListRequest = .init()) throws -> WorkspaceFileList {
106+
let root = try resolvedWorkspaceRoot()
107+
let directory = try resolveWorkspacePath(request.path, root: root)
108+
109+
guard FileManager.default.fileExists(atPath: directory.url.path) else {
110+
throw WorkspaceFileError.missingPath(path: directory.url.path)
111+
}
112+
113+
var isDirectory: ObjCBool = false
114+
guard FileManager.default.fileExists(atPath: directory.url.path, isDirectory: &isDirectory), isDirectory.boolValue else {
115+
throw WorkspaceFileError.notDirectory(path: directory.url.path)
116+
}
117+
118+
let properties: Set<URLResourceKey> = [
119+
.contentModificationDateKey,
120+
.fileSizeKey,
121+
.isDirectoryKey,
122+
.isRegularFileKey,
123+
.isSymbolicLinkKey,
124+
]
125+
let options: FileManager.DirectoryEnumerationOptions = request.includeHidden ? [] : [.skipsHiddenFiles]
126+
let urls: [URL]
127+
do {
128+
urls = try FileManager.default.contentsOfDirectory(
129+
at: directory.url,
130+
includingPropertiesForKeys: Array(properties),
131+
options: options
132+
)
133+
} catch {
134+
throw WorkspaceFileError.fileSystemFailure(
135+
operation: "listing",
136+
path: directory.url.path,
137+
reason: error.localizedDescription
138+
)
139+
}
140+
141+
let entries: [WorkspaceFileEntry]
142+
do {
143+
entries = try urls.map { url in
144+
try WorkspaceFileEntry(url: url, root: root)
145+
}
146+
.sorted()
147+
} catch let workspaceError as WorkspaceFileError {
148+
throw workspaceError
149+
} catch {
150+
throw WorkspaceFileError.fileSystemFailure(
151+
operation: "reading listed entry metadata",
152+
path: directory.url.path,
153+
reason: error.localizedDescription
154+
)
155+
}
156+
157+
return .init(
158+
directoryRelativePath: directory.relativePath,
159+
entries: entries,
160+
workspacePath: root.url.path
161+
)
162+
}
163+
164+
/// Reads one UTF-8 workspace file without asking the app-server to run a command.
165+
func readWorkspaceFile(_ request: WorkspaceFileReadRequest) throws -> WorkspaceFileContents {
166+
let root = try resolvedWorkspaceRoot()
167+
let file = try resolveWorkspacePath(request.path, root: root)
168+
169+
guard FileManager.default.fileExists(atPath: file.url.path) else {
170+
throw WorkspaceFileError.missingPath(path: file.url.path)
171+
}
172+
173+
let values: URLResourceValues
174+
do {
175+
values = try file.url.resourceValues(forKeys: [.fileSizeKey, .isRegularFileKey])
176+
} catch {
177+
throw WorkspaceFileError.fileSystemFailure(
178+
operation: "reading metadata",
179+
path: file.url.path,
180+
reason: error.localizedDescription
181+
)
182+
}
183+
guard values.isRegularFile == true else {
184+
throw WorkspaceFileError.notFile(path: file.url.path)
185+
}
186+
187+
let byteCount = Int64(values.fileSize ?? 0)
188+
guard byteCount <= Int64(request.maximumBytes) else {
189+
throw WorkspaceFileError.fileTooLarge(
190+
path: file.url.path,
191+
byteCount: byteCount,
192+
maximumBytes: request.maximumBytes
193+
)
194+
}
195+
196+
let data: Data
197+
do {
198+
data = try Data(contentsOf: file.url)
199+
} catch {
200+
throw WorkspaceFileError.fileSystemFailure(
201+
operation: "reading contents",
202+
path: file.url.path,
203+
reason: error.localizedDescription
204+
)
205+
}
206+
guard let contents = String(data: data, encoding: .utf8) else {
207+
throw WorkspaceFileError.unsupportedTextEncoding(path: file.url.path)
208+
}
209+
210+
return .init(
211+
byteCount: data.count,
212+
contents: contents,
213+
relativePath: file.relativePath,
214+
workspacePath: root.url.path
215+
)
216+
}
217+
}
218+
219+
private struct ResolvedWorkspacePath {
220+
let relativePath: String
221+
let url: URL
222+
}
223+
224+
private extension CodexThread {
225+
func resolvedWorkspaceRoot() throws -> ResolvedWorkspacePath {
226+
let rootURL = URL(fileURLWithPath: currentDirectoryPath, isDirectory: true)
227+
.standardizedFileURL
228+
.resolvingSymlinksInPath()
229+
230+
var isDirectory: ObjCBool = false
231+
guard FileManager.default.fileExists(atPath: rootURL.path, isDirectory: &isDirectory), isDirectory.boolValue else {
232+
throw WorkspaceFileError.missingWorkspaceRoot(path: currentDirectoryPath)
233+
}
234+
235+
return .init(relativePath: ".", url: rootURL)
236+
}
237+
238+
func resolveWorkspacePath(_ path: String?, root: ResolvedWorkspacePath) throws -> ResolvedWorkspacePath {
239+
let requestedPath = normalizedRequestedPath(path)
240+
let rawURL: URL
241+
if requestedPath.hasPrefix("/") {
242+
rawURL = URL(fileURLWithPath: requestedPath)
243+
} else {
244+
rawURL = root.url.appendingPathComponent(requestedPath)
245+
}
246+
247+
let resolvedURL = rawURL
248+
.standardizedFileURL
249+
.resolvingSymlinksInPath()
250+
guard resolvedURL.isContained(in: root.url) else {
251+
throw WorkspaceFileError.outsideWorkspace(
252+
path: rawURL.standardizedFileURL.path,
253+
workspacePath: root.url.path
254+
)
255+
}
256+
257+
return .init(
258+
relativePath: resolvedURL.workspaceRelativePath(from: root.url),
259+
url: resolvedURL
260+
)
261+
}
262+
263+
func normalizedRequestedPath(_ path: String?) -> String {
264+
guard let path else { return "." }
265+
let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines)
266+
return trimmed.isEmpty ? "." : trimmed
267+
}
268+
}
269+
270+
private extension CodexThread.WorkspaceFileEntry {
271+
init(url: URL, root: ResolvedWorkspacePath) throws {
272+
let values = try url.resourceValues(forKeys: [
273+
.contentModificationDateKey,
274+
.fileSizeKey,
275+
.isDirectoryKey,
276+
.isRegularFileKey,
277+
.isSymbolicLinkKey,
278+
])
279+
let kind: Kind
280+
if values.isSymbolicLink == true {
281+
kind = .symbolicLink
282+
} else if values.isDirectory == true {
283+
kind = .directory
284+
} else if values.isRegularFile == true {
285+
kind = .file
286+
} else {
287+
kind = .other
288+
}
289+
290+
self.init(
291+
byteCount: kind == .file ? Int64(values.fileSize ?? 0) : nil,
292+
isHidden: url.lastPathComponent.hasPrefix("."),
293+
kind: kind,
294+
modifiedAt: values.contentModificationDate,
295+
name: url.lastPathComponent,
296+
relativePath: url.standardizedFileURL.workspaceRelativePath(from: root.url)
297+
)
298+
}
299+
}
300+
301+
private extension Array where Element == CodexThread.WorkspaceFileEntry {
302+
func sorted() -> Self {
303+
sorted { lhs, rhs in
304+
if lhs.kind == .directory, rhs.kind != .directory {
305+
return true
306+
}
307+
if lhs.kind != .directory, rhs.kind == .directory {
308+
return false
309+
}
310+
return lhs.name.localizedStandardCompare(rhs.name) == .orderedAscending
311+
}
312+
}
313+
}
314+
315+
private extension URL {
316+
func isContained(in root: URL) -> Bool {
317+
let path = path
318+
let rootPath = root.path
319+
return path == rootPath || path.hasPrefix(rootPath + "/")
320+
}
321+
322+
func workspaceRelativePath(from root: URL) -> String {
323+
let path = path
324+
let rootPath = root.path
325+
guard path != rootPath else { return "." }
326+
return String(path.dropFirst(rootPath.count + 1))
327+
}
328+
}

0 commit comments

Comments
 (0)