refactor: move OAuth storage and drop legacy sessions

This commit is contained in:
Peter Steinberger
2025-12-22 21:02:48 +00:00
parent 9717f2d374
commit 4ca6591045
13 changed files with 265 additions and 123 deletions

View File

@@ -6,7 +6,7 @@ import SwiftUI
struct AnthropicAuthControls: View {
let connectionMode: AppState.ConnectionMode
@State private var oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()
@State private var oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
@State private var pkce: AnthropicOAuth.PKCE?
@State private var code: String = ""
@State private var busy = false
@@ -20,7 +20,7 @@ struct AnthropicAuthControls: View {
var body: some View {
VStack(alignment: .leading, spacing: 10) {
if self.connectionMode != .local {
Text("Gateway isnt running locally; OAuth must be created on the gateway host where Pi runs.")
Text("Gateway isnt running locally; OAuth must be created on the gateway host.")
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
@@ -35,10 +35,10 @@ struct AnthropicAuthControls: View {
.foregroundStyle(.secondary)
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
.disabled(!FileManager.default.fileExists(atPath: PiOAuthStore.oauthURL().path))
.disabled(!FileManager.default.fileExists(atPath: ClawdisOAuthStore.oauthURL().path))
Button("Refresh") {
self.refresh()
@@ -46,7 +46,7 @@ struct AnthropicAuthControls: View {
.buttonStyle(.bordered)
}
Text(PiOAuthStore.oauthURL().path)
Text(ClawdisOAuthStore.oauthURL().path)
.font(.caption.monospaced())
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -123,7 +123,11 @@ struct AnthropicAuthControls: View {
}
private func refresh() {
self.oauthStatus = PiOAuthStore.anthropicOAuthStatus()
let imported = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
self.oauthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
if imported != nil {
self.statusText = "Imported existing OAuth credentials."
}
}
private func startOAuth() {
@@ -161,11 +165,11 @@ struct AnthropicAuthControls: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
self.refresh()
self.pkce = nil
self.code = ""
self.statusText = "Connected. Pi can now use Claude via OAuth."
self.statusText = "Connected. Clawdis can now use Claude via OAuth."
} catch {
self.statusText = "OAuth failed: \(error.localizedDescription)"
}

View File

@@ -18,7 +18,7 @@ enum AnthropicAuthMode: Equatable {
var shortLabel: String {
switch self {
case .oauthFile: "OAuth (Pi token file)"
case .oauthFile: "OAuth (Clawdis token file)"
case .oauthEnv: "OAuth (env var)"
case .apiKeyEnv: "API key (env var)"
case .missing: "Missing credentials"
@@ -36,7 +36,8 @@ enum AnthropicAuthMode: Equatable {
enum AnthropicAuthResolver {
static func resolve(
environment: [String: String] = ProcessInfo.processInfo.environment,
oauthStatus: PiOAuthStore.AnthropicOAuthStatus = PiOAuthStore.anthropicOAuthStatus()) -> AnthropicAuthMode
oauthStatus: ClawdisOAuthStore.AnthropicOAuthStatus = ClawdisOAuthStore.anthropicOAuthStatus()
) -> AnthropicAuthMode
{
if oauthStatus.isConnected { return .oauthFile }
@@ -92,7 +93,7 @@ enum AnthropicOAuth {
URLQueryItem(name: "scope", value: self.scopes),
URLQueryItem(name: "code_challenge", value: pkce.challenge),
URLQueryItem(name: "code_challenge_method", value: "S256"),
// Match Pi: state is the verifier.
// Match legacy flow: state is the verifier.
URLQueryItem(name: "state", value: pkce.verifier),
]
return components.url!
@@ -140,7 +141,7 @@ enum AnthropicOAuth {
])
}
// Match Pi: expiresAt = now + expires_in - 5 minutes.
// Match legacy flow: expiresAt = now + expires_in - 5 minutes.
let expiresAtMs = Int64(Date().timeIntervalSince1970 * 1000)
+ Int64(expiresIn * 1000)
- Int64(5 * 60 * 1000)
@@ -150,10 +151,11 @@ enum AnthropicOAuth {
}
}
enum PiOAuthStore {
enum ClawdisOAuthStore {
static let oauthFilename = "oauth.json"
private static let providerKey = "anthropic"
private static let piAgentDirEnv = "PI_CODING_AGENT_DIR"
private static let clawdisOAuthDirEnv = "CLAWDIS_OAUTH_DIR"
private static let legacyPiDirEnv = "PI_CODING_AGENT_DIR"
enum AnthropicOAuthStatus: Equatable {
case missingFile
@@ -170,18 +172,18 @@ enum PiOAuthStore {
var shortDescription: String {
switch self {
case .missingFile: "Pi OAuth token file not found"
case .unreadableFile: "Pi OAuth token file not readable"
case .invalidJSON: "Pi OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Pi OAuth token file"
case .missingFile: "Clawdis OAuth token file not found"
case .unreadableFile: "Clawdis OAuth token file not readable"
case .invalidJSON: "Clawdis OAuth token file invalid"
case .missingProviderEntry: "No Anthropic entry in Clawdis OAuth token file"
case .missingTokens: "Anthropic entry missing tokens"
case .connected: "Pi OAuth credentials found"
case .connected: "Clawdis OAuth credentials found"
}
}
}
static func oauthDir() -> URL {
if let override = ProcessInfo.processInfo.environment[self.piAgentDirEnv]?
if let override = ProcessInfo.processInfo.environment[self.clawdisOAuthDirEnv]?
.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
@@ -190,14 +192,58 @@ enum PiOAuthStore {
}
return FileManager.default.homeDirectoryForCurrentUser
.appendingPathComponent(".pi", isDirectory: true)
.appendingPathComponent("agent", isDirectory: true)
.appendingPathComponent(".clawdis", isDirectory: true)
.appendingPathComponent("credentials", isDirectory: true)
}
static func oauthURL() -> URL {
self.oauthDir().appendingPathComponent(self.oauthFilename)
}
static func legacyOAuthURLs() -> [URL] {
var urls: [URL] = []
let env = ProcessInfo.processInfo.environment
if let override = env[self.legacyPiDirEnv]?.trimmingCharacters(in: .whitespacesAndNewlines),
!override.isEmpty
{
let expanded = NSString(string: override).expandingTildeInPath
urls.append(URL(fileURLWithPath: expanded, isDirectory: true).appendingPathComponent(self.oauthFilename))
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".pi/agent/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/claude/\(self.oauthFilename)"))
urls.append(home.appendingPathComponent(".config/anthropic/\(self.oauthFilename)"))
var seen = Set<String>()
return urls.filter { url in
let path = url.standardizedFileURL.path
if seen.contains(path) { return false }
seen.insert(path)
return true
}
}
static func importLegacyAnthropicOAuthIfNeeded() -> URL? {
let dest = self.oauthURL()
guard !FileManager.default.fileExists(atPath: dest.path) else { return nil }
for url in self.legacyOAuthURLs() {
guard FileManager.default.fileExists(atPath: url.path) else { continue }
guard self.anthropicOAuthStatus(at: url).isConnected else { continue }
guard let storage = self.loadStorage(at: url) else { continue }
do {
try self.saveStorage(storage)
return url
} catch {
continue
}
}
return nil
}
static func anthropicOAuthStatus() -> AnthropicOAuthStatus {
self.anthropicOAuthStatus(at: self.oauthURL())
}
@@ -240,17 +286,15 @@ enum PiOAuthStore {
return nil
}
private static func loadStorage(at url: URL) -> [String: Any]? {
guard let data = try? Data(contentsOf: url) else { return nil }
guard let json = try? JSONSerialization.jsonObject(with: data, options: []) else { return nil }
return json as? [String: Any]
}
static func saveAnthropicOAuth(_ creds: AnthropicOAuthCredentials) throws {
let url = self.oauthURL()
let existing: [String: Any] = if FileManager.default.fileExists(atPath: url.path),
let data = try? Data(contentsOf: url),
let json = try? JSONSerialization.jsonObject(with: data, options: []),
let dict = json as? [String: Any]
{
dict
} else {
[:]
}
let existing: [String: Any] = self.loadStorage(at: url) ?? [:]
var updated = existing
updated[self.providerKey] = [

View File

@@ -152,7 +152,7 @@ struct ConfigSettings: View {
}
private var anthropicAuthHelpText: String {
"Determined from Pi OAuth token file (~/.pi/agent/oauth.json) " +
"Determined from Clawdis OAuth token file (~/.clawdis/credentials/oauth.json) " +
"or environment variables (ANTHROPIC_OAUTH_TOKEN / ANTHROPIC_API_KEY)."
}

View File

@@ -62,7 +62,7 @@ struct OnboardingView: View {
@State private var anthropicAuthStatus: String?
@State private var anthropicAuthBusy = false
@State private var anthropicAuthConnected = false
@State private var anthropicAuthDetectedStatus: PiOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var anthropicAuthDetectedStatus: ClawdisOAuthStore.AnthropicOAuthStatus = .missingFile
@State private var anthropicAuthAutoDetectClipboard = true
@State private var anthropicAuthAutoConnectClipboard = true
@State private var anthropicAuthLastPasteboardChangeCount = NSPasteboard.general.changeCount
@@ -530,7 +530,7 @@ struct OnboardingView: View {
.multilineTextAlignment(.center)
.frame(maxWidth: 540)
.fixedSize(horizontal: false, vertical: true)
Text("Pi supports any model — we strongly recommend Opus 4.5 for the best experience.")
Text("Clawdis supports any model — we strongly recommend Opus 4.5 for the best experience.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
@@ -555,14 +555,14 @@ struct OnboardingView: View {
}
Text(
"This lets Pi use Claude immediately. Credentials are stored at " +
"`~/.pi/agent/oauth.json` (owner-only). You can redo this anytime.")
"This lets Clawdis use Claude immediately. Credentials are stored at " +
"`~/.clawdis/credentials/oauth.json` (owner-only). You can redo this anytime.")
.font(.subheadline)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
HStack(spacing: 12) {
Text(PiOAuthStore.oauthURL().path)
Text(ClawdisOAuthStore.oauthURL().path)
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
@@ -571,7 +571,7 @@ struct OnboardingView: View {
Spacer()
Button("Reveal") {
NSWorkspace.shared.activateFileViewerSelecting([PiOAuthStore.oauthURL()])
NSWorkspace.shared.activateFileViewerSelecting([ClawdisOAuthStore.oauthURL()])
}
.buttonStyle(.bordered)
@@ -683,9 +683,9 @@ struct OnboardingView: View {
code: parsed.code,
state: parsed.state,
verifier: pkce.verifier)
try PiOAuthStore.saveAnthropicOAuth(creds)
try ClawdisOAuthStore.saveAnthropicOAuth(creds)
self.refreshAnthropicOAuthStatus()
self.anthropicAuthStatus = "Connected. Pi can now use Claude."
self.anthropicAuthStatus = "Connected. Clawdis can now use Claude."
} catch {
self.anthropicAuthStatus = "OAuth failed: \(error.localizedDescription)"
}
@@ -717,7 +717,8 @@ struct OnboardingView: View {
}
private func refreshAnthropicOAuthStatus() {
let status = PiOAuthStore.anthropicOAuthStatus()
_ = ClawdisOAuthStore.importLegacyAnthropicOAuthIfNeeded()
let status = ClawdisOAuthStore.anthropicOAuthStatus()
self.anthropicAuthDetectedStatus = status
self.anthropicAuthConnected = status.isConnected
}
@@ -947,8 +948,8 @@ struct OnboardingView: View {
self.featureRow(
title: "Remote gateway checklist",
subtitle: """
On your gateway host: install/update the `clawdis` package and make sure Pi has credentials
(typically `~/.pi/agent/oauth.json`). Then connect again if needed.
On your gateway host: install/update the `clawdis` package and make sure credentials exist
(typically `~/.clawdis/credentials/oauth.json`). Then connect again if needed.
""",
systemImage: "network")
Divider()

View File

@@ -83,8 +83,6 @@ enum SessionActions {
}
let home = FileManager.default.homeDirectoryForCurrentUser
urls.append(home.appendingPathComponent(".clawdis/sessions/\(sessionId).jsonl"))
urls.append(home.appendingPathComponent(".pi/agent/sessions/\(sessionId).jsonl"))
urls.append(home.appendingPathComponent(".tau/agent/sessions/clawdis/\(sessionId).jsonl"))
return urls
}()

View File

@@ -6,7 +6,7 @@ import Testing
struct AnthropicAuthResolverTests {
@Test
func prefersOAuthFileOverEnv() throws {
let key = "PI_CODING_AGENT_DIR"
let key = "CLAWDIS_OAUTH_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
@@ -61,4 +61,3 @@ struct AnthropicAuthResolverTests {
#expect(mode == .missing)
}
}

View File

@@ -3,18 +3,18 @@ import Testing
@testable import Clawdis
@Suite
struct PiOAuthStoreTests {
struct ClawdisOAuthStoreTests {
@Test
func returnsMissingWhenFileAbsent() {
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)")
.appendingPathComponent("oauth.json")
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingFile)
}
@Test
func usesEnvOverrideForPiAgentDir() throws {
let key = "PI_CODING_AGENT_DIR"
func usesEnvOverrideForClawdisOAuthDir() throws {
let key = "CLAWDIS_OAUTH_DIR"
let previous = ProcessInfo.processInfo.environment[key]
defer {
if let previous {
@@ -25,10 +25,10 @@ struct PiOAuthStoreTests {
}
let dir = FileManager.default.temporaryDirectory
.appendingPathComponent("clawdis-pi-agent-\(UUID().uuidString)", isDirectory: true)
.appendingPathComponent("clawdis-oauth-\(UUID().uuidString)", isDirectory: true)
setenv(key, dir.path, 1)
#expect(PiOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
#expect(ClawdisOAuthStore.oauthDir().standardizedFileURL == dir.standardizedFileURL)
}
@Test
@@ -42,7 +42,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
@@ -55,7 +55,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url).isConnected)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url).isConnected)
}
@Test
@@ -68,7 +68,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingProviderEntry)
}
@Test
@@ -81,7 +81,7 @@ struct PiOAuthStoreTests {
],
])
#expect(PiOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
#expect(ClawdisOAuthStore.anthropicOAuthStatus(at: url) == .missingTokens)
}
private func writeOAuthFile(_ json: [String: Any]) throws -> URL {

View File

@@ -1,12 +1,12 @@
---
summary: "Agent runtime (embedded Pi), workspace contract, and session bootstrap"
summary: "Agent runtime (embedded p-mono), workspace contract, and session bootstrap"
read_when:
- Changing agent runtime, workspace bootstrap, or session behavior
---
<!-- {% raw %} -->
# Agent Runtime 🤖
CLAWDIS runs a single agent runtime: **Pi (embedded, in-process)**.
CLAWDIS runs a single embedded agent runtime derived from **p-mono** (internal name: **p**).
## Workspace (required)
@@ -30,7 +30,7 @@ If a file is missing, CLAWDIS injects a single “missing file” marker line (a
## Built-in tools (internal)
Pis embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; its guidance for how *you* want them used.
ps embedded core tools (read/bash/edit/write and related internals) are defined in code and always available. `TOOLS.md` does **not** control which tools exist; its guidance for how *you* want them used.
## Skills
@@ -41,11 +41,12 @@ Clawdis loads skills from three locations (workspace wins on name conflict):
Skills can be gated by config/env (see `skills.*` in `docs/configuration.md`).
## SDK integration
## p-mono integration
The embedded agent uses the `@mariozechner/pi-coding-agent` SDK for sessions and discovery.
- Hooks, custom tools, and slash commands are discovered via the SDK (from `~/.pi/agent` and `<workspace>/.pi` settings).
- Bootstrap files are injected as SDK project context (see “Project Context” in the system prompt).
Clawdis reuses pieces of the p-mono codebase (models/tools), but **session management, discovery, and tool wiring are Clawdis-owned**.
- No p-coding agent runtime.
- No `~/.pi/agent` or `<workspace>/.pi` settings are consulted.
## Peter @ steipete (only)
@@ -65,6 +66,7 @@ Session transcripts are stored as JSONL at:
- `~/.clawdis/sessions/<SessionId>.jsonl`
The session ID is stable and chosen by CLAWDIS.
Legacy Pi/Tau session folders are **not** read.
## Steering while streaming

View File

@@ -20,13 +20,13 @@ App bundle layout:
- `clawdis …` (CLI)
- `clawdis gateway-daemon …` (LaunchAgent daemon)
- `Clawdis.app/Contents/Resources/Relay/package.json`
- tiny “Pi compatibility” file (see below)
- tiny “p runtime compatibility” file (see below)
- `Clawdis.app/Contents/Resources/Relay/theme/`
- Pi TUI theme payload (optional, but strongly recommended)
- p TUI theme payload (optional, but strongly recommended)
Why the sidecar files matter:
- `@mariozechner/pi-coding-agent` detects “bun binary mode” and then looks for `package.json` + `theme/` **next to `process.execPath`** (i.e. next to `clawdis`).
- So even if bun can embed assets, Pi currently expects filesystem paths. Keep the sidecar files.
- The embedded p runtime detects “bun binary mode” and then looks for `package.json` + `theme/` **next to `process.execPath`** (i.e. next to `clawdis`).
- So even if bun can embed assets, the runtime expects filesystem paths. Keep the sidecar files.
## Build pipeline

View File

@@ -2,12 +2,12 @@
summary: "Planned first-run onboarding flow for Clawdis (local vs remote, Anthropic OAuth, workspace bootstrap ritual)"
read_when:
- Designing the macOS onboarding assistant
- Implementing Pi authentication or identity setup
- Implementing Anthropic auth or identity setup
---
<!-- {% raw %} -->
# Onboarding (macOS app)
This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for Pi, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
This doc describes the intended **first-run onboarding** for Clawdis. The goal is a good “day 0” experience: pick where the Gateway runs, bind Claude (Anthropic) auth for the embedded agent runtime, and then let the **agent bootstrap itself** via a first-run ritual in the workspace.
## Page order (high level)
@@ -19,58 +19,42 @@ This doc describes the intended **first-run onboarding** for Clawdis. The goal i
First question: where does the **Gateway** run?
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write Pis token store locally.
- **Local (this Mac):** onboarding can run the Anthropic OAuth flow and write the Clawdis token store locally.
- **Remote (over SSH/tailnet):** onboarding must not run OAuth locally, because credentials must exist on the **gateway host**.
Implementation note (2025-12-19): in local mode, the macOS app bundles the Gateway and enables it via a per-user launchd LaunchAgent (no global npm install/Node requirement for the user).
## 2) Local-only: Connect Claude (Anthropic OAuth)
This is the “bind Pi to Clawdis” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
This is the “bind Clawdis to Anthropic” step. It is explicitly the **Anthropic (Claude Pro/Max) OAuth flow**, not a generic “login”.
### Recommended: OAuth
The macOS app should:
- Start the Anthropic OAuth (PKCE) flow in the users browser.
- Ask the user to paste the `code#state` value.
- Exchange it for tokens and write Pi-compatible credentials to:
- `~/.pi/agent/oauth.json` (file mode `0600`, directory mode `0700`)
- Exchange it for tokens and write credentials to:
- `~/.clawdis/credentials/oauth.json` (file mode `0600`, directory mode `0700`)
Why this location matters: it makes Pi work immediately (Clawdis doesnt need a terminal and doesnt need to re-implement Pis auth plumbing later).
Why this location matters: its the Clawdis-owned OAuth store.
On first run, Clawdis can import existing OAuth tokens from legacy p/Claude locations if present.
### Alternative: API key (instructions only)
Offer an “API key” option, but for now it is **instructions only**:
- Get an Anthropic API key.
- Provide it to Pi (or to Clawdiss Pi invocation) via your preferred mechanism.
- Provide it to Clawdis via your preferred mechanism (env/config).
Note: environment variables are often confusing when the Gateway is launched by a GUI app (launchd environment != your shell).
### Provider/model safety rule
Clawdis should **always pass** `--provider` and `--model` when invoking Pi (dont rely on Pi defaults).
Clawdis should **always pass** `--provider` and `--model` when invoking the embedded agent (dont rely on defaults).
Until that is hard-coded, the equivalent configuration is:
Example (CLI):
```json5
{
inbound: {
reply: {
mode: "command",
command: [
"pi",
"--mode",
"rpc",
"--provider",
"anthropic",
"--model",
"claude-opus-4-5",
"{{BodyStripped}}"
],
agent: { kind: "pi", format: "json" }
}
}
}
```bash
clawdis agent --mode rpc --provider anthropic --model claude-opus-4-5 "<message>"
```
If the user skips auth, onboarding should be clear: the agent likely wont respond until auth is configured.
@@ -136,10 +120,10 @@ Daily memory lives under `memory/` in the workspace:
## Remote mode note (why OAuth is hidden)
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where Pi runs).
If the Gateway runs on another machine, the Anthropic OAuth credentials must be created/stored on that host (where the agent runtime runs).
For now, remote onboarding should:
- explain why OAuth isnt shown
- point the user at the credential location (`~/.pi/agent/oauth.json`) and the workspace location on the gateway host
- point the user at the credential location (`~/.clawdis/credentials/oauth.json`) and the workspace location on the gateway host
- mention that the **bootstrap ritual happens on the gateway host** (same BOOTSTRAP/IDENTITY/USER files)
<!-- {% endraw %} -->

View File

@@ -18,6 +18,7 @@ All session state is **owned by the gateway** (the “master” Clawdis). UI cli
- Store file: `~/.clawdis/sessions/sessions.json` (legacy: `~/.clawdis/sessions.json`).
- Transcripts: `~/.clawdis/sessions/<SessionId>.jsonl` (one file per session id).
- The store is a map `sessionKey -> { sessionId, updatedAt, ... }`. Deleting entries is safe; they are recreated on demand.
- Clawdis does **not** read legacy Pi/Tau session folders.
## Mapping transports → session keys
- Direct chats (WhatsApp, Telegram, desktop Web Chat) all collapse to the **primary key** so they share context.

View File

@@ -1,9 +1,16 @@
import fsSync from "node:fs";
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { AppMessage, ThinkingLevel } from "@mariozechner/pi-agent-core";
import type { Api, AssistantMessage, Model } from "@mariozechner/pi-ai";
import {
setOAuthStorage,
type Api,
type AssistantMessage,
type Model,
type OAuthStorage,
} from "@mariozechner/pi-ai";
import {
buildSystemPrompt,
createAgentSession,
@@ -18,7 +25,7 @@ import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import type { ClawdisConfig } from "../config/config.js";
import { splitMediaFromOutput } from "../media/parse.js";
import { enqueueCommand } from "../process/command-queue.js";
import { resolveUserPath } from "../utils.js";
import { CONFIG_DIR, resolveUserPath } from "../utils.js";
import { DEFAULT_MODEL, DEFAULT_PROVIDER } from "./defaults.js";
import {
buildBootstrapContextFiles,
@@ -75,6 +82,109 @@ type EmbeddedPiQueueHandle = {
const ACTIVE_EMBEDDED_RUNS = new Map<string, EmbeddedPiQueueHandle>();
const OAUTH_FILENAME = "oauth.json";
const DEFAULT_OAUTH_DIR = path.join(CONFIG_DIR, "credentials");
const DEFAULT_AGENT_DIR = path.join(CONFIG_DIR, "agent");
let oauthStorageConfigured = false;
let cachedDefaultApiKey: ReturnType<typeof defaultGetApiKey> | null = null;
function resolveClawdisOAuthPath(): string {
const overrideDir =
process.env.CLAWDIS_OAUTH_DIR?.trim() || DEFAULT_OAUTH_DIR;
return path.join(resolveUserPath(overrideDir), OAUTH_FILENAME);
}
function resolveAgentDir(): string {
const override =
process.env.CLAWDIS_AGENT_DIR?.trim() ||
process.env.PI_CODING_AGENT_DIR?.trim() ||
DEFAULT_AGENT_DIR;
return resolveUserPath(override);
}
function loadOAuthStorageAt(pathname: string): OAuthStorage | null {
if (!fsSync.existsSync(pathname)) return null;
try {
const content = fsSync.readFileSync(pathname, "utf8");
const json = JSON.parse(content) as OAuthStorage;
if (!json || typeof json !== "object") return null;
return json;
} catch {
return null;
}
}
function hasAnthropicOAuth(storage: OAuthStorage): boolean {
const entry = storage.anthropic as
| {
refresh?: string;
refresh_token?: string;
refreshToken?: string;
access?: string;
access_token?: string;
accessToken?: string;
}
| undefined;
if (!entry) return false;
const refresh =
entry.refresh ?? entry.refresh_token ?? entry.refreshToken ?? "";
const access = entry.access ?? entry.access_token ?? entry.accessToken ?? "";
return Boolean(refresh.trim() && access.trim());
}
function saveOAuthStorageAt(pathname: string, storage: OAuthStorage): void {
const dir = path.dirname(pathname);
fsSync.mkdirSync(dir, { recursive: true, mode: 0o700 });
fsSync.writeFileSync(
pathname,
`${JSON.stringify(storage, null, 2)}\n`,
"utf8",
);
fsSync.chmodSync(pathname, 0o600);
}
function legacyOAuthPaths(): string[] {
const paths: string[] = [];
const piOverride = process.env.PI_CODING_AGENT_DIR?.trim();
if (piOverride) {
paths.push(path.join(resolveUserPath(piOverride), OAUTH_FILENAME));
}
paths.push(path.join(os.homedir(), ".pi", "agent", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "claude", OAUTH_FILENAME));
paths.push(path.join(os.homedir(), ".config", "anthropic", OAUTH_FILENAME));
return Array.from(new Set(paths));
}
function importLegacyOAuthIfNeeded(destPath: string): void {
if (fsSync.existsSync(destPath)) return;
for (const legacyPath of legacyOAuthPaths()) {
const storage = loadOAuthStorageAt(legacyPath);
if (!storage || !hasAnthropicOAuth(storage)) continue;
saveOAuthStorageAt(destPath, storage);
return;
}
}
function ensureOAuthStorage(): void {
if (oauthStorageConfigured) return;
oauthStorageConfigured = true;
const oauthPath = resolveClawdisOAuthPath();
importLegacyOAuthIfNeeded(oauthPath);
setOAuthStorage({
load: () => loadOAuthStorageAt(oauthPath) ?? {},
save: (storage) => saveOAuthStorageAt(oauthPath, storage),
});
}
function getDefaultApiKey() {
if (!cachedDefaultApiKey) {
ensureOAuthStorage();
cachedDefaultApiKey = defaultGetApiKey();
}
return cachedDefaultApiKey;
}
export function queueEmbeddedPiMessage(
sessionId: string,
text: string,
@@ -106,14 +216,13 @@ function resolveModel(
return { model };
}
const defaultApiKey = defaultGetApiKey();
async function getApiKeyForModel(model: Model<Api>): Promise<string> {
ensureOAuthStorage();
if (model.provider === "anthropic") {
const oauthEnv = process.env.ANTHROPIC_OAUTH_TOKEN;
if (oauthEnv?.trim()) return oauthEnv.trim();
}
const key = await defaultApiKey(model);
const key = await getDefaultApiKey()(model);
if (key) return key;
throw new Error(`No API key found for provider "${model.provider}"`);
}
@@ -175,9 +284,10 @@ export async function runEmbeddedPiAgent(params: {
const provider =
(params.provider ?? DEFAULT_PROVIDER).trim() || DEFAULT_PROVIDER;
const modelId = (params.model ?? DEFAULT_MODEL).trim() || DEFAULT_MODEL;
const agentDir =
process.env.PI_CODING_AGENT_DIR ??
path.join(os.homedir(), ".pi", "agent");
const agentDir = resolveAgentDir();
if (!process.env.PI_CODING_AGENT_DIR) {
process.env.PI_CODING_AGENT_DIR = agentDir;
}
const { model, error } = resolveModel(provider, modelId, agentDir);
if (!model) {
throw new Error(error ?? `Unknown model: ${provider}/${modelId}`);

View File

@@ -720,8 +720,6 @@ function readSessionMessages(
const parsed = JSON.parse(line);
if (parsed?.message) {
messages.push(parsed.message);
} else if (parsed?.role && parsed?.content) {
messages.push(parsed);
}
} catch {
// ignore bad lines
@@ -742,19 +740,6 @@ function resolveSessionTranscriptCandidates(
candidates.push(
path.join(os.homedir(), ".clawdis", "sessions", `${sessionId}.jsonl`),
);
candidates.push(
path.join(os.homedir(), ".pi", "agent", "sessions", `${sessionId}.jsonl`),
);
candidates.push(
path.join(
os.homedir(),
".tau",
"agent",
"sessions",
"clawdis",
`${sessionId}.jsonl`,
),
);
return candidates;
}
@@ -1516,7 +1501,21 @@ export async function startGatewayServer(
);
return;
}
logTelegram.info("starting provider");
let telegramBotLabel = "";
try {
const probe = await probeTelegram(
telegramToken.trim(),
2500,
cfg.telegram?.proxy,
);
const username = probe.ok ? probe.bot?.username?.trim() : null;
if (username) telegramBotLabel = ` (@${username})`;
} catch (err) {
if (isVerbose()) {
logTelegram.debug(`bot probe failed: ${String(err)}`);
}
}
logTelegram.info(`starting provider${telegramBotLabel}`);
telegramAbort = new AbortController();
telegramRuntime = {
...telegramRuntime,