Skip to content

Commit ba3c55b

Browse files
scgopiclaude
andcommitted
Add repositories on remote machines over SSH
Sidebar ⊕ → Add Remote Repository…: server, optional user/port, and an absolute path, validated before anything is saved — reachability under BatchMode (key auth or fail fast), the path being a git repository, and zmx being installed there — so each failure names itself in the sheet instead of surfacing later as a loop that silently does nothing. A remote project's identity is the URI ssh://user@host[:port]/path, following the precedent the global graph set: the daemon keys its graph by it, nothing collides with a local checkout, and everything that treats a path as a directory branches on RemoteProjectLocation.parse. The daemon stays local — starting an unattended loop wraps the same zmx argv in ssh with a remote login shell and a cd into the repository, and the app's surfaces attach with ssh -t, plain-shell tabs included. The opening prompt can't ride the local environment through sshd, so it is assigned inside the quoted remote command and expanded by the same "$GRAPHCODE_TRIGGER_PROMPT" the local path uses. V1 limitations, stated in docs/09 and the README: kill/message/usage stay local-only (their closures don't carry the project path yet), no briefing or worktrees remotely, and a sleeping Mac fires no edges. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 719dcb8 commit ba3c55b

13 files changed

Lines changed: 791 additions & 26 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import Foundation
2+
3+
/// A project whose working tree lives on another machine, reached over SSH — the
4+
/// docs/09-remote-repositories.md Phase B model, drawn on supacode's remote connections
5+
/// (shape, not code).
6+
///
7+
/// **The project path *is* the identity.** A remote project's path is the URI
8+
/// `ssh://user@host[:port]/absolute/path`, following the precedent the global graph set
9+
/// (`graphcode://global`): the daemon keys graphs by path string, the app keys sidebar
10+
/// rows by it, and a URI that embeds the host can never collide with a local folder or
11+
/// with the same folder on a different machine. Everything that treats a project path as
12+
/// a *directory* branches on `parse` returning non-nil.
13+
///
14+
/// **The daemon stays local.** Every remote effect is "run a command over ssh": the
15+
/// session is a `zmx` session on the remote host, started and attached through
16+
/// `sshInvocation`. What that buys — and what it costs (no edge firing while the Mac
17+
/// sleeps) — is docs/09's trade, made deliberately.
18+
public struct RemoteProjectLocation: Equatable, Sendable {
19+
public var user: String?
20+
public var host: String
21+
public var port: Int?
22+
/// Absolute path of the repository on the remote machine.
23+
public var remotePath: String
24+
25+
public init(user: String? = nil, host: String, port: Int? = nil, remotePath: String) {
26+
self.user = user
27+
self.host = host
28+
self.port = port
29+
self.remotePath = remotePath
30+
}
31+
32+
/// The reserved scheme. A path with this prefix is a remote project everywhere a
33+
/// project path travels.
34+
public static let scheme = "ssh"
35+
36+
/// Parses a project path, returning `nil` for anything that isn't a remote one —
37+
/// which is the branch every existing call site takes for ordinary folders.
38+
public static func parse(projectPath: String) -> RemoteProjectLocation? {
39+
guard projectPath.hasPrefix("\(scheme)://"),
40+
let components = URLComponents(string: projectPath),
41+
components.scheme == scheme,
42+
let host = components.host, !host.isEmpty,
43+
!components.path.isEmpty, components.path.hasPrefix("/")
44+
else { return nil }
45+
return RemoteProjectLocation(
46+
user: components.user, host: host, port: components.port, remotePath: components.path)
47+
}
48+
49+
/// The path string this location travels as — `parse`'s inverse.
50+
public var projectPath: String {
51+
"\(Self.scheme)://\(authority)\(remotePath)"
52+
}
53+
54+
/// `user@host` / `user@host:port` — what `ssh` is pointed at (port rides separately
55+
/// as `-p`, but the authority string carries it for identity and display).
56+
public var authority: String {
57+
let userPart = user.map { "\($0)@" } ?? ""
58+
let portPart = port.map { ":\($0)" } ?? ""
59+
return "\(userPart)\(host)\(portPart)"
60+
}
61+
62+
/// What ssh itself is told to connect to — the authority without the port.
63+
public var sshDestination: String {
64+
let userPart = user.map { "\($0)@" } ?? ""
65+
return "\(userPart)\(host)"
66+
}
67+
68+
/// The sidebar title: the repository folder's name, with the host to tell it apart
69+
/// from a local checkout of the same project.
70+
public var displayName: String {
71+
let leaf = remotePath.split(separator: "/").last.map(String.init) ?? remotePath
72+
return "\(leaf) @ \(host)"
73+
}
74+
75+
/// POSIX single-quote escaping — the one quoting rule that survives every layer here.
76+
/// ssh joins its command arguments with spaces and hands the result to the remote
77+
/// login shell, so every remote command is built as one string with each embedded
78+
/// value quoted by this; nesting works because a quoted string is itself safe to
79+
/// quote again.
80+
public static func shellQuoted(_ text: String) -> String {
81+
"'" + text.replacingOccurrences(of: "'", with: "'\\''") + "'"
82+
}
83+
84+
/// The local argv that runs `remoteCommand` on this host.
85+
///
86+
/// `BatchMode=yes` because nothing launched by the app or daemon has a tty to answer
87+
/// a password prompt — key auth or fail fast, the same rule the clone form applies.
88+
/// `interactive` adds `-t`: a terminal surface needs the remote side to have a tty
89+
/// (zmx attaches a full-screen session), where a launch/query must *not* take one.
90+
public func sshInvocation(remoteCommand: String, interactive: Bool = false) -> [String] {
91+
var invocation = ["/usr/bin/ssh"]
92+
if interactive { invocation.append("-t") }
93+
invocation += ["-o", "BatchMode=yes", "-o", "ConnectTimeout=10"]
94+
if let port { invocation += ["-p", String(port)] }
95+
invocation += [sshDestination, "--", remoteCommand]
96+
return invocation
97+
}
98+
99+
/// Wraps a remote command so it resolves `PATH` the way a human's terminal would —
100+
/// the remote twin of `ZmxSessionLauncher.loginShellInvocation`, and `-i` is
101+
/// load-bearing for the same reason: the remote `~/.zshrc` is what knows where `zmx`
102+
/// and the agent live, and zsh reads it only when interactive.
103+
public func remoteLoginShellCommand(_ script: String) -> String {
104+
"exec zsh -l -i -c \(Self.shellQuoted(script))"
105+
}
106+
}

GraphcodeKit/Sources/ProjectRegistry.swift

Lines changed: 11 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -208,16 +208,22 @@ public actor ProjectRegistry {
208208
return newStore
209209
}
210210

211-
/// The global graph's reserved path is a `graphcode://` URL, not a folder — running it
212-
/// through `fileURLWithPath` would mangle it into a relative path under the cwd and
213-
/// route its commands to a store that doesn't exist.
211+
/// The global graph's reserved path is a `graphcode://` URL, and a remote project's
212+
/// is an `ssh://` one — neither is a folder, and running either through
213+
/// `fileURLWithPath` would mangle it into a relative path under the cwd and route its
214+
/// commands to a store that doesn't exist.
214215
private static func canonicalize(_ path: String) -> String {
215-
guard path != LoopGraphScope.globalPath else { return path }
216+
guard path != LoopGraphScope.globalPath,
217+
RemoteProjectLocation.parse(projectPath: path) == nil
218+
else { return path }
216219
return URL(fileURLWithPath: path).resolvingSymlinksInPath().path
217220
}
218221

219222
private static func displayName(for path: String) -> String {
220-
URL(fileURLWithPath: path).lastPathComponent
223+
if let remote = RemoteProjectLocation.parse(projectPath: path) {
224+
return remote.displayName
225+
}
226+
return URL(fileURLWithPath: path).lastPathComponent
221227
}
222228

223229
// MARK: - Unicast reply

GraphcodeKit/Sources/Sessions/ZmxSessionLauncher.swift

Lines changed: 76 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -205,8 +205,13 @@ public enum ZmxSessionLauncher {
205205
// Read per launch, not cached: changing a setting in the app then applies to the very
206206
// next loop the daemon starts, with nothing to restart.
207207
let settings = GraphcodeSettingsStore.load()
208+
// No briefing for a remote project's session: the file is written on *this* machine
209+
// and the session runs on another, and the CLI it describes talks to a daemon the
210+
// remote host can't reach. A remote loop that can't fan out is a stated v1
211+
// limitation (docs/09-remote-repositories.md), not a silent failure.
212+
let remote = projectPath.flatMap { RemoteProjectLocation.parse(projectPath: $0) }
208213
let briefingFile =
209-
settings.briefsSessionsAboutTheGraph
214+
settings.briefsSessionsAboutTheGraph && remote == nil
210215
? SessionBriefing.write(projectPath: projectPath) : nil
211216
// Both the executable and the shape of its arguments come from the node's backend —
212217
// `claude` takes its prompt positionally and its briefing via `--append-system-prompt`,
@@ -249,9 +254,17 @@ public enum ZmxSessionLauncher {
249254
/// it was branched from — see `CopilotPermissions.readableDirectories`.
250255
///
251256
/// The global graph's reserved `graphcode://` path names no directory and is dropped.
257+
/// A remote project contributes its *remote* path — the directory as the session
258+
/// running on that host sees it, which is the one a path-verifying backend needs.
252259
static func workspacePaths(forNode node: LoopNode, projectPath: String?) -> [String] {
253260
var paths: [String] = []
254-
if let projectPath, !projectPath.hasPrefix("graphcode://") { paths.append(projectPath) }
261+
if let projectPath, !projectPath.hasPrefix("graphcode://") {
262+
if let remote = RemoteProjectLocation.parse(projectPath: projectPath) {
263+
paths.append(remote.remotePath)
264+
} else {
265+
paths.append(projectPath)
266+
}
267+
}
255268
if let worktree = node.worktreeBinding?.worktreePath { paths.append(worktree) }
256269
return paths
257270
}
@@ -303,7 +316,68 @@ public enum ZmxSessionLauncher {
303316
return projectPath
304317
}
305318

319+
// MARK: - Remote projects
320+
321+
/// The local `ssh` argv that checks whether a remote node's session exists — the same
322+
/// `zmx get` exit-status test as local, run on the host the session lives on.
323+
static func remoteExistenceInvocation(
324+
forNode node: LoopNode, at location: RemoteProjectLocation
325+
) -> [String] {
326+
let script = quotedCommand(["zmx"] + existenceCheckArguments(forNode: node))
327+
return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script))
328+
}
329+
330+
/// The local `ssh` argv that starts a remote node's session, or `nil` when the node
331+
/// has nothing to run. The zmx argv is exactly the local one — session name, detach,
332+
/// nested login shell, backend command — assembled into one quoted string, because ssh
333+
/// joins its arguments with spaces and hands them to the remote shell. `cd` first so
334+
/// the session opens in the repository, the remote twin of `workingDirectory`.
335+
static func remoteLaunchInvocation(
336+
forNode node: LoopNode, at location: RemoteProjectLocation
337+
) -> [String]? {
338+
guard let zmxArguments = arguments(forNode: node, projectPath: location.projectPath)
339+
else { return nil }
340+
let script =
341+
"cd \(RemoteProjectLocation.shellQuoted(location.remotePath)) && "
342+
+ quotedCommand(["zmx"] + zmxArguments)
343+
return location.sshInvocation(remoteCommand: location.remoteLoginShellCommand(script))
344+
}
345+
346+
/// One argv as one shell-safe string — each argument quoted, so a prompt containing
347+
/// quotes, `$(…)`, or `;` stays one word through the remote shell exactly as it does
348+
/// through zmx's own quoting locally. Public because the app's remote *attach* is
349+
/// built from the same pieces (`GhosttyTerminalView.remoteCommand`).
350+
public static func quotedCommand(_ argv: [String]) -> String {
351+
argv.map(RemoteProjectLocation.shellQuoted).joined(separator: " ")
352+
}
353+
354+
private static func startRemote(_ node: LoopNode, at location: RemoteProjectLocation) async {
355+
guard let launch = remoteLaunchInvocation(forNode: node, at: location) else { return }
356+
let existence = remoteExistenceInvocation(forNode: node, at: location)
357+
do {
358+
// Create only, same as local: a live remote session is the loop still running,
359+
// and re-sending the command would type into it.
360+
let existing = try PTYProcessSession(
361+
executable: existence[0], arguments: Array(existence.dropFirst()))
362+
guard await !existing.waitUntilFinished() else { return }
363+
364+
let session = try PTYProcessSession(
365+
executable: launch[0], arguments: Array(launch.dropFirst()))
366+
_ = await session.waitUntilFinished()
367+
} catch {
368+
// Same posture as the local path: no UI here, the node's state stays honest, and
369+
// opening the loop retries.
370+
return
371+
}
372+
}
373+
306374
static func start(_ node: LoopNode, projectPath: String? = nil) async {
375+
// A remote project's session starts on the remote host — local zmx isn't involved
376+
// and doesn't need to be installed for it.
377+
if let projectPath, let remote = RemoteProjectLocation.parse(projectPath: projectPath) {
378+
await startRemote(node, at: remote)
379+
return
380+
}
307381
guard ZmxLocator.isInstalled else { return }
308382
guard let arguments = arguments(forNode: node, projectPath: projectPath) else { return }
309383

README.md

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,9 +77,11 @@ current release.
7777

7878
## Using it
7979

80-
1. **Add a folder** — the sidebar's ⊕ menu. It becomes a project with its own graph. Whatever
81-
was open is restored next launch; right-click a project to Close, Remove, or delete its
82-
loops.
80+
1. **Add a project** — the sidebar's ⊕ menu: open a local folder, **clone a repository
81+
from a URL**, or **add a remote repository over SSH** (key auth and zmx on the server
82+
required — loops then run on the server while this Mac steers them). Each becomes a
83+
project with its own graph. Whatever was open is restored next launch; right-click a
84+
project to Close, Remove, or delete its loops.
8385
2. **Create a loop** — ⊕ on the canvas. Write the prompt and hit Create: the form opens
8486
on goal-based, and the title is optional — leave it blank and GraphCode asks the loop's
8587
own backend for a name. For a time-based loop put the cadence in the prompt itself:
@@ -151,6 +153,11 @@ Design docs live in `docs/` and are kept local (gitignored) for now.
151153
instance) — the queued command runs only after the profile finishes.
152154
- **Sessions aren't reaped.** Long-lived `graphcode-*` zmx sessions accumulate; list them
153155
with `zmx list` and remove dead ones with `zmx kill`.
156+
- **Remote repositories are attach-first.** Loops on an SSH remote launch and attach
157+
fine, but deleting one doesn't kill its remote session, message edges into remote
158+
loops report undelivered, presence/usage read "not reported", and worktrees and
159+
loop fan-out aren't available there yet. And a sleeping Mac fires no edges — the
160+
daemon stays local by design.
154161

155162
## Inspiration & third-party
156163

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import Dependencies
2+
import Foundation
3+
import GraphcodeKit
4+
5+
/// Validates a remote repository connection before it is saved — reachability, the
6+
/// path being a git repository, and the session stack (`zmx`) being installed there.
7+
/// Run up front, in the form, because every one of these failures is otherwise
8+
/// discovered as a loop that silently does nothing: the connection that passes is one
9+
/// whose sessions can actually start.
10+
struct RemoteRepositoryClient: Sendable {
11+
/// `nil` when the connection is usable; otherwise the reason it isn't, worded for the
12+
/// sheet's footer.
13+
var validate: @Sendable (RemoteProjectLocation) async -> String?
14+
}
15+
16+
extension RemoteRepositoryClient: DependencyKey {
17+
static let liveValue = RemoteRepositoryClient { location in
18+
let quoted = RemoteProjectLocation.shellQuoted
19+
// Ordered so the first failure names the actual problem: an unreachable host would
20+
// otherwise read as "not a repository", which sends someone debugging the wrong
21+
// thing. BatchMode in `sshInvocation` means a password-only host fails here, fast —
22+
// key auth is a requirement, not a preference, since nothing later has a tty either.
23+
let checks: [(command: String, failure: String)] = [
24+
(
25+
"true",
26+
"Can't reach \(location.sshDestination) — check the host, and that key "
27+
+ "authentication works (`ssh \(location.sshDestination)` from a terminal)."
28+
),
29+
(
30+
"test -d \(quoted(location.remotePath))",
31+
"\(location.remotePath) doesn't exist on \(location.host)."
32+
),
33+
(
34+
"git -C \(quoted(location.remotePath)) rev-parse --is-inside-work-tree",
35+
"\(location.remotePath) isn't a git repository."
36+
),
37+
(
38+
location.remoteLoginShellCommand("command -v zmx"),
39+
"zmx isn't installed on \(location.host) — sessions live in it. "
40+
+ "Install it there and try again."
41+
),
42+
]
43+
for check in checks {
44+
let invocation = location.sshInvocation(remoteCommand: check.command)
45+
guard await succeeds(invocation) else { return check.failure }
46+
}
47+
return nil
48+
}
49+
50+
/// Form tests have no network and should not discover that by hanging.
51+
static let testValue = RemoteRepositoryClient { _ in nil }
52+
53+
private static func succeeds(_ invocation: [String]) async -> Bool {
54+
let process = Process()
55+
process.executableURL = URL(fileURLWithPath: invocation[0])
56+
process.arguments = Array(invocation.dropFirst())
57+
process.standardOutput = FileHandle.nullDevice
58+
process.standardError = FileHandle.nullDevice
59+
do {
60+
try process.run()
61+
} catch {
62+
return false
63+
}
64+
return await withCheckedContinuation { continuation in
65+
process.terminationHandler = { process in
66+
continuation.resume(returning: process.terminationStatus == 0)
67+
}
68+
}
69+
}
70+
}
71+
72+
extension DependencyValues {
73+
var remoteRepositoryClient: RemoteRepositoryClient {
74+
get { self[RemoteRepositoryClient.self] }
75+
set { self[RemoteRepositoryClient.self] = newValue }
76+
}
77+
}

graphcode/Sources/Features/App/AppSidebarView.swift

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,14 @@ struct AppSidebarView: View {
9595
) {
9696
CloneRepositoryFormView(store: store.scope(state: \.welcome, action: \.welcome))
9797
}
98+
.sheet(
99+
isPresented: Binding(
100+
get: { store.welcome.remoteDraft != nil },
101+
set: { if !$0 { store.send(.welcome(.remoteCancelled)) } }
102+
)
103+
) {
104+
RemoteRepositoryFormView(store: store.scope(state: \.welcome, action: \.welcome))
105+
}
98106
.confirmationDialog(
99107
"Delete this project's loops?",
100108
isPresented: Binding(
@@ -143,6 +151,12 @@ struct AppSidebarView: View {
143151
} label: {
144152
Label("Clone Repository…", systemImage: "square.and.arrow.down.on.square")
145153
}
154+
// A repository on another machine, over SSH — loops run there, this Mac steers.
155+
Button {
156+
store.send(.welcome(.addRemoteRepositoryButtonTapped))
157+
} label: {
158+
Label("Add Remote Repository…", systemImage: "network")
159+
}
146160
if !store.welcome.recentProjects.isEmpty {
147161
Divider()
148162
ForEach(store.welcome.recentProjects) { project in

graphcode/Sources/Features/LoopWorkspace/LoopWorkspaceView.swift

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,8 +190,10 @@ struct LoopWorkspaceView: View {
190190
// should open there, not wherever the app process happened to launch from.
191191
workingDirectory: store.node.worktreeBinding?.worktreePath ?? store.projectPath,
192192
// The graph's own project, for the session briefing — deliberately not the
193-
// worktree, whose path names a graph that doesn't exist.
194-
projectPath: ref.launchesClaudeCode ? store.projectPath : nil,
193+
// worktree, whose path names a graph that doesn't exist. Every surface gets it,
194+
// not just the agent's: a remote project's plain-shell tabs open on the remote
195+
// host, and which host that is lives in this path.
196+
projectPath: store.projectPath,
195197
// Only *one* surface in the whole workspace is the live one: the showing tab's
196198
// focused pane. Every other surface stays mounted and must not hold the keyboard —
197199
// including the other half of this tab's own split, which is what stops both panes

0 commit comments

Comments
 (0)