diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index fb753b9aa4bd..fca792978ccb 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -60,6 +60,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.projectsWriteFile]: AuthOrchestrationOperateScope, [WS_METHODS.shellOpenInEditor]: AuthOrchestrationOperateScope, [WS_METHODS.filesystemBrowse]: AuthOrchestrationReadScope, + [WS_METHODS.agentSessionsScan]: AuthOrchestrationReadScope, [WS_METHODS.assetsCreateUrl]: AuthOrchestrationReadScope, [WS_METHODS.subscribeVcsStatus]: AuthOrchestrationReadScope, [WS_METHODS.subscribeResourceTelemetry]: AuthOrchestrationReadScope, diff --git a/apps/server/src/project/AgentSessionScanner.test.ts b/apps/server/src/project/AgentSessionScanner.test.ts new file mode 100644 index 000000000000..78f651ed6929 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.test.ts @@ -0,0 +1,404 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { describe, expect, it } from "@effect/vitest"; +import { type OrchestrationProject, ProjectId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as AgentSessionScanner from "./AgentSessionScanner.ts"; + +const makeProject = (workspaceRoot: string): OrchestrationProject => ({ + id: ProjectId.make("project-1"), + title: "Imported", + workspaceRoot, + defaultModelSelection: null, + scripts: [], + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + deletedAt: null, +}); + +/** Only `getActiveProjectByWorkspaceRoot` is exercised; the rest must not be called. */ +const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray) => + Layer.succeed(ProjectionSnapshotQuery.ProjectionSnapshotQuery, { + getCommandReadModel: () => Effect.die("unused"), + getSnapshot: () => Effect.die("unused"), + getShellSnapshot: () => Effect.die("unused"), + getArchivedShellSnapshot: () => Effect.die("unused"), + getSnapshotSequence: () => Effect.die("unused"), + getCounts: () => Effect.die("unused"), + getActiveProjectByWorkspaceRoot: (workspaceRoot) => + Effect.succeed( + importedWorkspaceRoots.includes(workspaceRoot) + ? Option.some(makeProject(workspaceRoot)) + : Option.none(), + ), + getProjectShellById: () => Effect.die("unused"), + getFirstActiveThreadIdByProjectId: () => Effect.die("unused"), + getThreadCheckpointContext: () => Effect.die("unused"), + getFullThreadDiffContext: () => Effect.die("unused"), + getThreadShellById: () => Effect.die("unused"), + getThreadDetailById: () => Effect.die("unused"), + getThreadDetailSnapshot: () => Effect.die("unused"), + searchThreads: () => Effect.die("unused"), + }); + +/** + * Run a scan against the given homes. Homes are temp dirs created inside the + * test, so the layer is built per run rather than shared. + */ +const runScan = (input: { + readonly claudeHomePath: string; + readonly codexHomePath: string; + readonly importedWorkspaceRoots?: ReadonlyArray; + /** Base dir for the test ServerConfig; worktreesDir derives from it. */ + readonly configBaseDir?: string; +}) => + Effect.gen(function* () { + const scanner = yield* AgentSessionScanner.AgentSessionScanner; + return yield* scanner.scan; + }).pipe( + Effect.provide( + AgentSessionScanner.layer.pipe( + Layer.provide( + Layer.mergeAll( + ServerSettings.layerTest({ + providers: { + claudeAgent: { homePath: input.claudeHomePath }, + codex: { homePath: input.codexHomePath }, + }, + }), + ServerConfig.layerTest( + input.claudeHomePath, + input.configBaseDir ?? { prefix: "t3code-scanner-config-" }, + ), + makeProjectionSnapshotQueryLayer(input.importedWorkspaceRoots ?? []), + ), + ), + ), + ), + ); + +const makeTempDir = Effect.fn("AgentSessionScanner.test.makeTempDir")(function* (prefix: string) { + const fileSystem = yield* FileSystem.FileSystem; + return yield* fileSystem.makeTempDirectoryScoped({ prefix }); +}); + +const writeTranscript = Effect.fn("AgentSessionScanner.test.writeTranscript")(function* (input: { + readonly filePath: string; + readonly contents: string; + /** Epoch millis, so ordering assertions never depend on write timing. */ + readonly mtimeMs: number; +}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + yield* fileSystem.makeDirectory(path.dirname(input.filePath), { recursive: true }); + yield* fileSystem.writeFileString(input.filePath, input.contents); + // Numeric utimes arguments are seconds, not milliseconds. + const seconds = input.mtimeMs / 1000; + yield* fileSystem.utimes(input.filePath, seconds, seconds); +}); + +/** Claude session line: the first record carries the real `cwd`. */ +const claudeSessionLine = (cwd: string) => + `${JSON.stringify({ type: "user", cwd, sessionId: "s1" })}\n${JSON.stringify({ type: "assistant" })}\n`; + +/** Codex rollout line: session metadata is nested under `payload`. */ +const codexRolloutLine = (cwd: string) => + `${JSON.stringify({ timestamp: "2026-01-01T00:00:00.000Z", type: "session_meta", payload: { id: "r1", cwd } })}\n`; + +it.layer(NodeServices.layer)("AgentSessionScanner", (it) => { + describe("scan", () => { + it.effect("reads Claude project cwds from transcripts, newest first", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const olderWorkspace = yield* makeTempDir("t3code-workspace-older-"); + const newerWorkspace = yield* makeTempDir("t3code-workspace-newer-"); + + // Slugs are intentionally lossy; the scanner must not decode them. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "a.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-older", "b.jsonl"), + contents: claudeSessionLine(olderWorkspace), + mtimeMs: Date.parse("2026-01-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug-newer", "c.jsonl"), + contents: claudeSessionLine(newerWorkspace), + mtimeMs: Date.parse("2026-03-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: newerWorkspace, + title: path.basename(newerWorkspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-03-01T00:00:00.000Z", + alreadyImported: false, + }, + { + path: olderWorkspace, + title: path.basename(olderWorkspace), + sources: ["claudeAgent"], + threadCount: 2, + lastActiveAt: "2026-01-02T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("groups Codex rollouts by cwd across date directories", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + const otherWorkspace = yield* makeTempDir("t3code-workspace-other-"); + + const rollout = (year: string, month: string, day: string, name: string) => + path.join(codexHomePath, "sessions", year, month, day, name); + + yield* writeTranscript({ + filePath: rollout("2026", "01", "05", "rollout-2026-01-05T10-00-00-aaa.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-01-05T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T10-00-00-bbb.jsonl"), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-02-09T10:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: rollout("2026", "02", "09", "rollout-2026-02-09T11-00-00-ccc.jsonl"), + contents: codexRolloutLine(otherWorkspace), + mtimeMs: Date.parse("2026-02-09T11:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: otherWorkspace, + title: path.basename(otherWorkspace), + sources: ["codex"], + threadCount: 1, + lastActiveAt: "2026-02-09T11:00:00.000Z", + alreadyImported: false, + }, + { + path: workspace, + title: path.basename(workspace), + sources: ["codex"], + threadCount: 2, + lastActiveAt: "2026-02-09T10:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("merges the same cwd seen by both agents and flags imported projects", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join( + codexHomePath, + "sessions", + "2026", + "04", + "01", + "rollout-2026-04-01T09-00-00-aaa.jsonl", + ), + contents: codexRolloutLine(workspace), + mtimeMs: Date.parse("2026-04-01T09:00:00.000Z"), + }); + + const result = yield* runScan({ + claudeHomePath, + codexHomePath, + importedWorkspaceRoots: [workspace], + }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent", "codex"], + threadCount: 2, + lastActiveAt: "2026-04-01T09:00:00.000Z", + alreadyImported: true, + }, + ]); + }), + ); + + it.effect("drops candidates whose directory no longer exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(path.join(claudeHomePath, "does-not-exist")), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes T3-managed worktree sandboxes", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const fileSystem = yield* FileSystem.FileSystem; + + const worktreeCwd = path.join(claudeHomePath, ".t3", "worktrees", "t3code", "wt-1"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes under the configured worktrees dir without .t3 in the path", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const fileSystem = yield* FileSystem.FileSystem; + + // worktreesDir derives as `/worktrees`, and the temp base + // dir contains no `.t3` segment — only the config-based prefix match + // can exclude this one. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-2"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(worktreeCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("excludes sandboxes reached through a symlink into the worktrees dir", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const configBaseDir = yield* makeTempDir("t3code-scanner-base-"); + const linkParent = yield* makeTempDir("t3code-scanner-links-"); + const fileSystem = yield* FileSystem.FileSystem; + + // The recorded cwd is a symlink whose own spelling looks harmless; + // only its realpath reveals the managed sandbox. + const worktreeCwd = path.join(configBaseDir, "worktrees", "t3code", "wt-3"); + yield* fileSystem.makeDirectory(worktreeCwd, { recursive: true }); + const symlinkCwd = path.join(linkParent, "innocent-project"); + yield* fileSystem.symlink(worktreeCwd, symlinkCwd); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-slug", "a.jsonl"), + contents: claudeSessionLine(symlinkCwd), + mtimeMs: Date.parse("2026-01-01T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath, configBaseDir }); + + expect(result.candidates).toEqual([]); + }), + ); + + it.effect("skips malformed transcripts without failing the scan", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const claudeHomePath = yield* makeTempDir("t3code-claude-home-"); + const codexHomePath = yield* makeTempDir("t3code-codex-home-"); + const workspace = yield* makeTempDir("t3code-workspace-"); + + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-broken", "a.jsonl"), + contents: "not json at all\n", + mtimeMs: Date.parse("2026-05-01T00:00:00.000Z"), + }); + // Valid JSON, but no cwd anywhere in the record. + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-no-cwd", "a.jsonl"), + contents: `{"type":"summary"}\n`, + mtimeMs: Date.parse("2026-05-02T00:00:00.000Z"), + }); + yield* writeTranscript({ + filePath: path.join(claudeHomePath, "projects", "-good", "a.jsonl"), + contents: claudeSessionLine(workspace), + mtimeMs: Date.parse("2026-05-03T00:00:00.000Z"), + }); + + const result = yield* runScan({ claudeHomePath, codexHomePath }); + + expect(result.candidates).toEqual([ + { + path: workspace, + title: path.basename(workspace), + sources: ["claudeAgent"], + threadCount: 1, + lastActiveAt: "2026-05-03T00:00:00.000Z", + alreadyImported: false, + }, + ]); + }), + ); + + it.effect("returns an empty result when neither home directory exists", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const root = yield* makeTempDir("t3code-missing-homes-"); + + const result = yield* runScan({ + claudeHomePath: path.join(root, "no-claude"), + codexHomePath: path.join(root, "no-codex"), + }); + + expect(result.candidates).toEqual([]); + expect(result.scannedAt).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }), + ); + }); +}); diff --git a/apps/server/src/project/AgentSessionScanner.ts b/apps/server/src/project/AgentSessionScanner.ts new file mode 100644 index 000000000000..bb7718265fc8 --- /dev/null +++ b/apps/server/src/project/AgentSessionScanner.ts @@ -0,0 +1,456 @@ +/** + * AgentSessionScanner - discovery of projects a user already works on. + * + * Claude Code and Codex both keep a per-session transcript on disk, and each + * transcript records the directory the session ran in. Reading those `cwd` + * values gives us the set of directories worth offering as projects during + * onboarding, without asking the user to browse the filesystem. + * + * The scan is read-only and best-effort: an unreadable home, a malformed + * transcript, or a directory that has since been deleted is skipped rather + * than failing the scan. Project creation stays with the client, which + * dispatches `project.create` for whichever candidates the user picks. + * + * @module project/AgentSessionScanner + */ +import * as NodeOS from "node:os"; + +import { + AgentSessionScanError, + type AgentSessionProjectCandidate, + type AgentSessionScanResult, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; + +import * as ServerConfig from "../config.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import { resolveCodexHomeLayout } from "../provider/Drivers/CodexHomeLayout.ts"; +import { expandHomePath } from "../pathExpansion.ts"; +import * as ServerSettings from "../serverSettings.ts"; + +/** + * Bytes read from the head of each transcript. Session metadata (including + * `cwd`) is written in the first record, so reading a prefix keeps the scan + * cheap even when a transcript is hundreds of megabytes. + */ +const TRANSCRIPT_PREFIX_BYTES = 32 * 1024; + +/** + * Upper bound on transcripts inspected (first line read) per source. + * Newest-first ordering means the cap drops only stale sessions when a home + * directory is unusually large. + */ +const MAX_TRANSCRIPTS_PER_SOURCE = 5000; + +/** + * Upper bound on `stat` calls per source. Newest-first ordering needs mtimes + * before the read cap can be applied, so stats get their own larger budget; + * once it runs out the scan stops rather than walking a pathological home + * indefinitely. + */ +const MAX_STATS_PER_SOURCE = MAX_TRANSCRIPTS_PER_SOURCE * 4; + +/** Service tag for agent session discovery. */ +export class AgentSessionScanner extends Context.Service< + AgentSessionScanner, + { + /** + * Discover every directory the configured Claude and Codex homes have run + * a session in. Candidates are returned newest-first; the client decides + * which ones to import and how far back to look. Fails with the contract + * error directly — there is no server-local context worth wrapping. + */ + readonly scan: Effect.Effect; + } +>()("t3/project/AgentSessionScanner") {} + +type AgentSessionSource = AgentSessionProjectCandidate["sources"][number]; + +/** A single directory's worth of evidence from one source. */ +interface RawCandidate { + readonly cwd: string; + readonly source: AgentSessionSource; + readonly threadCount: number; + readonly lastActiveAtMs: number | null; +} + +const decoder = new TextDecoder(); + +/** + * T3 Code runs its own agent sessions inside disposable worktrees. Their + * transcripts look exactly like user sessions, but re-importing the app's own + * sandboxes as projects is never right. Matches this server's configured + * worktrees directory plus the conventional `.t3/worktrees` layout, which + * also catches sandboxes from other T3 homes on the same machine. Separators + * are normalized (and, on Windows, case folded) so the prefix match holds + * there too. Callers check both the recorded spelling and its realpath so a + * symlink into the worktrees directory cannot bypass the filter. + */ +function normalizeForWorktreeMatch(value: string, caseFold: boolean): string { + const normalized = `${value.replaceAll("\\", "/")}/`; + return caseFold ? normalized.toLowerCase() : normalized; +} + +function isT3ManagedWorktree( + candidatePath: string, + worktreesDir: string, + caseFold: boolean, +): boolean { + const normalized = normalizeForWorktreeMatch(candidatePath, caseFold); + return ( + normalized.startsWith(normalizeForWorktreeMatch(worktreesDir, caseFold)) || + normalized.includes("/.t3/worktrees/") + ); +} + +/** Extract `cwd` from a session-meta record, tolerating the shapes each CLI writes. */ +function extractCwd(line: string): string | null { + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return null; + } + if (typeof parsed !== "object" || parsed === null) return null; + + const record = parsed as Record; + if (typeof record.cwd === "string" && record.cwd.trim().length > 0) { + return record.cwd; + } + // Codex nests session metadata under `payload`. + const payload = record.payload; + if (typeof payload === "object" && payload !== null) { + const nested = (payload as Record).cwd; + if (typeof nested === "string" && nested.trim().length > 0) { + return nested; + } + } + return null; +} + +export const make = Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverConfig = yield* ServerConfig.ServerConfig; + const serverSettings = yield* ServerSettings.ServerSettingsService; + const projectionSnapshotQuery = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const worktreesDir = path.resolve(serverConfig.worktreesDir); + // Windows filesystems are case-insensitive, so path prefix checks there + // must case fold. + const foldWorktreeCase = (yield* HostProcessPlatform) === "win32"; + const hostEnvironment = yield* HostProcessEnvironment; + + const listDirectory = (directory: string) => + fileSystem.readDirectory(directory).pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + + const statOption = (target: string) => + fileSystem.stat(target).pipe(Effect.map(Option.some), Effect.orElseSucceed(Option.none)); + + /** + * Read the head of a transcript and return its first complete line. Returns + * `null` when the file is unreadable or its first line exceeds the prefix. + */ + const readFirstLine = Effect.fn("AgentSessionScanner.readFirstLine")(function* ( + filePath: string, + ): Effect.fn.Return { + const prefix = yield* Effect.scoped( + fileSystem + .open(filePath, { flag: "r" }) + .pipe(Effect.flatMap((file) => file.readAlloc(TRANSCRIPT_PREFIX_BYTES))), + ).pipe(Effect.orElseSucceed(Option.none)); + if (Option.isNone(prefix)) return null; + + const text = decoder.decode(prefix.value); + const newlineIndex = text.indexOf("\n"); + if (newlineIndex === -1) { + // Either a single-line file smaller than the prefix, or a first record + // too large to trust as complete JSON. + return prefix.value.length < TRANSCRIPT_PREFIX_BYTES ? text : null; + } + return text.slice(0, newlineIndex); + }); + + const readCwd = Effect.fn("AgentSessionScanner.readCwd")(function* (filePath: string) { + const line = yield* readFirstLine(filePath); + return line === null ? null : extractCwd(line.trim()); + }); + + const latestMtimeMs = Effect.fn("AgentSessionScanner.latestMtimeMs")(function* ( + filePaths: ReadonlyArray, + ) { + let latest: number | null = null; + for (const filePath of filePaths) { + const stats = yield* statOption(filePath); + if (Option.isNone(stats)) continue; + const mtime = stats.value.mtime; + if (Option.isNone(mtime)) continue; + const value = mtime.value.getTime(); + if (latest === null || value > latest) { + latest = value; + } + } + return latest; + }); + + /** + * Resolve the Claude config directory the CLI would use, matching the + * precedence the spawned CLI sees: the instance's `homePath` (exported as + * `CLAUDE_CONFIG_DIR`), then a `CLAUDE_CONFIG_DIR` already in the + * environment, then `~/.claude`. + */ + const resolveClaudeConfigDir = (homePath: string): string => { + const configured = homePath.trim(); + if (configured.length > 0) { + return path.resolve(expandHomePath(configured)); + } + const fromEnvironment = hostEnvironment.CLAUDE_CONFIG_DIR?.trim() ?? ""; + if (fromEnvironment.length > 0) { + return path.resolve(expandHomePath(fromEnvironment)); + } + return path.join(NodeOS.homedir(), ".claude"); + }; + + /** + * Claude keeps one directory per project under `projects/`, named after a + * lossy slug of the path. The slug can't be decoded (both `/` and `.` become + * `-`), so the real path comes from the `cwd` recorded in the newest + * transcript inside it. + */ + const scanClaude = Effect.fn("AgentSessionScanner.scanClaude")(function* (homePath: string) { + const projectsDir = path.join(resolveClaudeConfigDir(homePath), "projects"); + const projectDirectories = yield* listDirectory(projectsDir); + const candidates: Array = []; + let readBudget = MAX_TRANSCRIPTS_PER_SOURCE; + let statBudget = MAX_STATS_PER_SOURCE; + + for (const projectDirectory of projectDirectories) { + if (readBudget <= 0 || statBudget <= 0) break; + const directory = path.join(projectsDir, projectDirectory); + const transcripts = (yield* listDirectory(directory)) + .filter((entry) => entry.endsWith(".jsonl")) + .map((entry) => path.join(directory, entry)); + if (transcripts.length === 0) continue; + + // Stat (bounded), then spend the read budget newest-first, so the cap + // only ever drops the oldest transcripts. + const statted = transcripts.slice(0, statBudget); + statBudget -= statted.length; + const withMtimes: Array<{ filePath: string; mtimeMs: number }> = []; + for (const filePath of statted) { + const stats = yield* statOption(filePath); + if (Option.isNone(stats) || Option.isNone(stats.value.mtime)) continue; + withMtimes.push({ filePath, mtimeMs: stats.value.mtime.value.getTime() }); + } + withMtimes.sort((left, right) => right.mtimeMs - left.mtimeMs); + withMtimes.splice(readBudget); + readBudget -= withMtimes.length; + + // The slug is lossy (`/a/b.c` and `/a/b/c` share a directory), so a + // directory can hold sessions from several distinct paths — group by + // the recorded cwd like the Codex scan does. + const byCwd = new Map(); + for (const entry of withMtimes) { + const cwd = yield* readCwd(entry.filePath); + if (cwd === null) continue; + const existing = byCwd.get(cwd); + if (existing) { + existing.threadCount += 1; + existing.lastActiveAtMs = Math.max(existing.lastActiveAtMs, entry.mtimeMs); + } else { + byCwd.set(cwd, { threadCount: 1, lastActiveAtMs: entry.mtimeMs }); + } + } + for (const [cwd, group] of byCwd) { + candidates.push({ + cwd, + source: "claudeAgent", + threadCount: group.threadCount, + lastActiveAtMs: group.lastActiveAtMs, + }); + } + } + + return candidates; + }); + + /** + * Codex writes `sessions/YYYY/MM/DD/rollout-*.jsonl`. Always scan the shared + * home: in auth-overlay mode the effective home only symlinks to it. + */ + const scanCodex = Effect.fn("AgentSessionScanner.scanCodex")(function* ( + codexSettings: Parameters[0], + ) { + const layout = yield* resolveCodexHomeLayout(codexSettings).pipe( + Effect.provideService(Path.Path, path), + ); + const sessionsDir = path.join(layout.sharedHomePath, "sessions"); + + const rollouts: Array = []; + // Date-partitioned directories sort chronologically, so walking them in + // reverse keeps the newest sessions when the budget runs out. + for (const year of (yield* listDirectory(sessionsDir)).toSorted().toReversed()) { + for (const month of (yield* listDirectory(path.join(sessionsDir, year))) + .toSorted() + .toReversed()) { + for (const day of (yield* listDirectory(path.join(sessionsDir, year, month))) + .toSorted() + .toReversed()) { + const directory = path.join(sessionsDir, year, month, day); + for (const entry of (yield* listDirectory(directory)).toSorted().toReversed()) { + if (!entry.startsWith("rollout-") || !entry.endsWith(".jsonl")) continue; + rollouts.push(path.join(directory, entry)); + if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; + } + if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; + } + if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; + } + if (rollouts.length >= MAX_TRANSCRIPTS_PER_SOURCE) break; + } + + const byCwd = new Map>(); + for (const rollout of rollouts) { + const cwd = yield* readCwd(rollout); + if (cwd === null) continue; + const existing = byCwd.get(cwd); + if (existing) { + existing.push(rollout); + } else { + byCwd.set(cwd, [rollout]); + } + } + + const candidates: Array = []; + for (const [cwd, filePaths] of byCwd) { + candidates.push({ + cwd, + source: "codex", + threadCount: filePaths.length, + lastActiveAtMs: yield* latestMtimeMs(filePaths), + }); + } + return candidates; + }); + + const scan: AgentSessionScanner["Service"]["scan"] = Effect.gen(function* () { + const settings = yield* serverSettings.getSettings.pipe( + Effect.mapError((cause) => new AgentSessionScanError({ operation: "read-settings", cause })), + ); + + const raw = [ + ...(yield* scanClaude(settings.providers.claudeAgent.homePath)), + ...(yield* scanCodex(settings.providers.codex)), + ]; + + // Merge by resolved path first so both sources agree on a key, then by + // realpath so a symlinked home and its target collapse into one candidate. + const merged = new Map< + string, + { + path: string; + sources: Array; + threadCount: number; + lastActiveAtMs: number | null; + } + >(); + const realPathKeys = new Map(); + + for (const candidate of raw) { + const resolved = path.resolve(expandHomePath(candidate.cwd.trim())); + if (isT3ManagedWorktree(resolved, worktreesDir, foldWorktreeCase)) continue; + let key = realPathKeys.get(resolved); + if (key === undefined) { + const stats = yield* statOption(resolved); + // Directories that no longer exist can't be imported. + if (Option.isNone(stats) || stats.value.type !== "Directory") { + realPathKeys.set(resolved, ""); + continue; + } + key = yield* fileSystem.realPath(resolved).pipe(Effect.orElseSucceed(() => resolved)); + // A symlink can point into the worktrees directory even when its own + // spelling doesn't; check again with links resolved. + if (isT3ManagedWorktree(key, worktreesDir, foldWorktreeCase)) { + key = ""; + } + realPathKeys.set(resolved, key); + } + if (key === "") continue; + + const existing = merged.get(key); + if (!existing) { + merged.set(key, { + path: resolved, + sources: [candidate.source], + threadCount: candidate.threadCount, + lastActiveAtMs: candidate.lastActiveAtMs, + }); + continue; + } + if (!existing.sources.includes(candidate.source)) { + existing.sources.push(candidate.source); + } + existing.threadCount += candidate.threadCount; + existing.lastActiveAtMs = + existing.lastActiveAtMs === null || candidate.lastActiveAtMs === null + ? (existing.lastActiveAtMs ?? candidate.lastActiveAtMs) + : Math.max(existing.lastActiveAtMs, candidate.lastActiveAtMs); + } + + const candidates: Array = []; + for (const [key, entry] of merged.entries()) { + // Projects may have been created under either the recorded spelling or + // the resolved realpath (e.g. a symlinked home) — check both. + const lookupPaths = key === entry.path ? [entry.path] : [entry.path, key]; + let alreadyImported = false; + for (const lookupPath of lookupPaths) { + const existingProject = yield* projectionSnapshotQuery + .getActiveProjectByWorkspaceRoot(lookupPath) + .pipe( + Effect.mapError( + (cause) => new AgentSessionScanError({ operation: "read-projects", cause }), + ), + ); + if (Option.isSome(existingProject)) { + alreadyImported = true; + break; + } + } + candidates.push({ + path: entry.path, + title: path.basename(entry.path) || entry.path, + sources: entry.sources, + threadCount: entry.threadCount, + lastActiveAt: + entry.lastActiveAtMs === null + ? null + : DateTime.formatIso(DateTime.makeUnsafe(entry.lastActiveAtMs)), + alreadyImported, + }); + } + + // Newest first, undated candidates last. + candidates.sort((left, right) => { + if (left.lastActiveAt === right.lastActiveAt) return left.path.localeCompare(right.path); + if (left.lastActiveAt === null) return 1; + if (right.lastActiveAt === null) return -1; + return right.lastActiveAt.localeCompare(left.lastActiveAt); + }); + + return { + candidates, + scannedAt: DateTime.formatIso(yield* DateTime.now), + }; + }); + + return AgentSessionScanner.of({ scan }); +}); + +export const layer = Layer.effect(AgentSessionScanner, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 909a51a4cf52..423ef6c008e9 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -97,6 +97,7 @@ import * as VcsProvisioningService from "./vcs/VcsProvisioningService.ts"; import * as GitWorkflowService from "./git/GitWorkflowService.ts"; import * as ReviewService from "./review/ReviewService.ts"; import * as ProjectSetupScriptRunner from "./project/ProjectSetupScriptRunner.ts"; +import * as AgentSessionScanner from "./project/AgentSessionScanner.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; @@ -375,6 +376,7 @@ const makeWsRpcLayer = ( const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; const projectSetupScriptRunner = yield* ProjectSetupScriptRunner.ProjectSetupScriptRunner; + const agentSessionScanner = yield* AgentSessionScanner.AgentSessionScanner; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; const backgroundPolicy = yield* BackgroundPolicy.BackgroundPolicy; const rpcClientIds = yield* Ref.make(new Set()); @@ -1699,6 +1701,10 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "workspace" }, ), + [WS_METHODS.agentSessionsScan]: () => + observeRpcEffect(WS_METHODS.agentSessionsScan, agentSessionScanner.scan, { + "rpc.aggregate": "workspace", + }), [WS_METHODS.assetsCreateUrl]: (input) => observeRpcEffect( WS_METHODS.assetsCreateUrl, @@ -2130,6 +2136,7 @@ export const websocketRpcRouteLayer = Layer.unwrap( Effect.provide( makeWsRpcLayer(session, previewAutomationBroker).pipe( Layer.provideMerge(RpcSerialization.layerJson), + Layer.provide(AgentSessionScanner.layer), Layer.provide(ProviderMaintenanceRunner.layer), Layer.provide(Layer.succeed(ServerSelfUpdate.ServerSelfUpdate, serverSelfUpdate)), Layer.provide( diff --git a/apps/web/src/components/onboarding/FirstRunGate.tsx b/apps/web/src/components/onboarding/FirstRunGate.tsx new file mode 100644 index 000000000000..21edce712317 --- /dev/null +++ b/apps/web/src/components/onboarding/FirstRunGate.tsx @@ -0,0 +1,106 @@ +import { useAtomValue } from "@effect/atom-react"; +import { useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; + +import { useClientSettings, useClientSettingsHydrated } from "../../hooks/useSettings"; +import { + useAllEnvironmentShellsBootstrapped, + useProjects, + useThreadShells, +} from "../../state/entities"; +import { primaryServerConfigAtom } from "../../state/server"; + +/** + * Holds back the authenticated app tree until the first-run decision is known, + * so a fresh install never flashes the main screen before the welcome wizard. + * Nothing renders while pending — no shell, no EventRouter (whose welcome + * payload would otherwise navigate into a thread), no dialogs. + * + * Decision order: a set `onboardingCompletedAt` resolves to the app as soon as + * settings hydrate (the common case, no server round-trip). A `null` flag also + * covers installs that predate the field, so it alone is not enough — the gate + * waits for environment shells to bootstrap and inspects the workspace. A + * timeout guards the pathological case where shells never bootstrap + * (unreachable server): after it, the app renders as usual. + */ + +const FIRST_RUN_DECISION_TIMEOUT_MS = 4_000; + +type FirstRunDecision = "pending" | "app" | "wizard"; + +export function FirstRunGate({ + enabled, + children, +}: { + /** + * Only an authenticated primary-server session gates. Hosted-static has no + * primary server config to inspect and its empty state handles onboarding + * itself; gating it would just add a decision timeout to every load. + */ + readonly enabled: boolean; + readonly children: React.ReactNode; +}) { + const navigate = useNavigate(); + const hydrated = useClientSettingsHydrated(); + const onboardingCompletedAt = useClientSettings((settings) => settings.onboardingCompletedAt); + const bootstrapped = useAllEnvironmentShellsBootstrapped(); + const projects = useProjects(); + const threads = useThreadShells(); + const serverConfig = useAtomValue(primaryServerConfigAtom); + // Within a session settings stay hydrated, so remounts (e.g. returning from + // the wizard) resolve synchronously instead of blanking a frame. + const [decision, setDecision] = useState(() => + !enabled || (hydrated && onboardingCompletedAt !== null) ? "app" : "pending", + ); + + // A workspace still counts as fresh when its only content is the server's + // own cwd auto-bootstrap: web mode creates a project + thread from cwd at + // startup (`autoBootstrapProjectFromCwd` defaults on there), so "no + // projects at all" would mean `npx t3` users never see the wizard. Any + // other project, or more than one thread, is real user state. + const serverCwd = serverConfig?.cwd ?? null; + const workspaceFresh = + projects.every((project) => project.workspaceRoot === serverCwd) && threads.length <= 1; + + useEffect(() => { + if (decision !== "pending" || !hydrated) return; + if (!enabled || onboardingCompletedAt !== null) { + setDecision("app"); + return; + } + // Both shells AND the server config must be in before the workspace can + // be judged: shells bootstrap and config load independently, and a + // disconnected environment reports bootstrapped with empty projections. + // Without the config a fresh cwd-bootstrapped install would read as + // non-fresh (projects but no cwd yet), and an offline existing install + // would read as fresh (nothing at all). Config never arriving means the + // timeout below falls back to the app. + if (!bootstrapped || serverConfig === null) return; + setDecision(workspaceFresh ? "wizard" : "app"); + }, [ + bootstrapped, + decision, + enabled, + hydrated, + onboardingCompletedAt, + serverConfig, + workspaceFresh, + ]); + + useEffect(() => { + if (decision !== "pending") return; + const timer = window.setTimeout(() => setDecision("app"), FIRST_RUN_DECISION_TIMEOUT_MS); + return () => window.clearTimeout(timer); + }, [decision]); + + useEffect(() => { + if (decision === "wizard") { + void navigate({ to: "/welcome", replace: true }); + } + }, [decision, navigate]); + + if (decision !== "app") { + return null; + } + return children; +} diff --git a/apps/web/src/components/onboarding/WelcomeWizard.tsx b/apps/web/src/components/onboarding/WelcomeWizard.tsx new file mode 100644 index 000000000000..4123dfda1036 --- /dev/null +++ b/apps/web/src/components/onboarding/WelcomeWizard.tsx @@ -0,0 +1,1029 @@ +import { useAuth } from "@clerk/react"; +import { useAtomValue } from "@effect/atom-react"; +import type { + AgentSessionProjectCandidate, + EnvironmentId, + ServerProvider, +} from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { ThreadId } from "@t3tools/contracts"; +import { CheckIcon, ChevronLeftIcon, CopyIcon, TerminalIcon } from "lucide-react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; + +import { hasCloudPublicConfig } from "../../cloud/publicConfig"; +import { useT3ConnectAuthPrompt } from "../clerk/useT3ConnectAuthPrompt"; +import { useCompleteOnboarding } from "../../onboarding/firstRun"; +import { newProjectId } from "../../lib/utils"; +import { resolveDefaultProviderModelSelection } from "../../providerInstances"; +import { agentSessionScan } from "../../state/agentSessions"; +import { useEnvironments, usePrimaryEnvironment } from "../../state/environments"; +import { useEnvironmentQuery } from "../../state/query"; +import { projectEnvironment } from "../../state/projects"; +import { primaryServerKeybindingsAtom, serverEnvironment } from "../../state/server"; +import { terminalEnvironment } from "../../state/terminal"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { connectPairing } from "../../connection/onboarding"; +import { getProviderSummary } from "../settings/providerStatus"; +import { getDriverOption } from "../settings/providerDriverMeta"; +import { CloudEnvironmentConnectRows } from "../cloud/CloudEnvironmentConnectList"; +import { TerminalViewport } from "../ThreadTerminalDrawer"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { Input } from "../ui/input"; +import { cn } from "../../lib/utils"; + +/** + * First-run welcome wizard. Rendered as the full-screen `/welcome` route on a + * fresh install (no completed-onboarding flag, empty workspace). Flow per the + * onboarding overhaul spec: connection choice → sign-in/pair (remote paths) → + * agent setup with inline install terminal → project import → main screen. + * Every step past the connection gate is skippable; the whole wizard is + * re-runnable by clearing the flag. + */ + +type WizardStep = "connection" | "connect-machines" | "pair-direct" | "agents" | "import"; + +/** + * The machine the agent and import steps run against: the primary environment + * when it's connected, otherwise the first connected environment (the remote + * paths on web/hosted have no primary). Deliberately not a persisted "primary + * machine" concept — just whichever machine is reachable right now, labeled + * inline on each step. + */ +function useOnboardingTargetEnvironment() { + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + if (primaryEnvironment !== null && primaryEnvironment.connection.phase === "connected") { + return primaryEnvironment; + } + return environments.find((environment) => environment.connection.phase === "connected") ?? null; +} + +const AGENT_ONBOARDING_THREAD_ID = ThreadId.make("onboarding-agent-setup"); +const IMPORT_RECENT_WINDOW_MS = 30 * 24 * 60 * 60 * 1000; + +export function WelcomeWizard({ + localAvailable, + onDone, +}: { + /** + * Whether the "Local Only" card is offered. True whenever the app is served + * by an authenticated primary server — desktop, `npx t3`, or a dev server — + * since that server is "this machine" regardless of the hostname the app + * was opened from. Only hosted-static (app.t3.codes) has no local server. + */ + readonly localAvailable: boolean; + readonly onDone: () => void; +}) { + const completeOnboarding = useCompleteOnboarding(); + const [step, setStep] = useState("connection"); + const finish = useCallback(() => { + completeOnboarding(); + onDone(); + }, [completeOnboarding, onDone]); + + return ( +
+
+
+
+
+
+ +
+ {step === "connection" ? ( + setStep("agents")} + onConnect={() => setStep("connect-machines")} + onDirect={() => setStep("pair-direct")} + /> + ) : step === "connect-machines" ? ( + setStep("connection")} + onContinue={() => setStep("agents")} + /> + ) : step === "pair-direct" ? ( + setStep("connection")} onPaired={() => setStep("agents")} /> + ) : step === "agents" ? ( + setStep("import")} onSkip={() => setStep("import")} /> + ) : ( + + )} +
+
+ ); +} + +// ── Step 1: connection choice ──────────────────────────────── + +function ConnectionStep({ + localAvailable, + onLocal, + onConnect, + onDirect, +}: { + readonly localAvailable: boolean; + readonly onLocal: () => void; + readonly onConnect: () => void; + readonly onDirect: () => void; +}) { + const cloudEnabled = hasCloudPublicConfig(); + const [choice, setChoice] = useState<"local" | "connect" | "direct">( + localAvailable ? "local" : cloudEnabled ? "connect" : "direct", + ); + + const advance = () => { + if (choice === "local") onLocal(); + else if (choice === "connect") onConnect(); + else onDirect(); + }; + + return ( + <> +

+ How do you want to connect? +

+

+ Choose where your agents run. You can add more connections later in Settings. +

+
+ {localAvailable ? ( + setChoice("local")} + /> + ) : null} + {cloudEnabled ? ( + setChoice("connect")} + /> + ) : null} + setChoice("direct")} + /> +
+
+ +
+ + ); +} + +function ConnectionCard({ + title, + description, + tag, + selected, + onSelect, +}: { + readonly title: string; + readonly description: string; + readonly tag: string; + readonly selected: boolean; + readonly onSelect: () => void; +}) { + return ( + + ); +} + +// ── Step 2: T3 Connect (sign in, then connect machines) ────── + +const CONNECT_LOGIN_COMMAND = "npx t3 connect"; + +/** + * Sign-in and machine-connection combined: signed out shows the Clerk prompt, + * signed in forks on account state — zero connected machines blocks on the + * `npx t3 connect` command and auto-advance is left to the user pressing + * Continue once their machine appears; existing machines show a confirmation + * list with the command folded away. There is deliberately no "primary + * machine" selection. + */ +function ConnectMachinesStep({ + onBack, + onContinue, +}: { + readonly onBack: () => void; + readonly onContinue: () => void; +}) { + // Mirrors ManagedRelayAuthProvider: a pending Clerk session must not read + // as signed-out mid-transition. + const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); + const { openAuthPrompt } = useT3ConnectAuthPrompt(); + const { environments } = useEnvironments(); + const primaryEnvironment = usePrimaryEnvironment(); + const savedEnvironments = environments.filter( + (environment) => environment.entry.target._tag !== "PrimaryConnectionTarget", + ); + // Only a live connection counts: a saved-but-offline machine must not show + // the "connected" confirmation (the agents step would find nothing to + // probe). Its row still renders in the list either way. + const hasRemoteMachines = savedEnvironments.some( + (environment) => environment.connection.phase === "connected", + ); + + if (!isLoaded) { + return ; + } + + if (!isSignedIn) { + return ( + +
+ +
+
+ ); + } + + return ( + + {hasRemoteMachines ? ( + <> +
+ +
+
+ + Add another machine + + +
+
+ +
+ + ) : ( + <> + +
+ + Waiting for a machine. It appears here the moment it signs in. +

+ } + /> +
+
+ +
+ Waiting for a machine… + +
+
+ + )} +
+ ); +} + +// ── Step 2′: Direct pairing ────────────────────────────────── + +/** + * Server-minted pairing, D-B treatment: numbered steps, `t3 pair` on the + * server, paste the URL here. Registers the remote environment in this + * browser's catalog (same path the hosted /pair surface uses). + */ +function PairDirectStep({ + onBack, + onPaired, +}: { + readonly onBack: () => void; + readonly onPaired: () => void; +}) { + const connectPairingEnvironment = useAtomCommand(connectPairing, { reportFailure: false }); + const [pairingUrl, setPairingUrl] = useState(""); + const [errorMessage, setErrorMessage] = useState(""); + const [isPairing, setIsPairing] = useState(false); + + const submit = async () => { + setIsPairing(true); + setErrorMessage(""); + const result = await connectPairingEnvironment({ pairingUrl }); + setIsPairing(false); + if (result._tag === "Success") { + onPaired(); + return; + } + if (isAtomCommandInterrupted(result)) return; + const cause = squashAtomCommandFailure(result); + setErrorMessage(cause instanceof Error ? cause.message : "Pairing failed."); + }; + + return ( + +
+
+

+ 1. On the server: +

+ +

+ Prints a one-time pairing URL. Add{" "} + --tailscale to publish on your + tailnet. +

+
+
+

+ 2. Paste the URL it prints: +

+ setPairingUrl(event.currentTarget.value)} + onKeyDown={(event) => { + if (event.key === "Enter" && pairingUrl.trim().length > 0) void submit(); + }} + /> +
+ {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+
+ +
+
+ ); +} + +// ── Step 3: agents ─────────────────────────────────────────── + +const PRIMARY_AGENT_DRIVERS = ["claudeAgent", "codex"] as const; + +const AGENT_INSTALL_COMMANDS: Record = { + claudeAgent: "npm install -g @anthropic-ai/claude-code", + codex: "npm install -g @openai/codex", +}; + +// Claude has no `login` subcommand — running it interactively prompts OAuth. +const AGENT_LOGIN_COMMANDS: Record = { + claudeAgent: "claude", + codex: "codex login", +}; + +/** + * Claude Code and Codex as hero cards with live probe status; the remaining + * drivers listed quietly below. Install opens the built-in terminal inline + * with the command pre-typed — the update RPC can't install a binary that + * isn't there yet (it infers the package manager from the installed binary's + * path), and the terminal also handles the interactive login that follows. + */ +function AgentsStep({ + onContinue, + onSkip, +}: { + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const targetEnvironment = useOnboardingTargetEnvironment(); + if (targetEnvironment === null) { + return ( + +
+ +
+
+ ); + } + return ( + + ); +} + +function ConnectedAgentsStep({ + environmentId, + machineLabel, + onContinue, + onSkip, +}: { + readonly environmentId: EnvironmentId; + readonly machineLabel: string; + readonly onContinue: () => void; + readonly onSkip: () => void; +}) { + const providers = useAtomValue(serverEnvironment.providersValueAtom(environmentId)); + const refreshProviders = useAtomCommand(serverEnvironment.refreshProviders, { + reportFailure: false, + }); + const [terminalAgent, setTerminalAgent] = useState(null); + + // Re-probe on entry so freshly installed CLIs show up without a manual + // refresh; harmless when nothing changed (single-flighted per environment). + useEffect(() => { + void refreshProviders({ environmentId, input: {} }); + }, [environmentId, refreshProviders]); + + const byDriver = useMemo(() => { + const map = new Map(); + for (const provider of providers ?? []) { + if (!map.has(provider.driver)) map.set(provider.driver, provider); + } + return map; + }, [providers]); + + const primaryAgents = PRIMARY_AGENT_DRIVERS.map((driver) => ({ + driver, + provider: byDriver.get(driver), + })); + const readyCount = primaryAgents.filter( + ({ provider }) => + provider?.enabled === true && provider.installed && provider.auth.status === "authenticated", + ).length; + const otherAgents = [...byDriver.keys()].filter( + (driver) => !PRIMARY_AGENT_DRIVERS.includes(driver as (typeof PRIMARY_AGENT_DRIVERS)[number]), + ); + + return ( + +
+ {primaryAgents.map(({ driver, provider }) => ( + setTerminalAgent(driver)} + /> + ))} +
+ {terminalAgent !== null ? ( + { + setTerminalAgent(null); + void refreshProviders({ environmentId, input: {} }); + }} + /> + ) : null} + {otherAgents.length > 0 ? ( +

+ Also supported:{" "} + {otherAgents + .map((driver) => getDriverOption(driver as never)?.label ?? driver) + .join(" · ")}{" "} + — configure in Settings. +

+ ) : null} +
+ +
+ + {readyCount} of {primaryAgents.length} ready + + +
+
+
+ ); +} + +function AgentCard({ + driver, + provider, + terminalOpen, + onOpenTerminal, +}: { + readonly driver: string; + readonly provider: ServerProvider | undefined; + readonly terminalOpen: boolean; + readonly onOpenTerminal: () => void; +}) { + const meta = getDriverOption(driver as never); + const Icon = meta?.icon; + const displayName = driver === "claudeAgent" ? "Claude Code" : (meta?.label ?? driver); + const summary = getProviderSummary(provider); + // A provider disabled in settings is neither ready nor installable from + // here; the summary already reads "Disabled" and the card offers no action. + const disabled = provider !== undefined && !provider.enabled; + const usable = provider?.enabled === true && provider.installed; + const ready = usable && provider.auth.status === "authenticated"; + const needsLogin = usable && provider.auth.status !== "authenticated"; + + return ( +
+
+ {Icon ? : null} + {displayName} +
+

+ {summary.headline} + {summary.detail ? ` · ${summary.detail}` : ""} +

+
+ {ready ? ( + + + Ready + + ) : disabled ? ( + Enable in Settings + ) : ( + + )} +
+
+ ); +} + +/** + * Inline install terminal. Opens a PTY on the connected environment under a + * synthetic onboarding thread id (terminals are keyed by free-form thread id; + * the server validates only the cwd) and pre-types the install or login + * command without submitting, so the user reviews and presses Enter. + */ +// Each drawer mount gets its own PTY: install and sign-in for the same +// driver must not share a session, or the second open would reattach and +// either duplicate the pre-typed command or feed it to a running process. +let onboardingTerminalSequence = 0; + +function AgentInstallTerminal({ + environmentId, + driver, + installed, + onClose, +}: { + readonly environmentId: EnvironmentId; + readonly driver: string; + readonly installed: boolean; + readonly onClose: () => void; +}) { + const keybindings = useAtomValue(primaryServerKeybindingsAtom); + const serverConfig = useAtomValue(serverEnvironment.configValueAtom(environmentId)); + const openTerminal = useAtomCommand(terminalEnvironment.open, { reportFailure: false }); + const writeTerminal = useAtomCommand(terminalEnvironment.write, { reportFailure: false }); + const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const preparedRef = useRef(false); + const [terminalId] = useState(() => { + onboardingTerminalSequence += 1; + return `onboarding-${driver}-${onboardingTerminalSequence}`; + }); + const threadRef = useMemo( + () => scopeThreadRef(environmentId, AGENT_ONBOARDING_THREAD_ID), + [environmentId], + ); + // The terminal manager stats the cwd verbatim (no tilde expansion), so use + // the server process's own working directory — always real on that machine. + const cwd = serverConfig?.cwd ?? null; + + const command = installed + ? (AGENT_LOGIN_COMMANDS[driver] ?? "") + : (AGENT_INSTALL_COMMANDS[driver] ?? ""); + const [preTypeFailed, setPreTypeFailed] = useState(false); + + useEffect(() => { + if (preparedRef.current || cwd === null) return; + void (async () => { + const opened = await openTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, cwd }, + }); + // Only a successful open ends the attempts, so a transient RPC failure + // retries on the next render instead of leaving a dead terminal. + if (opened._tag !== "Success") return; + preparedRef.current = true; + if (command.length === 0) return; + // Pre-type without the trailing carriage return; the user submits. + // The terminal id is unique to this mount, so this session has never + // been written to before. + const wrote = await writeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId, data: command }, + }); + // A silent failure would leave a blank prompt under copy that says + // "review the command" — fall back to telling the user what to type. + if (wrote._tag !== "Success") setPreTypeFailed(true); + })(); + }, [command, cwd, environmentId, openTerminal, terminalId, writeTerminal]); + + // Every exit path unmounts the drawer (Done, Continue/Skip, card switch, + // session exit), so unmount cleanup is the single place the PTY dies — + // nothing is left running behind the wizard. An interrupted install is + // re-runnable from the card. + useEffect(() => { + return () => { + void closeTerminal({ + environmentId, + input: { threadId: AGENT_ONBOARDING_THREAD_ID, terminalId }, + }); + }; + }, [closeTerminal, environmentId, terminalId]); + + if (cwd === null) { + return null; + } + + return ( +
+
+ + {preTypeFailed ? ( + <> + Run {command} in this + terminal. + + ) : ( + "Review the command, then press Enter to run it." + )} + + +
+
+ undefined} + focusRequestId={1} + autoFocus + resizeEpoch={0} + drawerHeight={256} + keybindings={keybindings} + /> +
+
+ ); +} + +// ── Step 4: import ─────────────────────────────────────────── + +/** + * One-decision import (4B): a summary line with Import recent / Choose / + * Skip. The default imports only projects touched in the last 30 days; + * Choose expands a checklist including older ones. Projects only — thread + * history import is a follow-up. + */ +function ImportStep({ onDone }: { readonly onDone: () => void }) { + const targetEnvironment = useOnboardingTargetEnvironment(); + const environmentId = targetEnvironment?.environmentId ?? null; + const machineLabel = targetEnvironment?.label ?? "this machine"; + const providers = useAtomValue( + serverEnvironment.providersValueAtom(environmentId ?? ("" as EnvironmentId)), + ); + const scan = useEnvironmentQuery( + environmentId === null ? null : agentSessionScan({ environmentId, input: {} }), + ); + const createProject = useAtomCommand(projectEnvironment.create, { reportFailure: false }); + const [choosing, setChoosing] = useState(false); + const [deselected, setDeselected] = useState>(new Set()); + const [isImporting, setIsImporting] = useState(false); + const [importError, setImportError] = useState(""); + + const candidates = useMemo( + () => (scan.data?.candidates ?? []).filter((candidate) => !candidate.alreadyImported), + [scan.data], + ); + const recentCutoff = Date.now() - IMPORT_RECENT_WINDOW_MS; + const recent = useMemo( + () => + candidates.filter( + (candidate) => + candidate.lastActiveAt !== null && Date.parse(candidate.lastActiveAt) >= recentCutoff, + ), + [candidates, recentCutoff], + ); + const older = candidates.length - recent.length; + + const runImport = async (selection: ReadonlyArray) => { + if (environmentId === null || selection.length === 0) { + onDone(); + return; + } + setIsImporting(true); + setImportError(""); + const defaultModelSelection = resolveDefaultProviderModelSelection(providers ?? [], null); + let failures = 0; + for (const candidate of selection) { + const result = await createProject({ + environmentId, + input: { + projectId: newProjectId(), + title: candidate.title, + workspaceRoot: candidate.path, + createWorkspaceRootIfMissing: false, + defaultModelSelection, + }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + failures += 1; + } + } + setIsImporting(false); + if (failures > 0) { + setImportError( + failures === selection.length + ? "Import failed. You can add projects manually from the command palette." + : `Imported ${selection.length - failures} of ${selection.length} projects. The rest can be added from the command palette.`, + ); + return; + } + onDone(); + }; + + if (environmentId === null || (scan.isPending && scan.data === null)) { + return ( + +
+ +
+
+ ); + } + + if (scan.error !== null || candidates.length === 0) { + return ( + +

+ You can add projects any time from the command palette. +

+
+ +
+
+ ); + } + + if (choosing) { + const selectedCount = candidates.length - deselected.size; + return ( + setChoosing(false)} + description={`${candidates.length} projects found on ${machineLabel}.`} + > +
+ {candidates.map((candidate) => ( + + ))} +
+ {importError ?

{importError}

: null} +
+ + +
+
+ ); + } + + return ( + 0 ? ` ${older} older ${older === 1 ? "project is" : "projects are"} available too.` : ""}`} + > + {importError ?

{importError}

: null} +
+ + + +
+

+ Imports projects only. Thread history import coming soon. +

+
+ ); +} + +// ── Shared bits ────────────────────────────────────────────── + +function StepShell({ + title, + description, + onBack, + children, +}: { + readonly title: string; + readonly description?: string; + readonly onBack?: () => void; + readonly children?: React.ReactNode; +}) { + return ( + <> + {onBack ? ( + + ) : null} +

+ {title} +

+ {description ? ( +

{description}

+ ) : null} + {children} + + ); +} + +function CommandBlock({ + command, + className, + prominent = false, +}: { + readonly command: string; + readonly className?: string; + readonly prominent?: boolean; +}) { + const [copied, setCopied] = useState(false); + return ( +
+ + $ + {command} + + +
+ ); +} + +function formatSource(source: "claudeAgent" | "codex"): string { + return source === "claudeAgent" ? "Claude" : "Codex"; +} + +function formatRelativeTime(iso: string): string { + const deltaMs = Date.now() - Date.parse(iso); + const minutes = Math.round(deltaMs / 60_000); + if (minutes < 60) return `${Math.max(minutes, 1)}m ago`; + const hours = Math.round(minutes / 60); + if (hours < 24) return `${hours}h ago`; + const days = Math.round(hours / 24); + if (days < 30) return `${days}d ago`; + const months = Math.round(days / 30); + return `${months}mo ago`; +} diff --git a/apps/web/src/onboarding/firstRun.ts b/apps/web/src/onboarding/firstRun.ts new file mode 100644 index 000000000000..592c920d4328 --- /dev/null +++ b/apps/web/src/onboarding/firstRun.ts @@ -0,0 +1,15 @@ +import { useCallback } from "react"; + +import { useUpdateClientSettings } from "../hooks/useSettings"; + +/** + * Marks first-run onboarding finished (or skipped) so FirstRunGate never + * routes to the welcome wizard again. The gate itself lives in + * `components/onboarding/FirstRunGate.tsx`. + */ +export function useCompleteOnboarding(): () => void { + const updateClientSettings = useUpdateClientSettings(); + return useCallback(() => { + updateClientSettings({ onboardingCompletedAt: new Date().toISOString() }); + }, [updateClientSettings]); +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 58ab4c3a714e..2fd270b976a9 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -9,6 +9,7 @@ // Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. import { Route as rootRouteImport } from './routes/__root' +import { Route as WelcomeRouteImport } from './routes/welcome' import { Route as SettingsRouteImport } from './routes/settings' import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' @@ -27,6 +28,11 @@ import { Route as ConnectCallbackRouteImport } from './routes/connect_.callback' import { Route as ChatDraftDraftIdRouteImport } from './routes/_chat.draft.$draftId' import { Route as ChatEnvironmentIdThreadIdRouteImport } from './routes/_chat.$environmentId.$threadId' +const WelcomeRoute = WelcomeRouteImport.update({ + id: '/welcome', + path: '/welcome', + getParentRoute: () => rootRouteImport, +} as any) const SettingsRoute = SettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -118,6 +124,7 @@ export interface FileRoutesByFullPath { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/welcome': typeof WelcomeRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -135,6 +142,7 @@ export interface FileRoutesByTo { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/welcome': typeof WelcomeRoute '/connect/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -155,6 +163,7 @@ export interface FileRoutesById { '/connect': typeof ConnectRoute '/pair': typeof PairRoute '/settings': typeof SettingsRouteWithChildren + '/welcome': typeof WelcomeRoute '/connect_/callback': typeof ConnectCallbackRoute '/settings/appearance': typeof SettingsAppearanceRoute '/settings/archived': typeof SettingsArchivedRoute @@ -176,6 +185,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/welcome' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -193,6 +203,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/welcome' | '/connect/callback' | '/settings/appearance' | '/settings/archived' @@ -212,6 +223,7 @@ export interface FileRouteTypes { | '/connect' | '/pair' | '/settings' + | '/welcome' | '/connect_/callback' | '/settings/appearance' | '/settings/archived' @@ -232,11 +244,19 @@ export interface RootRouteChildren { ConnectRoute: typeof ConnectRoute PairRoute: typeof PairRoute SettingsRoute: typeof SettingsRouteWithChildren + WelcomeRoute: typeof WelcomeRoute ConnectCallbackRoute: typeof ConnectCallbackRoute } declare module '@tanstack/react-router' { interface FileRoutesByPath { + '/welcome': { + id: '/welcome' + path: '/welcome' + fullPath: '/welcome' + preLoaderRoute: typeof WelcomeRouteImport + parentRoute: typeof rootRouteImport + } '/settings': { id: '/settings' path: '/settings' @@ -406,6 +426,7 @@ const rootRouteChildren: RootRouteChildren = { ConnectRoute: ConnectRoute, PairRoute: PairRoute, SettingsRoute: SettingsRouteWithChildren, + WelcomeRoute: WelcomeRoute, ConnectCallbackRoute: ConnectCallbackRoute, } export const routeTree = rootRouteImport diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114de..aa17bae8d6b7 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding"; import { resolveServerBackedAppDisplayName } from "../branding.logic"; import { AppSidebarLayout } from "../components/AppSidebarLayout"; import { CommandPalette } from "../components/CommandPalette"; +import { FirstRunGate } from "../components/onboarding/FirstRunGate"; import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; @@ -106,6 +107,17 @@ function RootRouteView() { ); } + // The welcome wizard is full-screen like /pair, but keeps toasts so its + // connect/import actions can report failures. + if (pathname === "/welcome") { + return ( + + + + + ); + } + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { return ( <> @@ -123,20 +135,26 @@ function RootRouteView() { ); + // FirstRunGate holds back everything below it — including EventRouter, + // whose welcome payload navigates into a thread — until the first-run + // decision is known, so a fresh install renders nothing (not the shell, + // not a flash of threads) before landing on the welcome wizard. return ( - {primaryEnvironmentAuthenticated ? : null} - - - - - - {primaryEnvironmentAuthenticated ? : null} - {primaryEnvironmentAuthenticated ? : null} - {appShell} + + {primaryEnvironmentAuthenticated ? : null} + + + + + + {primaryEnvironmentAuthenticated ? : null} + {primaryEnvironmentAuthenticated ? : null} + {appShell} + ); diff --git a/apps/web/src/routes/_chat.index.tsx b/apps/web/src/routes/_chat.index.tsx index 6e8dbe33ff5f..e0a9eb07bc76 100644 --- a/apps/web/src/routes/_chat.index.tsx +++ b/apps/web/src/routes/_chat.index.tsx @@ -80,6 +80,8 @@ function IndexDraftLanding() { /> ) : null; } + // First-run routing to the welcome wizard happens in FirstRunGate at the + // root, before this route ever renders. return ; } diff --git a/apps/web/src/routes/welcome.tsx b/apps/web/src/routes/welcome.tsx new file mode 100644 index 000000000000..7fb86a65dad0 --- /dev/null +++ b/apps/web/src/routes/welcome.tsx @@ -0,0 +1,37 @@ +import { createFileRoute, redirect, useNavigate } from "@tanstack/react-router"; + +import { WelcomeWizard } from "../components/onboarding/WelcomeWizard"; + +/** + * First-run welcome wizard. Full-screen, outside the sidebar shell (the root + * route mounts this path bare, like /pair). Reached only via the first-run + * gate on the index route; visiting it directly after onboarding is harmless — + * finishing again just refreshes the completion flag. + */ +export const Route = createFileRoute("/welcome")({ + beforeLoad: ({ context }) => { + const { authGateState } = context; + if (authGateState.status !== "authenticated" && authGateState.status !== "hosted-static") { + throw redirect({ to: "/pair", replace: true }); + } + }, + component: WelcomeRouteView, +}); + +function WelcomeRouteView() { + const { authGateState } = Route.useRouteContext(); + const navigate = useNavigate(); + // An authenticated gate means a primary server is serving this app — + // desktop, `npx t3`, or a dev server — and that server is "this machine" + // no matter what hostname the browser used. Only hosted-static has no + // local server to offer. + const localAvailable = authGateState.status === "authenticated"; + return ( + { + void navigate({ to: "/", replace: true }); + }} + /> + ); +} diff --git a/apps/web/src/state/agentSessions.ts b/apps/web/src/state/agentSessions.ts new file mode 100644 index 000000000000..327cb0e6539f --- /dev/null +++ b/apps/web/src/state/agentSessions.ts @@ -0,0 +1,17 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import { createEnvironmentRpcQueryAtomFamily } from "@t3tools/client-runtime/state/runtime"; + +import { connectionAtomRuntime } from "../connection/runtime"; + +/** + * Scan of Claude Code / Codex home directories on an environment, surfacing + * project candidates for the welcome wizard's import step. The scan walks the + * filesystem server-side, so results are cached briefly and refreshed when the + * import step remounts. + */ +export const agentSessionScan = createEnvironmentRpcQueryAtomFamily(connectionAtomRuntime, { + label: "environment-data:agent-sessions:scan", + tag: WS_METHODS.agentSessionsScan, + staleTimeMs: 30_000, + idleTtlMs: 5 * 60_000, +}); diff --git a/docs/user/welcome-wizard.md b/docs/user/welcome-wizard.md new file mode 100644 index 000000000000..20676d742f51 --- /dev/null +++ b/docs/user/welcome-wizard.md @@ -0,0 +1,34 @@ +# Welcome Wizard + +On a fresh install, T3 Code opens a short setup flow before the main app. +Installs with existing projects or completed onboarding skip it. + +## Choose how to connect + +- **Local Only** — agents run on this machine. No account needed. Shown on the + desktop app and locally served web app. +- **T3 Connect** — sign in and reach any of your machines from anywhere. + Machines signed into your account connect automatically. If none are + connected yet, the wizard shows the command to run on the machine with your + code (`npx t3 connect`) and advances when it appears. +- **Direct** — connect to a server by URL. Works over LAN and Tailscale. Run + `npx t3 pair` on the server and paste the pairing URL it prints. + +## Set up your agents + +The wizard checks the connected machine for Claude Code and Codex and shows +their install and sign-in status. If one is missing, an inline terminal opens +with the install command pre-typed — press Enter to run it. It's a real shell, +so you can run the CLI's sign-in there too, or use the card's **Sign in** +action after the install finishes. Other supported agents are configured in +Settings → Providers. + +## Import your projects + +T3 Code scans the connected machine for directories where Claude Code or Codex +have already run and offers them as projects. The default imports projects +active in the last 30 days; **Choose** lists everything found. Imports create +projects only — thread history import is coming later. + +Every step after the connection choice can be skipped. Projects can always be +added later from the command palette. diff --git a/packages/contracts/src/agentSessions.ts b/packages/contracts/src/agentSessions.ts new file mode 100644 index 000000000000..8c739beb5154 --- /dev/null +++ b/packages/contracts/src/agentSessions.ts @@ -0,0 +1,46 @@ +import * as Schema from "effect/Schema"; +import { IsoDateTime, NonNegativeInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +/** Coding agent home directories the scanner knows how to read. */ +export const AgentSessionSource = Schema.Literals(["claudeAgent", "codex"]); +export type AgentSessionSource = typeof AgentSessionSource.Type; + +/** + * Empty for now. Kept as a struct so future scan options (source filters, + * explicit roots) can be added without a new method. + */ +export const AgentSessionScanInput = Schema.Struct({}); +export type AgentSessionScanInput = typeof AgentSessionScanInput.Type; + +/** + * A directory that at least one agent CLI has run in, suitable for import as a + * T3 Code project. `alreadyImported` marks candidates that already have an + * active project rooted at the same path. + */ +export const AgentSessionProjectCandidate = Schema.Struct({ + path: TrimmedNonEmptyString, + title: TrimmedNonEmptyString, + sources: Schema.Array(AgentSessionSource), + threadCount: NonNegativeInt, + lastActiveAt: Schema.NullOr(IsoDateTime), + alreadyImported: Schema.Boolean, +}); +export type AgentSessionProjectCandidate = typeof AgentSessionProjectCandidate.Type; + +export const AgentSessionScanResult = Schema.Struct({ + candidates: Schema.Array(AgentSessionProjectCandidate), + scannedAt: IsoDateTime, +}); +export type AgentSessionScanResult = typeof AgentSessionScanResult.Type; + +export class AgentSessionScanError extends Schema.TaggedErrorClass()( + "AgentSessionScanError", + { + operation: Schema.Literals(["read-settings", "read-projects"]), + cause: Schema.Defect(), + }, +) { + override get message(): string { + return `Failed to scan agent sessions during ${this.operation}.`; + } +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index f0ee1889177f..8cf767acc8ef 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -23,6 +23,7 @@ export * from "./t3ProjectFile.ts"; export * from "./editor.ts"; export * from "./project.ts"; export * from "./filesystem.ts"; +export * from "./agentSessions.ts"; export * from "./assets.ts"; export * from "./review.ts"; export * from "./preview.ts"; diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 400011f88435..94e7d3034a5e 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -18,6 +18,11 @@ import { FilesystemBrowseResult, FilesystemBrowseError, } from "./filesystem.ts"; +import { + AgentSessionScanInput, + AgentSessionScanResult, + AgentSessionScanError, +} from "./agentSessions.ts"; import { AssetAccessError, AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import { GitActionProgressEvent, @@ -180,6 +185,9 @@ export const WS_METHODS = { filesystemBrowse: "filesystem.browse", assetsCreateUrl: "assets.createUrl", + // Agent session discovery methods + agentSessionsScan: "agentSessions.scan", + // VCS methods vcsPull: "vcs.pull", vcsRefreshStatus: "vcs.refreshStatus", @@ -477,6 +485,12 @@ export const WsFilesystemBrowseRpc = Rpc.make(WS_METHODS.filesystemBrowse, { error: Schema.Union([FilesystemBrowseError, EnvironmentAuthorizationError]), }); +export const WsAgentSessionsScanRpc = Rpc.make(WS_METHODS.agentSessionsScan, { + payload: AgentSessionScanInput, + success: AgentSessionScanResult, + error: Schema.Union([AgentSessionScanError, EnvironmentAuthorizationError]), +}); + export const WsAssetsCreateUrlRpc = Rpc.make(WS_METHODS.assetsCreateUrl, { payload: AssetCreateUrlInput, success: AssetCreateUrlResult, @@ -816,6 +830,7 @@ export const WsRpcGroup = RpcGroup.make( WsProjectsWriteFileRpc, WsShellOpenInEditorRpc, WsFilesystemBrowseRpc, + WsAgentSessionsScanRpc, WsAssetsCreateUrlRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 2a0e087d06c3..5f01a1311c38 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -80,6 +80,14 @@ export const ClientSettingsSchema = Schema.Struct({ glassOpacity: GlassOpacity.pipe( Schema.withDecodingDefault(Effect.succeed(DEFAULT_GLASS_OPACITY)), ), + // When the first-run welcome wizard finished (or was skipped), as an ISO + // timestamp. `null` alone does not mean "show the wizard" — every install + // that predates this field decodes to `null` — so the gate also requires an + // empty workspace (no projects or threads) before it treats the client as a + // fresh install. + onboardingCompletedAt: Schema.NullOr(Schema.String).pipe( + Schema.withDecodingDefault(Effect.succeed(null)), + ), // Model favorites. Historically keyed by provider kind, now // widened to `ProviderInstanceId` so users can favorite a specific model // on a custom provider instance (e.g. "Codex Personal · gpt-5") without @@ -690,6 +698,7 @@ export const ClientSettingsPatch = Schema.Struct({ diffIgnoreWhitespace: Schema.optionalKey(Schema.Boolean), environmentIdentificationMode: Schema.optionalKey(EnvironmentIdentificationMode), glassOpacity: Schema.optionalKey(GlassOpacity), + onboardingCompletedAt: Schema.optionalKey(Schema.NullOr(Schema.String)), favorites: Schema.optionalKey( Schema.Array( Schema.Struct({