diff --git a/apps/macos/Sources/Clawdis/AgentWorkspace.swift b/apps/macos/Sources/Clawdis/AgentWorkspace.swift new file mode 100644 index 000000000..e3f308e1c --- /dev/null +++ b/apps/macos/Sources/Clawdis/AgentWorkspace.swift @@ -0,0 +1,54 @@ +import Foundation +import OSLog + +enum AgentWorkspace { + private static let logger = Logger(subsystem: "com.steipete.clawdis", category: "workspace") + static let agentsFilename = "AGENTS.md" + + static func displayPath(for url: URL) -> String { + let home = FileManager.default.homeDirectoryForCurrentUser.path + let path = url.path + if path == home { return "~" } + if path.hasPrefix(home + "/") { + return "~/" + String(path.dropFirst(home.count + 1)) + } + return path + } + + static func resolveWorkspaceURL(from userInput: String?) -> URL { + let trimmed = userInput?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { return ClawdisConfigFile.defaultWorkspaceURL() } + let expanded = (trimmed as NSString).expandingTildeInPath + return URL(fileURLWithPath: expanded, isDirectory: true) + } + + static func agentsURL(workspaceURL: URL) -> URL { + workspaceURL.appendingPathComponent(self.agentsFilename) + } + + static func bootstrap(workspaceURL: URL) throws -> URL { + try FileManager.default.createDirectory(at: workspaceURL, withIntermediateDirectories: true) + let agentsURL = self.agentsURL(workspaceURL: workspaceURL) + if !FileManager.default.fileExists(atPath: agentsURL.path) { + try self.defaultTemplate().write(to: agentsURL, atomically: true, encoding: .utf8) + self.logger.info("Created AGENTS.md at \(agentsURL.path, privacy: .public)") + } + return agentsURL + } + + static func defaultTemplate() -> String { + """ + # AGENTS.md — Clawdis Workspace + + This folder is the assistant’s working directory. + + ## Safety defaults + - Don’t exfiltrate secrets or private data. + - Don’t run destructive commands unless explicitly asked. + - Be concise in chat; write longer output to files in this workspace. + + ## Customize + - Add your preferred style, rules, and “memory” here. + """ + } +} diff --git a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift index cc161b580..173cda327 100644 --- a/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift +++ b/apps/macos/Sources/Clawdis/ClawdisConfigFile.swift @@ -7,6 +7,12 @@ enum ClawdisConfigFile { .appendingPathComponent("clawdis.json") } + static func defaultWorkspaceURL() -> URL { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".clawdis") + .appendingPathComponent("workspace", isDirectory: true) + } + static func loadDict() -> [String: Any] { let url = self.url() guard let data = try? Data(contentsOf: url) else { return [:] } @@ -37,4 +43,23 @@ enum ClawdisConfigFile { root["browser"] = browser self.saveDict(root) } + + static func inboundWorkspace() -> String? { + let root = self.loadDict() + let inbound = root["inbound"] as? [String: Any] + return inbound?["workspace"] as? String + } + + static func setInboundWorkspace(_ workspace: String?) { + var root = self.loadDict() + var inbound = root["inbound"] as? [String: Any] ?? [:] + let trimmed = workspace?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + if trimmed.isEmpty { + inbound.removeValue(forKey: "workspace") + } else { + inbound["workspace"] = trimmed + } + root["inbound"] = inbound + self.saveDict(root) + } } diff --git a/apps/macos/Sources/Clawdis/Constants.swift b/apps/macos/Sources/Clawdis/Constants.swift index dc0965425..569380849 100644 --- a/apps/macos/Sources/Clawdis/Constants.swift +++ b/apps/macos/Sources/Clawdis/Constants.swift @@ -2,7 +2,7 @@ import Foundation let launchdLabel = "com.steipete.clawdis" let onboardingVersionKey = "clawdis.onboardingVersion" -let currentOnboardingVersion = 4 +let currentOnboardingVersion = 5 let pauseDefaultsKey = "clawdis.pauseEnabled" let iconAnimationsEnabledKey = "clawdis.iconAnimationsEnabled" let swabbleEnabledKey = "clawdis.swabbleEnabled" diff --git a/apps/macos/Sources/Clawdis/Onboarding.swift b/apps/macos/Sources/Clawdis/Onboarding.swift index 7f2bc3603..3be173077 100644 --- a/apps/macos/Sources/Clawdis/Onboarding.swift +++ b/apps/macos/Sources/Clawdis/Onboarding.swift @@ -48,22 +48,35 @@ struct OnboardingView: View { @State private var monitoringDiscovery = false @State private var cliInstalled = false @State private var cliInstallLocation: String? + @State private var workspacePath: String = "" + @State private var workspaceStatus: String? + @State private var workspaceApplying = false @State private var gatewayStatus: GatewayEnvironmentStatus = .checking @State private var gatewayInstalling = false @State private var gatewayInstallMessage: String? // swiftlint:disable:next inclusive_language - @StateObject private var masterDiscovery = MasterDiscoveryModel() - @ObservedObject private var state = AppStateStore.shared - @ObservedObject private var permissionMonitor = PermissionMonitor.shared + @StateObject private var masterDiscovery: MasterDiscoveryModel + @ObservedObject private var state: AppState + @ObservedObject private var permissionMonitor: PermissionMonitor private let pageWidth: CGFloat = 680 private let contentHeight: CGFloat = 520 private let connectionPageIndex = 1 private let permissionsPageIndex = 3 - private var pageCount: Int { 7 } + private var pageCount: Int { 8 } private var buttonTitle: String { self.currentPage == self.pageCount - 1 ? "Finish" : "Next" } private let devLinkCommand = "ln -sf $(pwd)/apps/macos/.build/debug/ClawdisCLI /usr/local/bin/clawdis-mac" + init( + state: AppState = AppStateStore.shared, + permissionMonitor: PermissionMonitor = .shared, + masterDiscovery: MasterDiscoveryModel = MasterDiscoveryModel()) + { + self._state = ObservedObject(wrappedValue: state) + self._permissionMonitor = ObservedObject(wrappedValue: permissionMonitor) + self._masterDiscovery = StateObject(wrappedValue: masterDiscovery) + } + var body: some View { VStack(spacing: 0) { GlowingClawdisIcon(size: 156) @@ -78,6 +91,7 @@ struct OnboardingView: View { self.gatewayPage().frame(width: self.pageWidth) self.permissionsPage().frame(width: self.pageWidth) self.cliPage().frame(width: self.pageWidth) + self.workspacePage().frame(width: self.pageWidth) self.whatsappPage().frame(width: self.pageWidth) self.readyPage().frame(width: self.pageWidth) } @@ -112,6 +126,7 @@ struct OnboardingView: View { await self.refreshPerms() self.refreshCLIStatus() self.refreshGatewayStatus() + self.loadWorkspaceDefaults() } } @@ -414,6 +429,90 @@ struct OnboardingView: View { } } + private func workspacePage() -> some View { + self.onboardingPage { + Text("Agent workspace") + .font(.largeTitle.weight(.semibold)) + Text( + """ + Clawdis runs the agent from a dedicated workspace so it can load AGENTS.md + and write files without touching your other folders. + """) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + .fixedSize(horizontal: false, vertical: true) + + self.onboardingCard(spacing: 10) { + if self.state.connectionMode == .remote { + Text("Remote gateway detected") + .font(.headline) + Text( + "Create the workspace on the remote host (SSH in first). " + + "The macOS app can’t write files on your gateway over SSH yet.") + .font(.subheadline) + .foregroundStyle(.secondary) + + Button(self.copied ? "Copied" : "Copy setup command") { + self.copyToPasteboard(self.workspaceBootstrapCommand) + } + .buttonStyle(.bordered) + } else { + VStack(alignment: .leading, spacing: 8) { + Text("Workspace folder") + .font(.headline) + TextField( + AgentWorkspace.displayPath(for: ClawdisConfigFile.defaultWorkspaceURL()), + text: self.$workspacePath) + .textFieldStyle(.roundedBorder) + + HStack(spacing: 12) { + Button { + Task { await self.applyWorkspace() } + } label: { + if self.workspaceApplying { + ProgressView() + } else { + Text("Create workspace") + } + } + .buttonStyle(.borderedProminent) + .disabled(self.workspaceApplying) + + Button("Open folder") { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + NSWorkspace.shared.open(url) + } + .buttonStyle(.bordered) + .disabled(self.workspaceApplying) + + Button("Save in config") { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + ClawdisConfigFile.setInboundWorkspace(AgentWorkspace.displayPath(for: url)) + self.workspaceStatus = "Saved to ~/.clawdis/clawdis.json (inbound.workspace)" + } + .buttonStyle(.bordered) + .disabled(self.workspaceApplying) + } + } + + if let workspaceStatus { + Text(workspaceStatus) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } else { + Text("Tip: edit AGENTS.md in this folder to shape the assistant’s behavior.") + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + } + } + } + private func whatsappPage() -> some View { self.onboardingPage { Text("Link WhatsApp or Telegram") @@ -755,6 +854,38 @@ struct OnboardingView: View { self.copied = true DispatchQueue.main.asyncAfter(deadline: .now() + 1.2) { self.copied = false } } + + private func loadWorkspaceDefaults() { + guard self.workspacePath.isEmpty else { return } + let configured = ClawdisConfigFile.inboundWorkspace() + let url = AgentWorkspace.resolveWorkspaceURL(from: configured) + self.workspacePath = AgentWorkspace.displayPath(for: url) + } + + private var workspaceBootstrapCommand: String { + let template = AgentWorkspace.defaultTemplate().trimmingCharacters(in: .whitespacesAndNewlines) + return """ + mkdir -p ~/.clawdis/workspace + cat > ~/.clawdis/workspace/AGENTS.md <<'EOF' + \(template) + EOF + """ + } + + private func applyWorkspace() async { + guard !self.workspaceApplying else { return } + self.workspaceApplying = true + defer { self.workspaceApplying = false } + + do { + let url = AgentWorkspace.resolveWorkspaceURL(from: self.workspacePath) + _ = try AgentWorkspace.bootstrap(workspaceURL: url) + self.workspacePath = AgentWorkspace.displayPath(for: url) + self.workspaceStatus = "Workspace ready at \(self.workspacePath)" + } catch { + self.workspaceStatus = "Failed to create workspace: \(error.localizedDescription)" + } + } } private struct GlowingClawdisIcon: View { diff --git a/docs/AGENTS.default.md b/docs/AGENTS.default.md index dcb91ccbc..3609d2cd3 100644 --- a/docs/AGENTS.default.md +++ b/docs/AGENTS.default.md @@ -8,26 +8,26 @@ read_when: ## First run (recommended) -1) Create a dedicated workspace for your assistant (where it can read/write files): +Clawdis uses a dedicated workspace directory for the agent. Default: `~/.clawdis/workspace`. + +1) Create the workspace (if it doesn’t already exist): ```bash -mkdir -p ~/clawd +mkdir -p ~/.clawdis/workspace ``` -2) Copy this template to your workspace root as `AGENTS.md`: +2) Copy this template into the workspace as `AGENTS.md` (overwrites any existing file): ```bash -cp docs/AGENTS.default.md ~/clawd/AGENTS.md +cp docs/AGENTS.default.md ~/.clawdis/workspace/AGENTS.md ``` -3) Point CLAWDIS at that workspace so Pi runs with the right context: +3) Optional: choose a different workspace by setting `inbound.workspace` (supports `~`): ```json5 { inbound: { - reply: { - cwd: "~/clawd" - } + workspace: "~/clawd" } } ``` diff --git a/docs/agents.md b/docs/agents.md index 0b4d7291e..d6ba9f3a6 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -17,7 +17,7 @@ If you don’t configure `inbound.reply`, CLAWDIS uses the bundled Pi binary in This is usually enough for a personal assistant setup; add `inbound.allowFrom` to restrict who can trigger it. -If you keep an `AGENTS.md` (and optional “memory” files) for the agent, set `inbound.reply.cwd` to that workspace so Pi runs with the right context. +If you keep an `AGENTS.md` (and optional “memory” files) for the agent, set `inbound.workspace` (preferred) or `inbound.reply.cwd` so Pi runs with the right context. ## Custom agent command (still Pi) diff --git a/docs/clawd.md b/docs/clawd.md index bea9b823a..f8567ad4b 100644 --- a/docs/clawd.md +++ b/docs/clawd.md @@ -90,23 +90,23 @@ Now message the assistant number from your allowlisted phone. ## Give the agent a workspace (AGENTS.md) -Pi (the bundled coding agent) will read operating instructions and “memory” from the current working directory. For a good first-run experience, create a dedicated workspace and drop an `AGENTS.md` there. +Pi (the bundled coding agent) will read operating instructions and “memory” from its current working directory. + +By default, Clawdis uses `~/.clawdis/workspace` as the agent workspace, and will create it (plus a starter `AGENTS.md`) automatically on first agent run. From the CLAWDIS repo: ```bash -mkdir -p ~/clawd -cp docs/AGENTS.default.md ~/clawd/AGENTS.md +mkdir -p ~/.clawdis/workspace +cp docs/AGENTS.default.md ~/.clawdis/workspace/AGENTS.md ``` -Then set `inbound.reply.cwd` to that directory (supports `~`): +Optional: choose a different workspace with `inbound.workspace` (supports `~`). `inbound.reply.cwd` still works and overrides it. ```json5 { inbound: { - reply: { - cwd: "~/clawd" - } + workspace: "~/clawd" } } ``` @@ -133,8 +133,6 @@ Example: mode: "command", // Pi is bundled; CLAWDIS forces --mode rpc for Pi runs. command: ["pi", "--mode", "rpc", "{{BodyStripped}}"], - // Run the agent from your dedicated workspace (AGENTS.md, memory files, etc). - cwd: "~/clawd", timeoutSeconds: 1800, bodyPrefix: "/think:high ", session: { diff --git a/docs/configuration.md b/docs/configuration.md index fbc33d3d2..2538b2b20 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -77,13 +77,15 @@ Example command-mode config: ```json5 { inbound: { + // Preferred: the agent workspace directory (used as default cwd for agent runs; supports ~). + workspace: "~/.clawdis/workspace", reply: { mode: "command", // Example: run the bundled agent (Pi) in RPC mode command: ["pi", "--mode", "rpc", "{{BodyStripped}}"], - // Optional: run the agent from a specific working directory (supports ~). - // Useful when you keep an AGENTS.md + memory files in a dedicated workspace. - cwd: "~/clawd", + // Optional override: working directory for this reply command (supports ~). + // If omitted, `inbound.workspace` is used. + cwd: "~/.clawdis/workspace", timeoutSeconds: 1800, heartbeatMinutes: 30, // Optional: override the command used for heartbeat runs @@ -107,8 +109,8 @@ Example command-mode config: ``` Notes: -- `inbound.reply.cwd` sets the working directory for the reply command (and Pi RPC). It supports `~` and is resolved to an absolute path. -- If you don’t set it, the agent runs from the Gateway’s current directory (often not what you want for a “personal assistant” workspace). +- `inbound.workspace` sets the default working directory for agent runs (supports `~` and is resolved to an absolute path). +- `inbound.reply.cwd` overrides the working directory for that specific reply command. ### `browser` (clawd-managed Chrome) diff --git a/src/agents/workspace.test.ts b/src/agents/workspace.test.ts new file mode 100644 index 000000000..f4ab1fa7f --- /dev/null +++ b/src/agents/workspace.test.ts @@ -0,0 +1,32 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { ensureAgentWorkspace } from "./workspace.js"; + +describe("ensureAgentWorkspace", () => { + it("creates directory and AGENTS.md when missing", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-")); + const nested = path.join(dir, "nested"); + const result = await ensureAgentWorkspace({ + dir: nested, + ensureAgentsFile: true, + }); + expect(result.dir).toBe(path.resolve(nested)); + expect(result.agentsPath).toBe( + path.join(path.resolve(nested), "AGENTS.md"), + ); + expect(result.agentsPath).toBeDefined(); + if (!result.agentsPath) throw new Error("agentsPath missing"); + const content = await fs.readFile(result.agentsPath, "utf-8"); + expect(content).toContain("# AGENTS.md"); + }); + + it("does not overwrite existing AGENTS.md", async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "clawdis-ws-")); + const agentsPath = path.join(dir, "AGENTS.md"); + await fs.writeFile(agentsPath, "custom", "utf-8"); + await ensureAgentWorkspace({ dir, ensureAgentsFile: true }); + expect(await fs.readFile(agentsPath, "utf-8")).toBe("custom"); + }); +}); diff --git a/src/agents/workspace.ts b/src/agents/workspace.ts new file mode 100644 index 000000000..2650eb37e --- /dev/null +++ b/src/agents/workspace.ts @@ -0,0 +1,46 @@ +import fs from "node:fs/promises"; +import path from "node:path"; + +import { CONFIG_DIR, resolveUserPath } from "../utils.js"; + +export const DEFAULT_AGENT_WORKSPACE_DIR = path.join(CONFIG_DIR, "workspace"); +export const DEFAULT_AGENTS_FILENAME = "AGENTS.md"; + +const DEFAULT_AGENTS_TEMPLATE = `# AGENTS.md — Clawdis Workspace + +This folder is the assistant’s working directory. + +## Safety defaults +- Don’t exfiltrate secrets or private data. +- Don’t run destructive commands unless explicitly asked. +- Be concise in chat; write longer output to files in this workspace. + +## How to use this +- Put project notes, scratch files, and “memory” here. +- Customize this file with additional instructions for your assistant. +`; + +export async function ensureAgentWorkspace(params?: { + dir?: string; + ensureAgentsFile?: boolean; +}): Promise<{ dir: string; agentsPath?: string }> { + const rawDir = params?.dir?.trim() + ? params.dir.trim() + : DEFAULT_AGENT_WORKSPACE_DIR; + const dir = resolveUserPath(rawDir); + await fs.mkdir(dir, { recursive: true }); + + if (!params?.ensureAgentsFile) return { dir }; + + const agentsPath = path.join(dir, DEFAULT_AGENTS_FILENAME); + try { + await fs.writeFile(agentsPath, DEFAULT_AGENTS_TEMPLATE, { + encoding: "utf-8", + flag: "wx", + }); + } catch (err) { + const anyErr = err as { code?: string }; + if (anyErr.code !== "EEXIST") throw err; + } + return { dir, agentsPath }; +} diff --git a/src/auto-reply/command-reply.ts b/src/auto-reply/command-reply.ts index a647eb2be..6d1dfd234 100644 --- a/src/auto-reply/command-reply.ts +++ b/src/auto-reply/command-reply.ts @@ -362,6 +362,15 @@ export async function runCommandReply( typeof reply.cwd === "string" && reply.cwd.trim() ? resolveUserPath(reply.cwd) : undefined; + if (resolvedCwd) { + try { + await fs.mkdir(resolvedCwd, { recursive: true }); + } catch (err) { + throw new Error( + `Failed to create reply.cwd directory (${resolvedCwd}): ${String(err)}`, + ); + } + } if (!reply.command?.length) { throw new Error("reply.command is required for mode=command"); diff --git a/src/auto-reply/reply.ts b/src/auto-reply/reply.ts index 6591bf79f..bd8b13148 100644 --- a/src/auto-reply/reply.ts +++ b/src/auto-reply/reply.ts @@ -3,6 +3,10 @@ import crypto from "node:crypto"; import { lookupContextTokens } from "../agents/context.js"; import { DEFAULT_CONTEXT_TOKENS, DEFAULT_MODEL } from "../agents/defaults.js"; import { resolveBundledPiBinary } from "../agents/pi-path.js"; +import { + DEFAULT_AGENT_WORKSPACE_DIR, + ensureAgentWorkspace, +} from "../agents/workspace.js"; import { type ClawdisConfig, loadConfig } from "../config/config.js"; import { DEFAULT_IDLE_MINUTES, @@ -18,6 +22,7 @@ import { buildProviderSummary } from "../infra/provider-summary.js"; import { triggerClawdisRestart } from "../infra/restart.js"; import { drainSystemEvents } from "../infra/system-events.js"; import { defaultRuntime } from "../runtime.js"; +import { resolveUserPath } from "../utils.js"; import { resolveHeartbeatSeconds } from "../web/reconnect.js"; import { getWebAuthAgeMs, webAuthExists } from "../web/session.js"; import { runCommandReply } from "./command-reply.js"; @@ -44,6 +49,8 @@ const SYSTEM_MARK = "⚙️"; type ReplyConfig = NonNullable["reply"]; +type ResolvedReplyConfig = NonNullable; + export function extractThinkDirective(body?: string): { cleaned: string; thinkLevel?: ThinkLevel; @@ -136,7 +143,7 @@ function stripMentions( return result.replace(/\s+/g, " ").trim(); } -function makeDefaultPiReply(): ReplyConfig { +function makeDefaultPiReply(): ResolvedReplyConfig { const piBin = resolveBundledPiBinary() ?? "pi"; const defaultContext = lookupContextTokens(DEFAULT_MODEL) ?? DEFAULT_CONTEXT_TOKENS; @@ -165,8 +172,21 @@ export async function getReplyFromConfig( ): Promise { // Choose reply from config: static text or external command stdout. const cfg = configOverride ?? loadConfig(); - const reply: ReplyConfig = cfg.inbound?.reply ?? makeDefaultPiReply(); - const timeoutSeconds = Math.max(reply?.timeoutSeconds ?? 600, 1); + const workspaceDir = cfg.inbound?.workspace ?? DEFAULT_AGENT_WORKSPACE_DIR; + const configuredReply = cfg.inbound?.reply as ResolvedReplyConfig | undefined; + const reply: ResolvedReplyConfig = configuredReply + ? { ...configuredReply, cwd: configuredReply.cwd ?? workspaceDir } + : { ...makeDefaultPiReply(), cwd: workspaceDir }; + + // Bootstrap the workspace (and a starter AGENTS.md) only when we actually run from it. + if (reply.mode === "command" && typeof reply.cwd === "string") { + const resolvedWorkspace = resolveUserPath(workspaceDir); + const resolvedCwd = resolveUserPath(reply.cwd); + if (resolvedCwd === resolvedWorkspace) { + await ensureAgentWorkspace({ dir: workspaceDir, ensureAgentsFile: true }); + } + } + const timeoutSeconds = Math.max(reply.timeoutSeconds ?? 600, 1); const timeoutMs = timeoutSeconds * 1000; let started = false; const triggerTyping = async () => { diff --git a/src/config/config.ts b/src/config/config.ts index 103209e71..642db35ca 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -89,6 +89,8 @@ export type ClawdisConfig = { browser?: BrowserConfig; inbound?: { allowFrom?: string[]; // E.164 numbers allowed to trigger auto-reply (without whatsapp:) + /** Agent working directory (preferred). Used as the default cwd for agent runs. */ + workspace?: string; messagePrefix?: string; // Prefix added to all inbound messages (default: "[clawdis]" if no allowFrom, else "") responsePrefix?: string; // Prefix auto-added to all outbound replies (e.g., "🦞") timestampPrefix?: boolean | string; // true/false or IANA timezone string (default: true with UTC) @@ -228,6 +230,7 @@ const ClawdisSchema = z.object({ inbound: z .object({ allowFrom: z.array(z.string()).optional(), + workspace: z.string().optional(), messagePrefix: z.string().optional(), responsePrefix: z.string().optional(), timestampPrefix: z.union([z.boolean(), z.string()]).optional(),