diff --git a/.claude/rules/control-api.md b/.claude/rules/control-api.md
index 2332704fb..f8f4c6766 100644
--- a/.claude/rules/control-api.md
+++ b/.claude/rules/control-api.md
@@ -339,7 +339,10 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi
tree. `ProcessSweeper` is nil by default because a test's fake listing names real pids.
- `session.scratch` is a third, nonpersisted login shell with on/off/toggle. It spawns lazily, survives
hiding, recreates after exit, and renders as a full translucent cover below overlay. It has no session
- PWD/title link but a weak watermark link. GUI surfaces are Command-J, titlebar, View, and palette.
+ link but a weak watermark one; its PWD reaches only `Session.scratchCwd`, never the row's cwd or title.
+ A local scratch seeds that field with its start directory, so `AGT_SESSION_PWD` and
+ `AGT_SESSION_HOST` agree.
+ GUI surfaces are Command-J, titlebar, View, and palette.
Showing never selects; `--follow` does, as on `overlay.open`. An unselected session's scratch still
spawns, the deck mounting every session, and takes no focus: `deckActive` gates the grab.
`ControlActions` keeps the four-argument requirement and defaults the `follow:` overload to it.
@@ -797,7 +800,8 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi
trailing `.`, `?`, `!` or `;` too, so a path ending in one also lists up to three spellings with one character
removed at a time (`trimmedSpellings`), each split for a suffix the same way; a relative link is joined to
the clicking pane's session cwd before any scheme parse, and a first segment with a colon is never a directory, so
- `http://localhost:3000` stays a web link. The scratch, quick and overlay terminals pass no cwd. Every candidate has
+ `http://localhost:3000` stays a web link. A local scratch passes its own cwd; a remote window's scratch, whose
+ directory is the server's, and the quick and overlay terminals pass none. Every candidate has
passed `guardedLocalPath` (absolute, no `//`, no automount root) and a remote pane offers none, so a refused path
is never stat'ed. `LinkOpener` checks them off the main actor and routes the first that exists; with none on disk
the link keeps its typed route.
@@ -1570,6 +1574,8 @@ side, and reads `lastAppliedIsDark` when bare. Refuse it outside XCUITest; provi
the last reported split cwd to its restored initial cwd, then the primary effective cwd. Omitted without
a split or on older servers; it is model read-back, not a fresh process query. `title` stays the raw
primary OSC title; exposing `splitTitle` is deferred.
+- `scratchCwd` reports `cwd(for: .scratch)` while a scratch surface exists, shown or hidden: its last reported
+ or start directory, else the primary effective cwd. `closeScratch` clears it with the surface.
- `window.resize` echoes the applied frame size after `setFrame`, rounded to integer points like
`window.list` geometry, as `result.width`/`result.height`. Human output is `W H`.
- Session nodes include foreground/split foreground argv, idle shell basenames, background spec, overlay
diff --git a/.claude/rules/keymap.md b/.claude/rules/keymap.md
index 93e3138ca..eab5c7979 100644
--- a/.claude/rules/keymap.md
+++ b/.claude/rules/keymap.md
@@ -147,7 +147,9 @@ paths:
in `{...}` or `$...` form, no-op with notice; empty `{AGT_SESSION_PWD}` can turn `rm -rf .../*` into a
root glob. Commands using only `AGT_SOCKET`/`AGT_WINDOW`/`AGT_PANE` may run sessionless.
- `{AGT_SESSION_HOST}`/`$AGT_SESSION_HOST` is the SSH destination of a `zmx attach` session, empty
- otherwise; an `ssh` typed into a local session leaves it empty while `AGT_SESSION_PWD` still follows
+ otherwise and from that session's scratch, a local shell (`Session.remoteHost(for:)`); a remote window's
+ scratch attaches the server's shell and keeps it.
+ An `ssh` typed into a local session leaves it empty while `AGT_SESSION_PWD` still follows
any cwd reports that shell emits. `AGT_SESSION_PWD` stays the pane's reported path, remote or not; the
command's execution directory comes separately from `Session.localWorkingDirectory`, which returns
that path for a local session and, for a remote one, only when it exists here as a directory, else
diff --git a/agterm/Commands/CustomCommandRunner.swift b/agterm/Commands/CustomCommandRunner.swift
index 1f5ef8f4d..99f0f81c3 100644
--- a/agterm/Commands/CustomCommandRunner.swift
+++ b/agterm/Commands/CustomCommandRunner.swift
@@ -495,7 +495,7 @@ final class CustomCommandRunner {
sessionID: session.id.uuidString,
sessionName: session.displayName,
sessionPWD: session.cwd(for: pane),
- sessionHost: TerminalText.sanitized(session.remoteHost ?? ""),
+ sessionHost: TerminalText.sanitized(session.remoteHost(for: pane) ?? ""),
workspaceID: workspace?.id.uuidString ?? "",
workspaceName: workspace?.name ?? "",
windowID: windowID?.uuidString ?? "",
diff --git a/agterm/Ghostty/GhosttySurfaceView+Input.swift b/agterm/Ghostty/GhosttySurfaceView+Input.swift
index 184fd5cbc..e5139a403 100644
--- a/agterm/Ghostty/GhosttySurfaceView+Input.swift
+++ b/agterm/Ghostty/GhosttySurfaceView+Input.swift
@@ -647,10 +647,13 @@ extension GhosttySurfaceView: @preconcurrency NSTextInputClient {
session.flatMap { isSplitPane ? $0.splitPaneIdentity : $0.paneIdentity }
}
- /// The directory a relative link names a file in: this pane's reported cwd. Nil for the scratch, quick and
- /// overlay terminals, whose own directory the app does not track, so a relative link there stays ignored.
+ /// The directory a relative link names a file in: this pane's reported cwd, a local scratch's included. Nil
+ /// for a remote window's scratch, whose directory is on the server, and for the quick and overlay
+ /// terminals, whose own directory the app does not track, so a relative link there stays ignored.
var linkClickCwd: String? {
- session.map { isSplitPane ? $0.cwd(for: .right) : $0.effectiveCwd }
+ if let session { return isSplitPane ? session.cwd(for: .right) : session.effectiveCwd }
+ guard let owner = scratchOwner, owner.catalogScratch == nil else { return nil }
+ return owner.cwd(for: .scratch)
}
/// Which kind of surface a clicked link came from, read off the ownership each factory already sets.
diff --git a/agterm/Ghostty/GhosttySurfaceView+Panes.swift b/agterm/Ghostty/GhosttySurfaceView+Panes.swift
index f1273b60a..0369947d3 100644
--- a/agterm/Ghostty/GhosttySurfaceView+Panes.swift
+++ b/agterm/Ghostty/GhosttySurfaceView+Panes.swift
@@ -23,6 +23,12 @@ extension GhosttySurfaceView {
/// surface's CURRENT slot even after a promote + re-split (#199).
var paneToken: String { env["AGTERM_PANE_ID"] ?? "" }
+ /// The session whose live scratch this surface is, nil for every other surface.
+ var scratchOwner: Session? {
+ guard session == nil, let owner = watermarkSession, owner.scratchSurface === self else { return nil }
+ return owner
+ }
+
var zmxSessionName: String? {
guard backedByZmx, let paneIdentity = UUID(uuidString: paneToken) else { return nil }
return ZmxSupport.daemonName(for: paneIdentity)
diff --git a/agterm/Ghostty/GhosttySurfaceView.swift b/agterm/Ghostty/GhosttySurfaceView.swift
index db89abfaf..3a9476e06 100644
--- a/agterm/Ghostty/GhosttySurfaceView.swift
+++ b/agterm/Ghostty/GhosttySurfaceView.swift
@@ -70,8 +70,7 @@ final class GhosttySurfaceView: NSView, PaneRoleMutableSurface {
let backedByZmx: Bool
/// The session whose visual config this surface inherits when it deliberately has no `session`: the scratch
- /// renders the owner's watermark without its OSC title/PWD reports mutating the session model. Nil for
- /// overlays and quick terminals; main/split use `session`.
+ /// renders the owner's watermark and reports its PWD only to `scratchCwd`. Nil for overlays and quick terminals.
weak var watermarkSession: Session?
/// Whether this is the session's split (right) pane. Routes `applyPwd`/`applyTitle` to the session's
@@ -511,6 +510,10 @@ final class GhosttySurfaceView: NSView, PaneRoleMutableSurface {
// no save(): OSC 7 fires on every cd/prompt redraw and would thrash the disk. live cwd is persisted on
// quit and on structural mutations, so a crash loses only cwd changes since the last save. the
// equality guard matters likewise: an equal write still notifies observers and churns the reconcile.
+ if let owner = scratchOwner {
+ owner.scratchCwd = pwd
+ return
+ }
if isSplitPane {
if session?.splitCwd != pwd { session?.splitCwd = pwd }
} else {
diff --git a/agterm/agtermApp.swift b/agterm/agtermApp.swift
index c53f6ec28..6d80284c8 100644
--- a/agterm/agtermApp.swift
+++ b/agterm/agtermApp.swift
@@ -882,9 +882,9 @@ struct agtermApp: App {
}
/// Scratch-terminal surface factory: a third per-session shell, full-overlay rendered. Like the overlay it is
- /// NOT operationally wired to the session (no `view.session`/`isSplitPane`), so its PWD/title never clobber
- /// the sidebar name, but it keeps a weak visual-config link for the watermark and — unlike the overlay —
- /// stays alive when hidden. Runs a login shell, or `session.scratchCommand` (`session.scratch --command`)
+ /// NOT operationally wired to the session (no `view.session`/`isSplitPane`), so its title never clobbers the
+ /// sidebar name and its PWD goes only to `scratchCwd`; it keeps a weak link for the watermark and — unlike
+ /// the overlay — stays alive when hidden. Runs a login shell, or `session.scratchCommand` (`session.scratch --command`)
/// RUN-ONCE. `autoFocus` grabs first responder on show (winning the SwiftUI/AppKit responder race); the
/// shell's `exit` runs `closeScratch`, hiding + tearing down so the next show is fresh.
@MainActor
@@ -897,6 +897,8 @@ struct agtermApp: App {
let cwd = session.localWorkingDirectory(reported: session.effectiveCwd, homeDirectory: NSHomeDirectory())
// a remote window's scratch is its server's: a stopped attach keeps its screen until it reconnects
let catalogScratch = session.catalogScratch
+ // a server scratch's directory is the server's, known only once it reports one
+ if catalogScratch == nil { session.scratchCwd = cwd }
let view = GhosttySurfaceView(workingDirectory: cwd, fontSize: session.fontSize.map(Float.init), command: command,
autoFocus: !suppressAutoFocus,
env: env.merging(catalogScratch.map { ["AGTERM_PANE_ID": $0.uuidString] } ?? [:]) { $1 })
diff --git a/agtermCore/Sources/agtermCore/AppStore+Panes.swift b/agtermCore/Sources/agtermCore/AppStore+Panes.swift
index e67bdbd12..77759189d 100644
--- a/agtermCore/Sources/agtermCore/AppStore+Panes.swift
+++ b/agtermCore/Sources/agtermCore/AppStore+Panes.swift
@@ -198,11 +198,12 @@ extension AppStore {
}
/// releaseScratchOwnedState drops what a departing scratch owned: a `.scratch`-tagged block (a main/split tag
- /// survives), its background label, since a respawn runs another program, and its mailbox.
+ /// survives), its background label, since a respawn runs another program, its mailbox, and its directory.
func releaseScratchOwnedState(of session: Session, token: String) {
clearIndicatorOwnedByPane(.scratch, of: session)
dropPaneBackground(.scratch, of: session)
disposeMailbox(token: token)
+ session.scratchCwd = nil
}
/// clearPaneOwnedState drops what the program in `pane` owned when its shell is replaced in place: its
diff --git a/agtermCore/Sources/agtermCore/AppStore.swift b/agtermCore/Sources/agtermCore/AppStore.swift
index dbda4b4ce..e50cc8f83 100644
--- a/agtermCore/Sources/agtermCore/AppStore.swift
+++ b/agtermCore/Sources/agtermCore/AppStore.swift
@@ -449,6 +449,7 @@ public final class AppStore {
realized: session.surface?.isRealized ?? false,
context: session.effectiveContext, remoteHost: session.remoteHost,
splitCwd: session.hasSplit ? session.cwd(for: .right) : nil,
+ scratchCwd: session.scratchSurface == nil ? nil : session.cwd(for: .scratch),
liveAttribution: mainAttribution?.rawValue, splitLiveAttribution: splitAttribution?.rawValue,
liveHostStale: mainStale ? true : nil, splitLiveHostStale: splitStale ? true : nil,
presentation: presentationNode(of: session), presenters: presentersNode(of: session),
diff --git a/agtermCore/Sources/agtermCore/ControlProjection.swift b/agtermCore/Sources/agtermCore/ControlProjection.swift
index 3a86a3e09..998e7fda6 100644
--- a/agtermCore/Sources/agtermCore/ControlProjection.swift
+++ b/agtermCore/Sources/agtermCore/ControlProjection.swift
@@ -195,6 +195,8 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
public let cwd: String
/// The split pane's last reported or fallback directory, including hidden splits; omitted without one.
public let splitCwd: String?
+ /// The scratch shell's last reported or start directory, hidden scratch included; omitted without one.
+ public let scratchCwd: String?
/// The raw terminal title from the latest OSC 0/1/2 (a remote host over SSH, a shell `PROMPT_COMMAND`);
/// nil/omitted when none reported. The unprocessed `Session.oscTitle`, distinct from `name` (the derived
/// sidebar label, which uses it as one fallback); a remote session's local `cwd` goes stale, this does not.
@@ -420,7 +422,7 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
background: BackgroundWatermark? = nil, paneBackgrounds: PaneBackgrounds? = nil, unseen: Int? = nil,
fontSize: Double? = nil, splitFontSize: Double? = nil, scratchFontSize: Double? = nil,
surfaces: [ControlSurfaceNode]? = nil, realized: Bool? = nil,
- context: String? = nil, remoteHost: String? = nil, splitCwd: String? = nil,
+ context: String? = nil, remoteHost: String? = nil, splitCwd: String? = nil, scratchCwd: String? = nil,
liveAttribution: String? = nil, splitLiveAttribution: String? = nil,
liveHostStale: Bool? = nil, splitLiveHostStale: Bool? = nil,
presentation: ControlPresentationNode? = nil, presenters: ControlPresentersNode? = nil,
@@ -487,6 +489,7 @@ public struct ControlSessionNode: Codable, Sendable, Equatable {
self.surfaces = surfaces
self.realized = realized
self.splitCwd = splitCwd
+ self.scratchCwd = scratchCwd
self.remoteHost = remoteHost
self.liveAttribution = liveAttribution
self.splitLiveAttribution = splitLiveAttribution
diff --git a/agtermCore/Sources/agtermCore/Session+PaneDirectory.swift b/agtermCore/Sources/agtermCore/Session+PaneDirectory.swift
new file mode 100644
index 000000000..61b8531a8
--- /dev/null
+++ b/agtermCore/Sources/agtermCore/Session+PaneDirectory.swift
@@ -0,0 +1,31 @@
+import Foundation
+
+extension Session {
+ /// The working directory for a given pane role, each pane's own, falling back to the primary's.
+ public func cwd(for pane: CommandContext.Pane) -> String {
+ switch pane {
+ case .right:
+ return splitCwd ?? initialSplitCwd ?? effectiveCwd
+ case .scratch:
+ return scratchCwd ?? effectiveCwd
+ case .left:
+ return effectiveCwd
+ }
+ }
+
+ /// The host a pane's shell runs on: `remoteHost`, except for a scratch that runs on this Mac, which an
+ /// attached session's is. A remote window's scratch attaches the server's shell, so it keeps the host.
+ public func remoteHost(for pane: CommandContext.Pane) -> String? {
+ pane == .scratch && catalogScratch == nil ? nil : remoteHost
+ }
+
+ /// Where a LOCAL process for this session starts, given the pane path it would inherit: that path on a
+ /// local session; on a remote one, only when it exists here as a directory, else `homeDirectory`. The
+ /// reported path itself stays what `cwd(for:)` and `AGT_SESSION_PWD` carry.
+ public func localWorkingDirectory(reported path: String, homeDirectory: String) -> String {
+ guard remoteHost != nil else { return path }
+ var isDirectory: ObjCBool = false
+ let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
+ return exists && isDirectory.boolValue ? path : homeDirectory
+ }
+}
diff --git a/agtermCore/Sources/agtermCore/Session.swift b/agtermCore/Sources/agtermCore/Session.swift
index 6b6880fa9..a2627eccf 100644
--- a/agtermCore/Sources/agtermCore/Session.swift
+++ b/agtermCore/Sources/agtermCore/Session.swift
@@ -557,6 +557,10 @@ public final class Session: Identifiable {
/// close, the shell's own `exit`, or session/workspace/window teardown), after which a show spawns fresh.
@ObservationIgnored public var scratchSurface: (any TerminalSurface)?
+ /// The scratch shell's own cwd: where a local scratch started, then each PWD it reports. Separate from
+ /// `currentCwd` so a `cd` in the scratch never renames the row. Nil with no scratch; a departing scratch clears it.
+ @ObservationIgnored public var scratchCwd: String?
+
/// Bumped on every `closeScratch` so the deck keys the scratch view's identity on it. A `--command`
/// respawn closes and re-shows inside one store call, so `scratchActive` never dips where SwiftUI can
/// see it and the torn-down view would otherwise stay mounted. Observed, never persisted.
@@ -648,19 +652,9 @@ public final class Session: Identifiable {
/// The live `currentCwd` once a PWD report arrived, else `initialCwd`. Always the PRIMARY pane's, never
/// focus-aware (cf. `focusedCwd`): it seeds new split, overlay, scratch and quick terminals. A custom
- /// command's `AGT_SESSION_PWD` resolves through `cwd(for:)`, so the right pane's value can differ.
+ /// command's `AGT_SESSION_PWD` resolves through `cwd(for:)`, so the split's and scratch's values can differ.
public var effectiveCwd: String { currentCwd ?? initialCwd }
- /// The working directory for a given pane role: the split pane's while targeting `.right`, else the primary's.
- public func cwd(for pane: CommandContext.Pane) -> String {
- switch pane {
- case .right:
- return splitCwd ?? initialSplitCwd ?? effectiveCwd
- case .left, .scratch:
- return effectiveCwd
- }
- }
-
/// The stable token of the surface currently in `pane`'s slot (`TerminalSurface.paneToken`), the inverse
/// of `paneRole(forToken:)`; empty while the slot holds no surface.
public func paneToken(for pane: CommandContext.Pane) -> String {
@@ -674,16 +668,6 @@ public final class Session: Identifiable {
}
}
- /// Where a LOCAL process for this session starts, given the pane path it would inherit: that path on a
- /// local session; on a remote one, only when it exists here as a directory, else `homeDirectory`. The
- /// reported path itself stays what `cwd(for:)` and `AGT_SESSION_PWD` carry.
- public func localWorkingDirectory(reported path: String, homeDirectory: String) -> String {
- guard remoteHost != nil else { return path }
- var isDirectory: ObjCBool = false
- let exists = FileManager.default.fileExists(atPath: path, isDirectory: &isDirectory)
- return exists && isDirectory.boolValue ? path : homeDirectory
- }
-
/// The focused pane's surface: the split (right) while it has focus and exists, else the primary. With the
/// split hidden the detail pane maximizes this one and focus helpers target it, so typing always reaches
/// the visible pane.
diff --git a/agtermCore/Sources/agtermctlKit/SocketClient.swift b/agtermCore/Sources/agtermctlKit/SocketClient.swift
index 398f9f7b9..17fbdd4a7 100644
--- a/agtermCore/Sources/agtermctlKit/SocketClient.swift
+++ b/agtermCore/Sources/agtermctlKit/SocketClient.swift
@@ -575,6 +575,7 @@ struct SocketClient {
let tags = splitTag + realizedTag + overlayTag
+ (session.scratch ? " (scratch)" : "") + pendingTag + interactionTag
let splitCwdSuffix = session.splitCwd.map { $0 == session.cwd ? "" : " split cwd: \($0)" } ?? ""
+ let scratchCwdSuffix = session.scratchCwd.map { $0 == session.cwd ? "" : " scratch cwd: \($0)" } ?? ""
let titleSuffix = session.title.map { " title: \($0)" } ?? ""
let earlier = " (earlier helper)"
let attribution = session.liveAttribution.map {
@@ -590,7 +591,8 @@ struct SocketClient {
let presenters = session.presenters.map {
($0.presenter == true ? " presented remotely" : "") + ($0.mirrors > 0 ? " mirrored by: \($0.mirrors)" : "")
} ?? ""
- lines.append(" \(smark) \(session.name)\(tags) [\(session.id)] \(session.cwd)\(splitCwdSuffix)\(titleSuffix)\(attribution)\(splitAttribution)\(presentation)\(presenters)")
+ let cwds = session.cwd + splitCwdSuffix + scratchCwdSuffix
+ lines.append(" \(smark) \(session.name)\(tags) [\(session.id)] \(cwds)\(titleSuffix)\(attribution)\(splitAttribution)\(presentation)\(presenters)")
}
}
return lines.joined(separator: "\n")
diff --git a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift
index 6ebc7b089..92fb8e694 100644
--- a/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/AppStorePaneTests.swift
@@ -1706,9 +1706,12 @@ struct AppStorePaneTests {
session.scratchActive = true
let scratch = SpySurface()
session.scratchSurface = scratch
+ session.scratchCwd = "/a/docs"
#expect(store.closeScratch(session.id) == true)
#expect(session.scratchActive == false)
#expect(session.scratchSurface == nil)
+ #expect(session.scratchCwd == nil)
+ #expect(session.cwd(for: .scratch) == "/a")
#expect(scratch.teardownCount == 1)
#expect(session.scratchGeneration == 1)
// closing again (no surface) is a no-op.
@@ -1716,6 +1719,19 @@ struct AppStorePaneTests {
#expect(session.scratchGeneration == 1)
}
+ // a departed server scratch with no surface left its directory for the next daemon's scratch
+ @Test func dropScratchDaemonForgetsTheDepartedScratchsDirectory() {
+ let store = makeStore()
+ let ws = store.addWorkspace(name: "work")
+ let session = store.addSession(toWorkspace: ws.id, cwd: "/a")!
+ session.scratchDaemon = Session.ScratchDaemon(identity: UUID(), shown: false)
+ session.scratchCwd = "/srv/old"
+ store.dropScratchDaemon(session.id)
+ #expect(session.scratchDaemon == nil)
+ #expect(session.scratchCwd == nil)
+ #expect(session.cwd(for: .scratch) == "/a")
+ }
+
@Test func closeScratchClearsStuckSearchWhenScratchOwnsIt() {
let store = makeStore()
let ws = store.addWorkspace(name: "work")
diff --git a/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift b/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift
index 4e211e2e5..4e5e7102a 100644
--- a/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/AppStoreTreeProjectionTests.swift
@@ -990,4 +990,32 @@ struct AppStoreTreeProjectionTests {
let json = String(decoding: try JSONEncoder().encode(node), as: UTF8.self)
#expect(json.contains(#""zoom":1.25"#))
}
+
+ @Test(arguments: [false, true])
+ func scratchCwdReportsTheScratchsOwnOrPrimaryDirectoryEvenWhenHidden(shown: Bool) throws {
+ let store = makeStore()
+ let workspace = store.addWorkspace(name: "work")
+ let session = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/primary"))
+ session.scratchSurface = SpySurface()
+ session.scratchActive = shown
+ for (reported, expected): (String?, String) in [(nil, "/primary"), ("/primary/docs", "/primary/docs")] {
+ session.scratchCwd = reported
+ let node = store.controlTree().workspaces[0].sessions[0]
+ let data = try JSONEncoder().encode(node)
+ let json = try #require(JSONSerialization.jsonObject(with: data) as? [String: Any])
+ #expect(json["scratchCwd"] as? String == expected)
+ #expect(node.cwd == "/primary")
+ #expect(try JSONDecoder().decode(ControlSessionNode.self, from: data) == node)
+ }
+ }
+
+ @Test func noScratchOmitsTheScratchDirectoryEvenWithAStaleValue() throws {
+ let store = makeStore()
+ let workspace = store.addWorkspace(name: "work")
+ let session = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/primary"))
+ session.scratchCwd = "/stale"
+ let node = store.controlTree().workspaces[0].sessions[0]
+ let json = try #require(JSONSerialization.jsonObject(with: JSONEncoder().encode(node)) as? [String: Any])
+ #expect(json["scratchCwd"] == nil)
+ }
}
diff --git a/agtermCore/Tests/agtermCoreTests/SessionTests.swift b/agtermCore/Tests/agtermCoreTests/SessionTests.swift
index 25a13876c..1268a8405 100644
--- a/agtermCore/Tests/agtermCoreTests/SessionTests.swift
+++ b/agtermCore/Tests/agtermCoreTests/SessionTests.swift
@@ -430,6 +430,27 @@ struct SessionTests {
session.initialSplitCwd = nil
#expect(session.cwd(for: .right) == "/repo/primary")
+
+ session.scratchCwd = "/repo/docs"
+ #expect(session.cwd(for: .scratch) == "/repo/docs")
+ #expect(session.cwd(for: .left) == "/repo/primary")
+ #expect(session.focusedCwd == "/repo/primary")
+ }
+
+ @Test func remoteHostForPaneLeavesALocalScratchWithoutTheAttachHost() throws {
+ let store = makeStore()
+ let workspace = store.addWorkspace(name: "work")
+ let local = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/repo"))
+ let attached = try #require(store.addSession(toWorkspace: workspace.id, cwd: "/srv", remoteHost: "box"))
+ #expect(local.remoteHost(for: .scratch) == nil)
+ #expect(attached.remoteHost(for: .left) == "box")
+ #expect(attached.remoteHost(for: .right) == "box")
+ #expect(attached.remoteHost(for: .scratch) == nil)
+
+ store.bindRemote(RemoteBinding(remoteSessionID: "r", daemonsByLocalPane: [:], presentationVersion: 1, catalogOwned: true),
+ forSession: attached.id)
+ attached.scratchDaemon = Session.ScratchDaemon(identity: UUID(), shown: true)
+ #expect(attached.remoteHost(for: .scratch) == "box")
}
@Test func localWorkingDirectoryKeepsAnyPathForALocalSession() {
diff --git a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift
index 04fea9f72..52d2cce98 100644
--- a/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift
+++ b/agtermCore/Tests/agtermctlKitTests/SocketClientTests.swift
@@ -1161,6 +1161,16 @@ struct SocketClientTests {
#expect(output == "* work [w]\n * shell (split) [s] \(expected)")
}
+ @Test(arguments: [("/other", "/main scratch cwd: /other"), ("/main", "/main")])
+ func formatTreeIncludesOnlyADifferingScratchDirectory(_ scratchCwd: String, _ expected: String) throws {
+ let data = Data(#"{"id":"s","name":"shell","cwd":"/main","scratchCwd":"\#(scratchCwd)","active":true,"split":false,"overlay":false,"scratch":true,"flagged":false}"#.utf8)
+ let session = try JSONDecoder().decode(ControlSessionNode.self, from: data)
+ let tree = ControlTree(workspaces: [ControlWorkspaceNode(id: "w", name: "work", active: true, sessions: [session])])
+ let response = ControlResponse(ok: true, result: ControlResult(tree: tree))
+ let output = SocketClient.formatResponse(response)
+ #expect(output == "* work [w]\n * shell (scratch) [s] \(expected)")
+ }
+
@Test func formatWindowResizeReportsAppliedWidthAndHeight() throws {
let response = try JSONDecoder().decode(ControlResponse.self, from: Data(#"{"ok":true,"result":{"id":"w","width":1200,"height":800}}"#.utf8))
#expect(SocketClient.formatResponse(response) == "1200 800")
diff --git a/agtermTests/CustomCommandRunnerTests.swift b/agtermTests/CustomCommandRunnerTests.swift
index c64e59cb8..fbfb445a4 100644
--- a/agtermTests/CustomCommandRunnerTests.swift
+++ b/agtermTests/CustomCommandRunnerTests.swift
@@ -141,6 +141,23 @@ final class CustomCommandRunnerTests: XCTestCase {
XCTAssertEqual(scratchContext.paneID, scratch.uuidString)
}
+ func testAScratchCommandOnAnAttachedSessionNamesTheLocalScratchShell() throws {
+ let fixture = try fixture()
+ let workspace = try XCTUnwrap(fixture.store.workspaces.first?.id)
+ let session = try XCTUnwrap(fixture.store.addSession(toWorkspace: workspace, cwd: "/srv", remoteHost: "box"))
+ let before = fixture.runner.context(for: session, in: fixture.store, selectionSurface: nil, pane: .scratch)
+ session.scratchCwd = "/Users/me/docs"
+
+ let scratch = fixture.runner.context(for: session, in: fixture.store, selectionSurface: nil, pane: .scratch)
+ let left = fixture.runner.context(for: session, in: fixture.store, selectionSurface: nil, pane: .left)
+
+ XCTAssertEqual(before.sessionPWD, "/srv")
+ XCTAssertEqual(scratch.sessionPWD, "/Users/me/docs")
+ XCTAssertEqual(scratch.sessionHost, "")
+ XCTAssertEqual(left.sessionPWD, "/srv")
+ XCTAssertEqual(left.sessionHost, "box")
+ }
+
func testMenuFunctionKeyConsumesRepeatAndRelease() throws {
try assertMenuFunctionKeyOwnership(keymap: "map f5 next_session\n")
}
diff --git a/agtermTests/GhosttySurfaceViewInputTests.swift b/agtermTests/GhosttySurfaceViewInputTests.swift
index 56574182f..6308d4f85 100644
--- a/agtermTests/GhosttySurfaceViewInputTests.swift
+++ b/agtermTests/GhosttySurfaceViewInputTests.swift
@@ -95,6 +95,27 @@ final class GhosttySurfaceViewInputTests: XCTestCase {
XCTAssertEqual(remote.linkClickOrigin, .remotePane(remoteSession.id))
}
+ func testRelativeLinksInALocalScratchResolveAgainstItsOwnCwd() {
+ let store = AppStore(persistence: PersistenceStore(directory: FileManager.default.temporaryDirectory
+ .appendingPathComponent("agterm-scratch-link-\(UUID().uuidString)")))
+ let workspace = store.addWorkspace(name: "work")
+ guard let session = store.addSession(toWorkspace: workspace.id, cwd: "/tmp/main", remoteHost: "box") else {
+ return XCTFail("no session")
+ }
+ let scratch = bareSurface()
+ scratch.watermarkSession = session
+ XCTAssertNil(scratch.linkClickCwd)
+
+ session.scratchSurface = scratch
+ session.scratchCwd = "/tmp/main/docs"
+ XCTAssertEqual(scratch.linkClickCwd, "/tmp/main/docs")
+
+ store.bindRemote(RemoteBinding(remoteSessionID: "r", daemonsByLocalPane: [:], presentationVersion: 1, catalogOwned: true),
+ forSession: session.id)
+ session.scratchDaemon = Session.ScratchDaemon(identity: UUID(), shown: true)
+ XCTAssertNil(scratch.linkClickCwd)
+ }
+
func testCommandClickOnAnOSC8SessionLinkSelectsItsTarget() throws {
let id = UUID()
let fixture = try LinkClickFixture(printing: "\\033]8;;agterm://session/\(id.uuidString)\\033\\\\example session\\033]8;;\\033\\\\")
diff --git a/agtermTests/GhosttySurfaceViewTitleTests.swift b/agtermTests/GhosttySurfaceViewTitleTests.swift
index 49923ff75..268c2fc6e 100644
--- a/agtermTests/GhosttySurfaceViewTitleTests.swift
+++ b/agtermTests/GhosttySurfaceViewTitleTests.swift
@@ -123,6 +123,31 @@ final class GhosttySurfaceViewTitleTests: XCTestCase {
XCTAssertNil(session.oscTitle)
}
+ func testScratchPwdReachesOnlyTheScratchCwd() {
+ let session = Session(initialCwd: "/tmp/start")
+ let view = GhosttySurfaceView(workingDirectory: session.initialCwd)
+ view.watermarkSession = session
+ session.scratchSurface = view
+
+ view.applyPwd("/tmp/start/\u{7}docs")
+
+ XCTAssertEqual(session.scratchCwd, "/tmp/start/docs")
+ XCTAssertNil(session.currentCwd)
+ XCTAssertNil(session.splitCwd)
+ XCTAssertEqual(session.displayName, "start")
+ }
+
+ func testAScratchNoLongerInItsSlotDropsItsPwd() {
+ let session = Session(initialCwd: "/tmp/start")
+ let view = GhosttySurfaceView(workingDirectory: session.initialCwd)
+ view.watermarkSession = session
+
+ view.applyPwd("/tmp/old")
+
+ XCTAssertNil(session.scratchCwd)
+ XCTAssertNil(session.currentCwd)
+ }
+
private func makeView(split: Bool = false) -> (Session, GhosttySurfaceView) {
let session = Session(initialCwd: "/tmp/start")
let view = GhosttySurfaceView(workingDirectory: session.initialCwd)
diff --git a/agtermTests/SurfaceFactorySeedTests.swift b/agtermTests/SurfaceFactorySeedTests.swift
index db8d6eeca..bd5325a3e 100644
--- a/agtermTests/SurfaceFactorySeedTests.swift
+++ b/agtermTests/SurfaceFactorySeedTests.swift
@@ -322,6 +322,17 @@ final class SurfaceFactorySeedTests: XCTestCase {
XCTAssertEqual(overlaySurface(for: session).workingDirectory, "/nowhere/explicit")
}
+ func testALocalScratchRecordsWhereItStarted() {
+ let local = Session(initialCwd: "/nowhere")
+ local.currentCwd = "/nowhere/primary"
+ _ = scratchSurface(for: local)
+ XCTAssertEqual(local.scratchCwd, "/nowhere/primary")
+
+ let attached = remoteSession(reportedCwd: stateDir.appendingPathComponent("only-on-the-remote").path)
+ _ = scratchSurface(for: attached)
+ XCTAssertEqual(attached.scratchCwd, NSHomeDirectory())
+ }
+
func testLocalSessionFactoriesKeepTheirInheritedPaths() {
let session = Session(initialCwd: "/nowhere")
session.currentCwd = "/nowhere/primary"
diff --git a/plugins/agterm/skills/agterm/SKILL.md b/plugins/agterm/skills/agterm/SKILL.md
index 20e6ed0b9..2db22574c 100644
--- a/plugins/agterm/skills/agterm/SKILL.md
+++ b/plugins/agterm/skills/agterm/SKILL.md
@@ -107,11 +107,12 @@ its slot until it closes, so opening another there is refused. Separately, the a
or whatever share Settings sets instead; not part of the tree and not owned by a window).
Inspect the live tree any time with `agtermctl tree --json` (workspaces → sessions, each with
-`id`, `name`, `cwd`, `splitCwd`, `title`, `active`, `split`, `overlay`, `hud`, `ask`, `scratch`, `status`, `background`, `surfaces`). `title` is the raw OSC
+`id`, `name`, `cwd`, `splitCwd`, `scratchCwd`, `title`, `active`, `split`, `overlay`, `hud`, `ask`, `scratch`, `status`, `background`, `surfaces`). `title` is the raw OSC
terminal title (e.g. a remote host over SSH), omitted when none was reported — read it when a
session's local `cwd` is stale because it's connected to a remote. `splitCwd` is the split pane's last
reported directory, falling back to its restored directory, then the primary cwd. It is present for a
-shown or hidden split and omitted without one or on older servers. `surfaces[].id` is the
+shown or hidden split and omitted without one or on older servers. `scratchCwd` is the scratch shell's own
+directory, present while a scratch exists, shown or hidden. `surfaces[].id` is the
control address for `surface zoom` and `surface cursor` (`left`, `right`, `scratch`, `overlay`,
`overlay-left`, or `overlay-right`), including hidden-but-alive split/scratch surfaces. The tree object also carries
read-only top-level fields — `idleMs` (ms since the last user input in the window), `autoFollowMs`
diff --git a/plugins/agterm/skills/agterm/reference.md b/plugins/agterm/skills/agterm/reference.md
index 58e8811f3..c6958bc99 100644
--- a/plugins/agterm/skills/agterm/reference.md
+++ b/plugins/agterm/skills/agterm/reference.md
@@ -144,7 +144,8 @@ waits forever on an absent app. SIGINT and SIGTERM use normal process behavior.
`agtermctl tree [--json] [--window W]` — the workspace/session tree. Each session node:
`id`, `name`, `cwd`, `splitCwd` (the split pane's last reported directory, falling back to its restored
-directory, then the primary cwd; omitted without a split or on older servers), `title` (the raw OSC terminal
+directory, then the primary cwd; omitted without a split or on older servers), `scratchCwd` (the scratch
+shell's last reported directory or where it started; omitted without a scratch or on older servers), `title` (the raw OSC terminal
title, for example a remote host over SSH, omitted
when none reported; distinct from `name`, the derived sidebar label), `active` (selected),
`split` (split SHOWN side by side, the read side of `session split on|off`),
@@ -2111,14 +2112,14 @@ so `{AGT_SESSION_NAME}` and `{AGT_SESSION_PWD}` are as untrusted as `{AGT_SELECT
`$AGT_*` form for any of them:
- `{AGT_SESSION_NAME}` / `$AGT_SESSION_NAME` — the session's display name (the focused pane's terminal title, remote-settable via OSC).
-- `{AGT_SESSION_PWD}` / `$AGT_SESSION_PWD` — the working directory of the pane the command fired from;
- the scratch terminal reports the main pane's, since it tracks no cwd of its own. For a session opened
- by `zmx attach` the path can be remote: the session starts with local HOME and follows subsequent cwd
+- `{AGT_SESSION_PWD}` / `$AGT_SESSION_PWD` — the working directory of the pane the command fired from,
+ the scratch terminal included. For a session opened by `zmx attach` the path can be remote: the session starts with local HOME and follows subsequent cwd
reports. See Remote sessions. On any session the command itself starts in that path only when it
exists here as a directory, else in local HOME, so a deleted directory does not stop it; the variable
keeps the reported path either way.
- `{AGT_SESSION_HOST}` / `$AGT_SESSION_HOST` — the SSH destination of a session opened by `zmx attach`,
verbatim as given (`user@alias` included); empty for a local session, an `ssh` typed into one included,
+ and from the scratch of an attached session, whose shell runs on this Mac,
so branch on it: `if [ -n "$AGT_SESSION_HOST" ]; then ssh "$AGT_SESSION_HOST" uptime; fi` (a bare
`&&` chain exits 1 on a local session and the runner reports that as a failure).
- `{AGT_SELECTION}` / `$AGT_SELECTION` — the current selection.
@@ -2523,7 +2524,8 @@ after a relaunch whatever the restore mode is.
The pane reports the far side's working directory, and the local launches that would inherit it pick
their start directory by one rule: the reported path when it exists here as a directory, else local
HOME. Those launches are a custom command (its `$AGT_SESSION_PWD` keeps the reported path and
-`$AGT_SESSION_HOST` names the destination), the scratch terminal, an overlay opened without `--cwd`,
+`$AGT_SESSION_HOST` names the destination; one fired from the local scratch gets that shell's directory and an
+empty host), the scratch terminal, an overlay opened without `--cwd`,
the quick terminal, a local split (the first one on a remote session that arrived without a split, or
one opened after the attach-time split is closed), Duplicate Session, and New Session when it is set
to open in the current session's directory. An explicit overlay `--cwd` is used as given. Quote both
diff --git a/site/commands.html b/site/commands.html
index 2786dd528..2c83143f2 100644
--- a/site/commands.html
+++ b/site/commands.html
@@ -455,6 +455,9 @@
splitCwd (the split pane's last reported
directory, falling back to its restored directory, then the primary cwd; present for shown or hidden splits,
omitted without a split or on older servers),
+ scratchCwd (the scratch shell's last
+ reported directory or where it started; present for a shown or hidden scratch, omitted without one or on older
+ servers),
title (raw OSC terminal title, omitted when
none), active,
split (both split panes are SHOWN),
diff --git a/site/docs.html b/site/docs.html
index cc1e26eda..902342bd6 100644
--- a/site/docs.html
+++ b/site/docs.html
@@ -3159,7 +3159,8 @@
exists here as a directory, else in your home. A custom command still sees the reported path in
$AGT_SESSION_PWD and the
destination in $AGT_SESSION_HOST,
- empty for a local session, so it can reach the remote directory itself. When another client leads at a different
+ empty for a local session, so it can reach the remote directory itself; one fired from the scratch, a local shell,
+ sees that shell's directory and no destination. When another client leads at a different
terminal size, local cursor and screen-text reads can disagree with the application's layout; automation relying on
those reads is unsupported in that state.