Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ let package = Package(
products: [
.library(name: "TesseraKit", targets: ["TesseraKit"]),
.library(name: "TesseraHost", targets: ["TesseraHost"]),
.executable(name: "Tessera", targets: ["TesseraMac"])
.executable(name: "Tessera", targets: ["TesseraMac"]),
.executable(name: "tessera-cli", targets: ["TesseraCLI"])
],
dependencies: [
.package(url: "https://github.com/migueldeicaza/SwiftTerm", from: "1.9.0"),
Expand All @@ -20,17 +21,22 @@ let package = Package(
name: "TesseraKit",
dependencies: [.product(name: "SwiftTerm", package: "SwiftTerm")]
),
// Where Tessera's agent API is found; shared by the app and the `tessera` command, which
// stays Foundation-only.
.target(name: "TesseraAPI"),
// macOS-only: owns processes, watches agent apps, places windows, serves remotes.
.target(
name: "TesseraHost",
dependencies: ["TesseraKit", .product(name: "SwiftTerm", package: "SwiftTerm"),
dependencies: ["TesseraKit", "TesseraAPI", .product(name: "SwiftTerm", package: "SwiftTerm"),
.product(name: "libzstd", package: "zstd")]
),
// `tessera`: agents' command line and stdio MCP server, talking to the running app.
.executableTarget(name: "TesseraCLI", dependencies: ["TesseraAPI"]),
.executableTarget(
name: "TesseraMac",
dependencies: ["TesseraKit", "TesseraHost", .product(name: "SwiftTerm", package: "SwiftTerm")]
),
.testTarget(name: "TesseraKitTests", dependencies: ["TesseraKit", "TesseraHost",
.testTarget(name: "TesseraKitTests", dependencies: ["TesseraKit", "TesseraHost", "TesseraAPI",
.product(name: "libzstd", package: "zstd")])
],
swiftLanguageModes: [.v5]
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,23 @@ Native macOS app, with a focused iOS companion built on the same core.
silent job lands in the Needs-you lane and notifies you, and so does a lease that is running out
or whose restore failed. Opening a job goes to the agent's own conversation when it reported one
(`--session`). The Jobs chip filters them. The admin key stays in your Keychain.
- **Steward (Settings → Agents).** Point it at any OpenAI-compatible endpoint, such as a LLooM
gateway with a local model, and it keeps a one-line summary of each tile on the card's footer.
At the head of the Needs-you lane it writes a brief: what is going on in a sentence, what
finished, failed or started asking since you last looked, what needs you, and what can be put
away (Close all, with Undo). Tile text is sent with credentials removed, and nothing is sent in
privacy mode. It only suggests.
- **Agents talk to Tessera, and through it to each other.** A `tessera` command, also an MCP
server (`tessera mcp`), lets any agent on the Mac see the board (`board`), read another tile
(`read`), find itself (`whoami`), message another agent (`send`, `inbox`), ask you something
(`ask`) and leave you a note (`notify`).
- Questions land in the Needs-you lane, as alerts and on the phone, with the agent's options as
one-tap answers and a reply field.
- A message is typed into a terminal agent's prompt only with your OK, unless you allow it
outright.
- It listens on 127.0.0.1 behind a token in a file only you can read; browser requests are refused.
- Settings → Agents → Install puts `tessera` on your PATH, installs the skill
(`skills/tessera`) for Claude Code and Codex, and registers the MCP server.
- **Shut down & resume.** Quit Tessera (or shut tiles down) and every agent comes back in the same
conversation — including ones you typed into a shell yourself, and through your own launchers
(`codex-work` resumes as `codex-work resume <id>`). A tile that is shut down, exited or failed
Expand Down
70 changes: 70 additions & 0 deletions Sources/TesseraAPI/AgentEndpoint.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import Foundation

/// Where a running Tessera listens for agents on this Mac, and the token they must show. Tessera
/// writes it (readable by this user only) when its agent API starts; the `tessera` command reads it.
public struct AgentEndpoint: Codable, Equatable, Sendable {
public var port: UInt16
public var token: String
/// The Tessera process serving it, so a file left behind by a crash is told apart.
public var pid: Int32
public var version: Int

public static let currentVersion = 1
public static let fileName = "agent-api.json"

public init(port: UInt16, token: String, pid: Int32, version: Int = Self.currentVersion) {
self.port = port
self.token = token
self.pid = pid
self.version = version
}

public var url: URL { URL(string: "http://127.0.0.1:\(port)/rpc")! }

/// Tessera's data folder: `TESSERA_DATA_DIR` (a development copy), else Application Support.
public static func dataDirectory(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL {
if let override = environment["TESSERA_DATA_DIR"], !override.isEmpty { return URL(fileURLWithPath: override) }
return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0].appendingPathComponent("Tessera")
}

public static func file(in directory: URL) -> URL { directory.appendingPathComponent(fileName) }

public static func read(from directory: URL = dataDirectory()) -> AgentEndpoint? {
guard let data = try? Data(contentsOf: file(in: directory)) else { return nil }
return try? JSONDecoder().decode(AgentEndpoint.self, from: data)
}

/// Written atomically and readable by this user only: the token is a credential.
public func write(to directory: URL) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
let url = Self.file(in: directory)
try JSONEncoder().encode(self).write(to: url, options: [.atomic])
try FileManager.default.setAttributes([.posixPermissions: 0o600], ofItemAtPath: url.path)
}

public static func remove(from directory: URL, ifPid pid: Int32) {
if read(from: directory)?.pid == pid { try? FileManager.default.removeItem(at: file(in: directory)) }
}
}

/// Who is calling: what the `tessera` command knows about the agent that ran it. Sent with every
/// call (in the request's `_meta`), and used to work out which tile the agent is.
public struct AgentCaller: Codable, Equatable, Sendable {
/// `TESSERA_TILE_ID`, which every terminal Tessera starts carries.
public var tile: String?
public var cwd: String?
/// A name the agent gives itself (`--as`, `TESSERA_AS`), for when no tile can be found.
public var name: String?

public init(tile: String? = nil, cwd: String? = nil, name: String? = nil) {
self.tile = tile
self.cwd = cwd
self.name = name
}

public static func current(environment: [String: String] = ProcessInfo.processInfo.environment, name: String? = nil) -> AgentCaller {
AgentCaller(tile: environment["TESSERA_TILE_ID"].flatMap { $0.isEmpty ? nil : $0 },
cwd: FileManager.default.currentDirectoryPath,
name: name ?? environment["TESSERA_AS"].flatMap { $0.isEmpty ? nil : $0 })
}
}
117 changes: 117 additions & 0 deletions Sources/TesseraCLI/Installer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import Foundation

/// `tessera install`: makes Tessera reachable from every agent on this Mac.
/// - `~/.local/bin/tessera` points at this command;
/// - the skills shipped beside it go to `~/.agents/skills` (read by Codex and other agents), linked
/// from `~/.claude/skills` for Claude Code;
/// - `tessera mcp` is registered as an MCP server with Claude Code (`claude mcp add --scope user`)
/// and Codex (`[mcp_servers.tessera]` in `~/.codex/config.toml`, backed up first).
/// Each step says what it did; `--dry-run` only says what it would do.
struct Installer {
let dryRun: Bool
let fm = FileManager.default
let home = URL(fileURLWithPath: NSHomeDirectory())

var executable: URL {
let path = CommandLine.arguments[0]
let url = path.contains("/") ? URL(fileURLWithPath: path) : (Self.which(path) ?? URL(fileURLWithPath: path))
return url.resolvingSymlinksInPath()
}

/// Beside the command in the app (Contents/Helpers → Contents/Resources/skills), or in a
/// source checkout (.build/<config>/ → skills/).
var skillsSource: URL? {
let dir = executable.deletingLastPathComponent()
let candidates = [ProcessInfo.processInfo.environment["TESSERA_SKILLS_DIR"].map { URL(fileURLWithPath: $0) },
dir.appendingPathComponent("../Resources/skills"),
dir.appendingPathComponent("../../skills"),
dir.appendingPathComponent("../../../skills")].compactMap { $0 }
return candidates.map(\.standardizedFileURL).first { fm.fileExists(atPath: $0.appendingPathComponent("tessera/SKILL.md").path) }
}

func say(_ text: String) { print((dryRun ? "would: " : "") + text) }

func run() throws {
let bin = home.appendingPathComponent(".local/bin/tessera")
try link(bin, to: executable)

if let source = skillsSource {
for name in try fm.contentsOfDirectory(atPath: source.path).sorted() where !name.hasPrefix(".") {
let target = home.appendingPathComponent(".agents/skills/\(name)")
say("install skill \(name) → \(target.path)")
if !dryRun {
try fm.createDirectory(at: target.deletingLastPathComponent(), withIntermediateDirectories: true)
if fm.fileExists(atPath: target.path) { try fm.removeItem(at: target) }
try fm.copyItem(at: source.appendingPathComponent(name), to: target)
}
try link(home.appendingPathComponent(".claude/skills/\(name)"), to: target, relative: "../../.agents/skills/\(name)")
}
} else {
say("skip skills: none found beside \(executable.path) (set TESSERA_SKILLS_DIR)")
}

if let claude = Self.which("claude") {
say("register MCP server \"tessera\" with Claude Code (user scope)")
if !dryRun {
_ = Self.run(claude, ["mcp", "remove", "--scope", "user", "tessera"])
let (status, output) = Self.run(claude, ["mcp", "add", "--scope", "user", "tessera", "--", bin.path, "mcp"])
if status != 0 { print(" claude mcp add failed: \(output.trimmingCharacters(in: .whitespacesAndNewlines))") }
}
} else {
say("skip Claude Code: `claude` not found")
}

let codex = home.appendingPathComponent(".codex/config.toml")
if let config = try? String(contentsOf: codex, encoding: .utf8) {
if config.contains("[mcp_servers.tessera]") {
say("leave Codex's [mcp_servers.tessera] as it is")
} else {
say("add [mcp_servers.tessera] to \(codex.path) (backup: config.toml.bak-tessera)")
if !dryRun {
try? fm.removeItem(at: codex.appendingPathExtension("bak-tessera"))
try fm.copyItem(at: codex, to: URL(fileURLWithPath: codex.path + ".bak-tessera"))
let block = "\n[mcp_servers.tessera]\ncommand = \"\(bin.path)\"\nargs = [\"mcp\"]\ntool_timeout_sec = 70\n"
try (config + (config.hasSuffix("\n") ? "" : "\n") + block).write(to: codex, atomically: true, encoding: .utf8)
}
}
} else {
say("skip Codex: no ~/.codex/config.toml")
}
print(dryRun ? "Nothing changed." : "Done. Restart agent sessions to pick up the MCP server and skills.")
}

/// A symlink at `path` to `target`, replacing an older symlink but never a real file.
func link(_ path: URL, to target: URL, relative: String? = nil) throws {
let destination = relative ?? target.path
if let existing = try? fm.destinationOfSymbolicLink(atPath: path.path) {
if existing == destination { return say("keep \(path.path) → \(destination)") }
say("relink \(path.path) → \(destination)")
if !dryRun { try fm.removeItem(at: path) }
} else if fm.fileExists(atPath: path.path) {
return say("leave \(path.path): it is a real file, not a link Tessera made")
} else {
say("link \(path.path) → \(destination)")
}
guard !dryRun else { return }
try fm.createDirectory(at: path.deletingLastPathComponent(), withIntermediateDirectories: true)
try fm.createSymbolicLink(atPath: path.path, withDestinationPath: destination)
}

static func which(_ name: String) -> URL? {
let path = (ProcessInfo.processInfo.environment["PATH"] ?? "") + ":\(NSHomeDirectory())/.local/bin:/opt/homebrew/bin:/usr/local/bin"
return path.split(separator: ":").map { URL(fileURLWithPath: String($0)).appendingPathComponent(name) }
.first { FileManager.default.isExecutableFile(atPath: $0.path) }
}

static func run(_ tool: URL, _ arguments: [String]) -> (Int32, String) {
let process = Process()
process.executableURL = tool
process.arguments = arguments
let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe
do { try process.run() } catch { return (-1, error.localizedDescription) }
process.waitUntilExit()
return (process.terminationStatus, String(decoding: pipe.fileHandleForReading.readDataToEndOfFile(), as: UTF8.self))
}
}
Loading