From 71918c6575418b51f0baba16713bbc7183e06c77 Mon Sep 17 00:00:00 2001 From: Vegard Stikbakke Date: Fri, 5 Jun 2026 14:56:35 +0200 Subject: [PATCH] feat(coding-agent): add pi.dev integration Add pi.dev account integration, profile setup, shared session support, activity sync, and slash command support using shared pi.dev OAuth/client utilities. --- packages/coding-agent/README.md | 6 +- packages/coding-agent/docs/sessions.md | 2 +- packages/coding-agent/docs/settings.md | 26 ++ packages/coding-agent/docs/usage.md | 21 +- packages/coding-agent/src/cli/args.ts | 11 +- .../coding-agent/src/cli/startup-setup.ts | 110 +++++ .../src/core/activity-sync/activity-sync.ts | 214 ++++++++++ .../src/core/activity-sync/api.ts | 146 +++++++ .../src/core/activity-sync/index.ts | 80 ++++ .../src/core/activity-sync/payload.ts | 211 +++++++++ .../activity-sync/session-analytics-reader.ts | 141 ++++++ .../core/activity-sync/session-analytics.ts | 331 ++++++++++++++ .../core/activity-sync/session-discovery.ts | 185 ++++++++ .../src/core/activity-sync/state.ts | 114 +++++ .../coding-agent/src/core/auth-storage.ts | 51 +++ packages/coding-agent/src/core/index.ts | 156 ++++++- .../coding-agent/src/core/pi-dev/config.ts | 29 ++ packages/coding-agent/src/core/pi-dev/http.ts | 106 +++++ .../coding-agent/src/core/pi-dev/index.ts | 67 +++ .../coding-agent/src/core/pi-dev/oauth.ts | 325 ++++++++++++++ .../src/core/pi-dev/session-share.ts | 124 ++++++ .../coding-agent/src/core/settings-manager.ts | 93 +++- packages/coding-agent/src/core/setup-state.ts | 184 ++++++++ .../coding-agent/src/core/slash-commands.ts | 3 +- packages/coding-agent/src/index.ts | 176 +++++++- packages/coding-agent/src/main.ts | 19 +- .../interactive/components/login-dialog.ts | 30 +- .../components/settings-selector.ts | 17 +- .../src/modes/interactive/interactive-mode.ts | 371 ++++++++++++++-- .../modes/interactive/pi-dev-login-dialog.ts | 47 ++ .../src/modes/interactive/setup-wizard.ts | 404 ++++++++++++++++++ .../test/activity-sync-api.test.ts | 136 ++++++ .../test/activity-sync-payload.test.ts | 86 ++++ .../test/activity-sync-state.test.ts | 93 ++++ .../coding-agent/test/activity-sync.test.ts | 235 ++++++++++ .../interactive-mode-share-command.test.ts | 79 ++++ .../test/session-analytics-reader.test.ts | 83 ++++ .../test/session-analytics.test.ts | 276 ++++++++++++ .../test/session-discovery.test.ts | 98 +++++ .../coding-agent/test/session-share.test.ts | 226 ++++++++++ .../coding-agent/test/setup-state.test.ts | 61 +++ .../coding-agent/test/slash-commands.test.ts | 12 + 42 files changed, 5097 insertions(+), 88 deletions(-) create mode 100644 packages/coding-agent/src/cli/startup-setup.ts create mode 100644 packages/coding-agent/src/core/activity-sync/activity-sync.ts create mode 100644 packages/coding-agent/src/core/activity-sync/api.ts create mode 100644 packages/coding-agent/src/core/activity-sync/index.ts create mode 100644 packages/coding-agent/src/core/activity-sync/payload.ts create mode 100644 packages/coding-agent/src/core/activity-sync/session-analytics-reader.ts create mode 100644 packages/coding-agent/src/core/activity-sync/session-analytics.ts create mode 100644 packages/coding-agent/src/core/activity-sync/session-discovery.ts create mode 100644 packages/coding-agent/src/core/activity-sync/state.ts create mode 100644 packages/coding-agent/src/core/pi-dev/config.ts create mode 100644 packages/coding-agent/src/core/pi-dev/http.ts create mode 100644 packages/coding-agent/src/core/pi-dev/index.ts create mode 100644 packages/coding-agent/src/core/pi-dev/oauth.ts create mode 100644 packages/coding-agent/src/core/pi-dev/session-share.ts create mode 100644 packages/coding-agent/src/core/setup-state.ts create mode 100644 packages/coding-agent/src/modes/interactive/pi-dev-login-dialog.ts create mode 100644 packages/coding-agent/src/modes/interactive/setup-wizard.ts create mode 100644 packages/coding-agent/test/activity-sync-api.test.ts create mode 100644 packages/coding-agent/test/activity-sync-payload.test.ts create mode 100644 packages/coding-agent/test/activity-sync-state.test.ts create mode 100644 packages/coding-agent/test/activity-sync.test.ts create mode 100644 packages/coding-agent/test/interactive-mode-share-command.test.ts create mode 100644 packages/coding-agent/test/session-analytics-reader.test.ts create mode 100644 packages/coding-agent/test/session-analytics.test.ts create mode 100644 packages/coding-agent/test/session-discovery.test.ts create mode 100644 packages/coding-agent/test/session-share.test.ts create mode 100644 packages/coding-agent/test/setup-state.test.ts create mode 100644 packages/coding-agent/test/slash-commands.test.ts diff --git a/packages/coding-agent/README.md b/packages/coding-agent/README.md index 202b6f3e9..484be06de 100644 --- a/packages/coding-agent/README.md +++ b/packages/coding-agent/README.md @@ -178,9 +178,10 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | Command | Description | |---------|-------------| | `/login`, `/logout` | OAuth authentication | +| `/pi.dev` | Create or sign in to a pi.dev profile | | `/model` | Switch models | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | -| `/settings` | Thinking level, theme, message delivery, transport | +| `/settings` | Thinking level, theme, message delivery, transport, activity sync | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | @@ -192,7 +193,7 @@ Type `/` in the editor to trigger commands. [Extensions](#extensions) can regist | `/compact [prompt]` | Manually compact context, optional custom instructions | | `/copy` | Copy last assistant message to clipboard | | `/export [file]` | Export session to HTML file | -| `/share` | Upload as private GitHub gist with shareable HTML link | +| `/share [pi.dev\|github]` | Upload with shareable HTML link backed by pi.dev when authenticated, otherwise GitHub gist | | `/reload` | Reload keybindings, extensions, skills, prompts, and context files (themes hot-reload automatically) | | `/hotkeys` | Show all keyboard shortcuts | | `/changelog` | Display version history | @@ -307,6 +308,7 @@ Pi has two separate startup features: - **Update check:** fetches `https://pi.dev/api/latest-version` to check whether a newer Pi version exists. Disable it with `PI_SKIP_VERSION_CHECK=1`. Disabling update checks only turns off this check. - **Install/update telemetry:** after first install or a changelog-detected update, sends an anonymous version ping to `https://pi.dev/api/report-install`. This setting also controls optional provider attribution headers for OpenRouter, Cloudflare, and direct NVIDIA NIM requests. Opt out by setting `enableInstallTelemetry` to `false` in `settings.json`, or by setting `PI_TELEMETRY=0`. This does not disable update checks; Pi may still contact `pi.dev` for the latest version unless update checks are disabled or offline mode is enabled. +- **pi.dev profiles:** setup or `/pi.dev` can create or sign in to a pi.dev profile and enable background activity sync. `/share` uses an existing pi.dev profile for session sharing, otherwise falls back to GitHub gist; `/share pi.dev` signs in only to store shared sessions. Disable activity sync with `/settings` or `piDev.activitySync.enabled`. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. diff --git a/packages/coding-agent/docs/sessions.md b/packages/coding-agent/docs/sessions.md index 1a50ee02d..575e4a63e 100644 --- a/packages/coding-agent/docs/sessions.md +++ b/packages/coding-agent/docs/sessions.md @@ -32,7 +32,7 @@ For the JSONL file format and SessionManager API, see [Session Format](session-f | `/clone` | Duplicate the current active branch into a new session | | `/compact [prompt]` | Summarize older context; see [Compaction](compaction.md) | | `/export [file]` | Export session to HTML | -| `/share` | Upload as private GitHub gist with shareable HTML link | +| `/share [pi.dev\|github]` | Upload with shareable HTML link backed by pi.dev when authenticated, otherwise GitHub gist | ## Resuming and Deleting Sessions diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index df2d0bd61..8b13c5165 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -64,6 +64,32 @@ Use `/trust` in interactive mode to save a project trust decision for future ses Set `PI_SKIP_VERSION_CHECK=1` to disable the Pi version update check. Use `--offline` or `PI_OFFLINE=1` to disable all startup network operations described here, including update checks, package update checks, and install/update telemetry. +### pi.dev Integration + +`/share` uses pi.dev for unlisted session sharing when an authenticated pi.dev profile is already available; otherwise it falls back to the GitHub gist backend. Setup, `/pi.dev`, and `/share pi.dev` create or sign in to a pi.dev profile when needed, storing the pi.dev OAuth credential in `auth.json` under `pi.dev`. `PI_DEV_URL` overrides the pi.dev API base URL. + +Creating or signing in to a pi.dev profile during setup or with `/pi.dev` enables background sync of session activity analytics metadata. `/share pi.dev` signs in only to store shared sessions and does not change activity sync. Disable sync with `/settings` or `piDev.activitySync.enabled`. + +| Setting | Type | Default | Description | +|---------|------|---------|-------------| +| `piDev.activitySync.enabled` | boolean | `false` | Enable background activity sync after pi.dev profile setup | +| `piDev.activitySync.intervalHours` | number | `24` | Minimum hours between background sync attempts | +| `piDev.activitySync.deviceId` | string | auto | Stable device ID used for pi.dev activity sync | + +Activity sync omits raw message content, tool arguments, thinking text, error text, labels, names, and custom data. Non-secret sync timestamps are stored in `activity-sync.json`. `PI_OFFLINE=1` disables background sync. + +```json +{ + "piDev": { + "activitySync": { + "deviceId": "019eab26-10a1-79a4-94b0-dc6914f6a82d", + "enabled": true, + "intervalHours": 24 + } + } +} +``` + ### Warnings | Setting | Type | Default | Description | diff --git a/packages/coding-agent/docs/usage.md b/packages/coding-agent/docs/usage.md index ad9b80e14..78b715692 100644 --- a/packages/coding-agent/docs/usage.md +++ b/packages/coding-agent/docs/usage.md @@ -35,10 +35,11 @@ Type `/` in the editor to open command completion. Extensions can register custo | Command | Description | |---------|-------------| -| `/login`, `/logout` | Manage OAuth or API-key credentials | +| `/login`, `/logout` | OAuth authentication | +| `/pi.dev` | Create or sign in to a pi.dev profile | | `/model` | Switch models | | `/scoped-models` | Enable/disable models for Ctrl+P cycling | -| `/settings` | Thinking level, theme, message delivery, transport | +| `/settings` | Thinking level, theme, message delivery, transport, activity sync | | `/resume` | Pick from previous sessions | | `/new` | Start a new session | | `/name ` | Set session display name | @@ -49,7 +50,7 @@ Type `/` in the editor to open command completion. Extensions can register custo | `/compact [prompt]` | Manually compact context, optionally with custom instructions | | `/copy` | Copy last assistant message to clipboard | | `/export [file]` | Export session to HTML | -| `/share` | Upload as private GitHub gist with shareable HTML link | +| `/share [pi.dev\|github]` | Upload with shareable HTML link backed by pi.dev when authenticated, otherwise GitHub gist | | `/reload` | Reload keybindings, extensions, skills, prompts, and context files | | `/hotkeys` | Show all keyboard shortcuts | | `/changelog` | Display version history | @@ -124,7 +125,17 @@ Use `/trust` in interactive mode to save a project trust decision for future ses Use `/export [file]` to write a session to HTML. -Use `/share` to upload a private GitHub gist with a shareable HTML link. +Use `/share` to share the current session HTML. Pi uses pi.dev for an unlisted share when an authenticated pi.dev profile is already available; otherwise it creates a GitHub gist with the `gh` CLI and returns a share-viewer URL. + +Use `/share pi.dev` to create or sign in to a pi.dev profile and upload to pi.dev. Use `/share github` to force the GitHub gist backend. `PI_SHARE_VIEWER_URL` controls the viewer base URL for GitHub gist shares. + +### Activity Sync + +Creating or signing in to a pi.dev profile during setup or with `/pi.dev` enables background activity sync. `/share pi.dev` signs in only to store shared sessions and does not change activity sync. Disable sync from `/settings` or by setting `piDev.activitySync.enabled` to `false`. + +Activity sync uploads session activity analytics metadata to pi.dev, including session and entry metadata, model IDs, token/cost usage, and content block counts. It omits raw message content, tool arguments, thinking text, error text, labels, names, and custom data. + +When enabled, Pi stores the pi.dev OAuth credential in `auth.json`, stores non-secret sync state in `activity-sync.json`, and writes settings under `piDev.activitySync` in `settings.json`. Background sync runs at most once every `piDev.activitySync.intervalHours` hours and is disabled by `PI_OFFLINE=1`. If you use pi for open source work and want to publish sessions for model, prompt, tool, and evaluation research, see [`badlogic/pi-share-hf`](https://github.com/badlogic/pi-share-hf). It publishes sessions to Hugging Face datasets. @@ -288,6 +299,8 @@ pi --exclude-tools ask_question | `PI_OFFLINE` | Disable startup network operations, including update checks, package update checks, and install/update telemetry | | `PI_SKIP_VERSION_CHECK` | Skip the Pi version update check at startup. This prevents the `pi.dev` latest-version request | | `PI_TELEMETRY` | Override install/update telemetry and provider attribution headers: `1`/`true`/`yes` or `0`/`false`/`no`. This does not disable update checks | +| `PI_DEV_URL` | Base URL for pi.dev API calls; default is `https://pi.dev` | +| `PI_SHARE_VIEWER_URL` | Base URL for GitHub gist `/share github` viewer URLs; default is `https://pi.dev/session/` | | `PI_CACHE_RETENTION` | Set to `long` for extended prompt cache where supported | | `VISUAL`, `EDITOR` | External editor for Ctrl+G | diff --git a/packages/coding-agent/src/cli/args.ts b/packages/coding-agent/src/cli/args.ts index 0258c0c1a..9a39fdf30 100644 --- a/packages/coding-agent/src/cli/args.ts +++ b/packages/coding-agent/src/cli/args.ts @@ -47,6 +47,7 @@ export interface Args { offline?: boolean; verbose?: boolean; projectTrustOverride?: boolean; + noSetup?: boolean; messages: string[]; fileArgs: string[]; /** Unknown flags (potentially extension flags) - map of flag name to value */ @@ -181,6 +182,8 @@ export function parseArgs(args: string[]): Args { result.projectTrustOverride = true; } else if (arg === "--no-approve" || arg === "-na") { result.projectTrustOverride = false; + } else if (arg === "--no-setup") { + result.noSetup = true; } else if (arg === "--offline") { result.offline = true; } else if (arg.startsWith("@")) { @@ -235,6 +238,8 @@ ${chalk.bold("Commands:")} ${APP_NAME} config [--no-approve] Open TUI to enable/disable package resources ${APP_NAME} --help Show help for install/remove/uninstall/update/list + ${APP_NAME} list List installed extensions from settings + ${APP_NAME} config Open TUI to enable/disable package resources ${chalk.bold("Options:")} --provider Provider name (default: google) @@ -275,6 +280,7 @@ ${chalk.bold("Options:")} --verbose Force verbose startup (overrides quietStartup setting) --approve, -a Trust project-local files for this run --no-approve, -na Ignore project-local files for this run + --no-setup Skip proactive first-run setup --offline Disable startup network operations (same as PI_OFFLINE=1) --help, -h Show this help --version, -v Show version number @@ -377,8 +383,9 @@ ${chalk.bold("Environment Variables:")} ${ENV_SESSION_DIR.padEnd(32)} - Session storage directory (overridden by --session-dir) PI_PACKAGE_DIR - Override package directory (for Nix/Guix store paths) PI_OFFLINE - Disable startup network operations when set to 1/true/yes - PI_TELEMETRY - Override install telemetry when set to 1/true/yes or 0/false/no - PI_SHARE_VIEWER_URL - Base URL for /share command (default: https://pi.dev/session/) + PI_TELEMETRY - Override telemetry when set to 1/true/yes or 0/false/no + PI_DEV_URL - Base URL for pi.dev API calls (default: https://pi.dev) + PI_SHARE_VIEWER_URL - Base URL for GitHub gist /share URLs (default: https://pi.dev/session/) ${chalk.bold("Built-in Tool Names:")} read - Read file contents diff --git a/packages/coding-agent/src/cli/startup-setup.ts b/packages/coding-agent/src/cli/startup-setup.ts new file mode 100644 index 000000000..4091d0e58 --- /dev/null +++ b/packages/coding-agent/src/cli/startup-setup.ts @@ -0,0 +1,110 @@ +import { Container, ProcessTerminal, setKeybindings, TUI } from "@earendil-works/pi-tui"; +import { getStableActivitySyncDeviceId } from "../core/activity-sync/state.ts"; +import type { AuthStorage } from "../core/auth-storage.ts"; +import { KeybindingsManager } from "../core/keybindings.ts"; +import { getPiDevAuth, PI_DEV_PROFILE_SCOPES, PI_DEV_SETUP_PROFILE_CONNECTED_STATUS } from "../core/pi-dev/index.ts"; +import type { SettingsManager } from "../core/settings-manager.ts"; +import { hasPendingSetupSteps } from "../core/setup-state.ts"; +import { runPiDevLoginDialog } from "../modes/interactive/pi-dev-login-dialog.ts"; +import { runSetupWizard } from "../modes/interactive/setup-wizard.ts"; +import { initTheme } from "../modes/interactive/theme/theme.ts"; + +export interface StartupSetupResult { + statusMessage?: string; + errorMessage?: string; +} + +async function connectPiDevProfile(options: { + tui: TUI; + container: Container; + settingsManager: SettingsManager; + authStorage: AuthStorage; +}): Promise<{ accessToken?: string; errorMessage?: string }> { + const deviceId = getStableActivitySyncDeviceId(options.settingsManager); + await options.settingsManager.flush(); + + const auth = await getPiDevAuth(options.authStorage, PI_DEV_PROFILE_SCOPES); + let accessToken: string; + if (auth.available) { + accessToken = auth.accessToken; + } else { + try { + const credential = await runPiDevLoginDialog({ + tui: options.tui, + container: options.container, + authStorage: options.authStorage, + scopes: PI_DEV_PROFILE_SCOPES, + deviceId, + title: "Create pi.dev profile", + }); + if (!credential) { + return {}; + } + accessToken = credential.access; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + return { errorMessage: `Failed to login to pi.dev: ${message}` }; + } + } + + options.settingsManager.setActivitySyncEnabled(true); + await options.settingsManager.flush(); + return { accessToken }; +} + +export async function runStartupSetupIfNeeded(options: { + agentDir: string; + settingsManager: SettingsManager; + authStorage: AuthStorage; + skip?: boolean; +}): Promise { + if ( + options.skip === true || + process.stdin.isTTY !== true || + process.stdout.isTTY !== true || + Boolean(process.env.PI_OFFLINE) || + !hasPendingSetupSteps(options.agentDir, { + themeConfigured: options.settingsManager.getTheme() !== undefined, + }) + ) { + return {}; + } + + initTheme(options.settingsManager.getTheme()); + setKeybindings(KeybindingsManager.create()); + + const tui = new TUI(new ProcessTerminal(), options.settingsManager.getShowHardwareCursor()); + tui.setClearOnShrink(options.settingsManager.getClearOnShrink()); + const container = new Container(); + tui.addChild(container); + tui.start(); + + try { + const result = await runSetupWizard({ + tui, + settingsManager: options.settingsManager, + agentDir: options.agentDir, + mode: "automatic", + container, + }); + + if (!result.profileRequested) { + return {}; + } + + const profileResult = await connectPiDevProfile({ + tui, + container, + settingsManager: options.settingsManager, + authStorage: options.authStorage, + }); + return { + errorMessage: profileResult.errorMessage, + statusMessage: profileResult.accessToken + ? PI_DEV_SETUP_PROFILE_CONNECTED_STATUS + : "Setup complete. pi.dev profile setup skipped.", + }; + } finally { + tui.stop(); + } +} diff --git a/packages/coding-agent/src/core/activity-sync/activity-sync.ts b/packages/coding-agent/src/core/activity-sync/activity-sync.ts new file mode 100644 index 000000000..41b462cd2 --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/activity-sync.ts @@ -0,0 +1,214 @@ +import { randomUUID } from "node:crypto"; +import { join } from "node:path"; +import { AuthStorage } from "../auth-storage.ts"; +import { getPiDevAuth, PI_DEV_ACTIVITY_SYNC_SCOPE } from "../pi-dev/index.ts"; +import { SettingsManager } from "../settings-manager.ts"; +import { + ActivitySyncApiError, + type ActivitySyncFetch, + getActivitySyncWatermark, + uploadSessionAnalytics, +} from "./api.ts"; +import { type ActivitySyncPayload, buildActivitySyncPayloads } from "./payload.ts"; +import { buildSessionAnalyticsUpload } from "./session-analytics-reader.ts"; +import { + getActivitySyncStatePaths, + getStableActivitySyncDeviceId, + loadActivitySyncState, + saveActivitySyncState, + withActivitySyncLock, +} from "./state.ts"; + +export type ActivitySyncStatus = "uploaded" | "no_changes" | "not_authenticated" | "already_running" | "failed"; + +export interface ActivitySyncResult { + status: ActivitySyncStatus; + recordsSent?: number; + compressedBytes?: number; + decompressedBytes?: number; + /** Server watermark returned by GET /analytics/activity/:deviceId before building the upload. */ + serverWatermark?: string | null; + /** Watermark returned by the upload response, or the current server watermark when there are no changes. */ + watermark?: string; + filesScanned?: number; + error?: string; +} + +export interface SyncSessionAnalyticsOptions { + agentDir?: string; + sessionsRoot?: string; + settingsManager?: SettingsManager; + authStorage?: AuthStorage; + fetch?: ActivitySyncFetch; + signal?: AbortSignal; + now?: Date; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +function getActivitySyncAuthStorage(options: SyncSessionAnalyticsOptions): AuthStorage { + if (options.authStorage) return options.authStorage; + const { agentDir } = getActivitySyncStatePaths(options.agentDir); + return AuthStorage.create(join(agentDir, "auth.json")); +} + +async function getActivitySyncAccessToken( + authStorage: AuthStorage, + options: SyncSessionAnalyticsOptions, + forceRefresh = false, +): Promise { + const auth = await getPiDevAuth(authStorage, [PI_DEV_ACTIVITY_SYNC_SCOPE], { + fetch: options.fetch, + forceRefresh, + }); + return auth.available ? auth.accessToken : undefined; +} + +async function uploadWithRefreshRetry( + authStorage: AuthStorage, + accessToken: string, + payload: Pick, + metadata: { deviceId: string; idempotencyKey: string }, + options: SyncSessionAnalyticsOptions, +): Promise<{ watermark: string; accessToken: string }> { + try { + const response = await uploadSessionAnalytics({ + fetch: options.fetch, + accessToken, + deviceId: metadata.deviceId, + watermark: payload.watermark, + idempotencyKey: metadata.idempotencyKey, + body: payload.body, + contentEncoding: payload.contentEncoding, + }); + return { watermark: response.watermark, accessToken }; + } catch (error) { + if (!(error instanceof ActivitySyncApiError) || error.status !== 401) throw error; + const refreshedAccessToken = await getActivitySyncAccessToken(authStorage, options, true); + if (!refreshedAccessToken) throw error; + const response = await uploadSessionAnalytics({ + fetch: options.fetch, + accessToken: refreshedAccessToken, + deviceId: metadata.deviceId, + watermark: payload.watermark, + idempotencyKey: metadata.idempotencyKey, + body: payload.body, + contentEncoding: payload.contentEncoding, + }); + return { watermark: response.watermark, accessToken: refreshedAccessToken }; + } +} + +async function getWatermarkWithRefreshRetry( + authStorage: AuthStorage, + accessToken: string, + deviceId: string, + options: SyncSessionAnalyticsOptions, +): Promise<{ watermark: string | null; accessToken: string }> { + try { + const response = await getActivitySyncWatermark(accessToken, deviceId, { + fetch: options.fetch, + }); + return { watermark: response.watermark, accessToken }; + } catch (error) { + if (!(error instanceof ActivitySyncApiError) || error.status !== 401) throw error; + const refreshedAccessToken = await getActivitySyncAccessToken(authStorage, options, true); + if (!refreshedAccessToken) throw error; + const response = await getActivitySyncWatermark(refreshedAccessToken, deviceId, { + fetch: options.fetch, + }); + return { watermark: response.watermark, accessToken: refreshedAccessToken }; + } +} + +async function syncSessionAnalyticsUnlocked(options: SyncSessionAnalyticsOptions): Promise { + const settingsManager = options.settingsManager ?? SettingsManager.create(process.cwd(), options.agentDir); + const deviceId = getStableActivitySyncDeviceId(settingsManager); + await settingsManager.flush(); + const state = await loadActivitySyncState(options.agentDir); + state.lastAttemptAt = (options.now ?? new Date()).toISOString(); + await saveActivitySyncState(state, options.agentDir); + + const authStorage = getActivitySyncAuthStorage(options); + let accessToken = await getActivitySyncAccessToken(authStorage, options); + if (!accessToken) return { status: "not_authenticated" }; + + const watermarkResponse = await getWatermarkWithRefreshRetry(authStorage, accessToken, deviceId, options); + accessToken = watermarkResponse.accessToken; + const serverWatermark = watermarkResponse.watermark; + + try { + // The server watermark means: the server has accepted everything this client had fully scanned/prepared through this local time. + const upload = await buildSessionAnalyticsUpload({ + serverWatermark, + sessionsRoot: options.sessionsRoot, + signal: options.signal, + }); + + if (upload.records.length === 0) { + await saveActivitySyncState(state, options.agentDir); + return { + status: "no_changes", + filesScanned: upload.filesScanned, + serverWatermark, + watermark: serverWatermark ?? undefined, + }; + } + + const payloads = await buildActivitySyncPayloads({ + records: upload.records, + scanCutoff: upload.scanCutoff, + serverWatermark, + }); + let recordsSent = 0; + let compressedBytes = 0; + let decompressedBytes = 0; + let watermark = serverWatermark ?? undefined; + + for (const payload of payloads) { + const uploaded = await uploadWithRefreshRetry( + authStorage, + accessToken, + payload, + { deviceId, idempotencyKey: randomUUID() }, + options, + ); + accessToken = uploaded.accessToken; + watermark = uploaded.watermark; + recordsSent += payload.recordCount; + compressedBytes += payload.compressedBytes; + decompressedBytes += payload.decompressedBytes; + state.lastSuccessAt = (options.now ?? new Date()).toISOString(); + await saveActivitySyncState(state, options.agentDir); + } + + return { + status: "uploaded", + recordsSent, + compressedBytes, + decompressedBytes, + serverWatermark, + watermark, + filesScanned: upload.filesScanned, + }; + } catch (error) { + return { status: "failed", error: errorMessage(error), serverWatermark }; + } +} + +export async function syncSessionAnalytics(options: SyncSessionAnalyticsOptions = {}): Promise { + const locked = await withActivitySyncLock(async () => { + try { + return await syncSessionAnalyticsUnlocked(options); + } catch (error) { + return { + status: "failed", + error: errorMessage(error), + } satisfies ActivitySyncResult; + } + }, options.agentDir); + if (locked.status === "already_running") return { status: "already_running" }; + return locked.result; +} diff --git a/packages/coding-agent/src/core/activity-sync/api.ts b/packages/coding-agent/src/core/activity-sync/api.ts new file mode 100644 index 000000000..70845defe --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/api.ts @@ -0,0 +1,146 @@ +import type { Buffer } from "node:buffer"; +import { + PI_DEV_ACTIVITY_SYNC_SCOPE, + PI_DEV_DEFAULT_BASE_URL, + PI_DEV_OAUTH_CLIENT_ID, + PI_DEV_OFFLINE_ACCESS_SCOPE, +} from "../pi-dev/config.ts"; +import { + getPiDevApiUrl, + getPiDevFetch, + isRecord, + PiDevApiError, + type PiDevApiOptions, + type PiDevFetch, + readJson, + requireNumber, + requireString, + throwIfPiDevNotOk, +} from "../pi-dev/http.ts"; +import { + type PiDevDeviceFlowResponse, + type PiDevTokenResponse, + pollPiDevDeviceToken, + refreshPiDevAccessToken, + startPiDevDeviceFlow, +} from "../pi-dev/oauth.ts"; + +export const ACTIVITY_SYNC_CLIENT_ID = PI_DEV_OAUTH_CLIENT_ID; +export const ACTIVITY_SYNC_SCOPE = `${PI_DEV_ACTIVITY_SYNC_SCOPE} ${PI_DEV_OFFLINE_ACCESS_SCOPE}`; +export const DEFAULT_PI_DEV_URL = PI_DEV_DEFAULT_BASE_URL; + +export type ActivitySyncDeviceFlowResponse = PiDevDeviceFlowResponse; +export type ActivitySyncTokenResponse = PiDevTokenResponse; + +export interface ActivitySyncWatermarkResponse { + ok: true; + watermark: string | null; +} + +export interface ActivitySyncUploadResponse { + ok: true; + accepted: true; + received_bytes: number; + watermark: string; +} + +export type ActivitySyncFetch = PiDevFetch; + +export interface ActivitySyncApiOptions extends PiDevApiOptions {} + +export interface UploadSessionAnalyticsOptions extends ActivitySyncApiOptions { + accessToken: string; + deviceId: string; + watermark: string; + idempotencyKey: string; + body: Buffer; + contentEncoding: "zstd"; +} + +export class ActivitySyncApiError extends PiDevApiError { + constructor(status: number, errorCode: string | undefined, description: string | undefined, operation?: string) { + super(status, errorCode, description, operation); + this.name = "ActivitySyncApiError"; + } +} + +function parseWatermarkResponse(json: unknown): ActivitySyncWatermarkResponse { + if (!isRecord(json) || json.ok !== true || (json.watermark !== null && typeof json.watermark !== "string")) { + throw new Error("Invalid activity sync watermark response"); + } + return { ok: true, watermark: json.watermark }; +} + +function parseUploadResponse(json: unknown): ActivitySyncUploadResponse { + if (!isRecord(json) || json.ok !== true || json.accepted !== true) { + throw new Error("Invalid activity sync upload response"); + } + return { + ok: true, + accepted: true, + received_bytes: requireNumber(json, "received_bytes", "activity sync upload response"), + watermark: requireString(json, "watermark", "activity sync upload response"), + }; +} + +export async function startActivitySyncDeviceFlow( + deviceId: string, + options: ActivitySyncApiOptions = {}, +): Promise { + return startPiDevDeviceFlow({ + ...options, + deviceId, + scopes: [PI_DEV_ACTIVITY_SYNC_SCOPE], + errorClass: ActivitySyncApiError, + }); +} + +export async function pollActivitySyncDeviceToken( + deviceCode: string, + options: ActivitySyncApiOptions = {}, +): Promise { + return pollPiDevDeviceToken(deviceCode, { + ...options, + errorClass: ActivitySyncApiError, + }); +} + +export async function refreshActivitySyncAccessToken( + refreshToken: string, + options: ActivitySyncApiOptions = {}, +): Promise { + return refreshPiDevAccessToken(refreshToken, { + ...options, + errorClass: ActivitySyncApiError, + }); +} + +export async function getActivitySyncWatermark( + accessToken: string, + deviceId: string, + options: ActivitySyncApiOptions = {}, +): Promise { + const response = await getPiDevFetch(options.fetch)(getPiDevApiUrl(`/analytics/activity/${deviceId}`), { + headers: { Authorization: `Bearer ${accessToken}` }, + }); + await throwIfPiDevNotOk(response, "GET /analytics/activity/:deviceId", ActivitySyncApiError); + return parseWatermarkResponse(await readJson(response)); +} + +export async function uploadSessionAnalytics( + options: UploadSessionAnalyticsOptions, +): Promise { + const response = await getPiDevFetch(options.fetch)(getPiDevApiUrl(`/analytics/activity/${options.deviceId}`), { + method: "POST", + headers: { + Authorization: `Bearer ${options.accessToken}`, + "Content-Type": "application/x-ndjson", + "Content-Encoding": options.contentEncoding, + "Pi-Sync-Watermark": options.watermark, + "Idempotency-Key": options.idempotencyKey, + }, + body: options.body, + }); + await throwIfPiDevNotOk(response, "POST /analytics/activity/:deviceId", ActivitySyncApiError); + return parseUploadResponse(await readJson(response)); +} diff --git a/packages/coding-agent/src/core/activity-sync/index.ts b/packages/coding-agent/src/core/activity-sync/index.ts new file mode 100644 index 000000000..936642d7c --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/index.ts @@ -0,0 +1,80 @@ +/** + * Activity sync utilities. + */ + +export { + type ActivitySyncResult, + type ActivitySyncStatus, + type SyncSessionAnalyticsOptions, + syncSessionAnalytics, +} from "./activity-sync.ts"; +export { + ACTIVITY_SYNC_CLIENT_ID, + ACTIVITY_SYNC_SCOPE, + ActivitySyncApiError, + type ActivitySyncApiOptions, + type ActivitySyncDeviceFlowResponse, + type ActivitySyncFetch, + type ActivitySyncTokenResponse, + type ActivitySyncUploadResponse, + type ActivitySyncWatermarkResponse, + DEFAULT_PI_DEV_URL, + getActivitySyncWatermark, + pollActivitySyncDeviceToken, + refreshActivitySyncAccessToken, + startActivitySyncDeviceFlow, + type UploadSessionAnalyticsOptions, + uploadSessionAnalytics, +} from "./api.ts"; +export { + ACTIVITY_SYNC_CONTENT_ENCODING, + ACTIVITY_SYNC_MAX_COMPRESSED_BYTES, + ACTIVITY_SYNC_MAX_DECOMPRESSED_BYTES, + type ActivitySyncPayload, + type BuildActivitySyncPayloadsOptions, + buildActivitySyncPayloads, + compareSessionAnalyticsRecords, + getSessionAnalyticsRecordTimestamp, + serializeSessionAnalyticsNdjson, + sortSessionAnalyticsRecords, +} from "./payload.ts"; +export { + hashSessionAnalyticsString, + type ProjectSessionAnalyticsOptions, + type ProjectSessionHeaderAnalyticsOptions, + projectSessionEntryForAnalytics, + projectSessionForAnalytics, + projectSessionHeaderForAnalytics, + SESSION_ANALYTICS_SCHEMA_VERSION, + type SessionAnalyticsContentStats, + type SessionAnalyticsEntryRecord, + type SessionAnalyticsRecord, + type SessionAnalyticsSessionRecord, + type SessionAnalyticsUsage, +} from "./session-analytics.ts"; +export { + type BuildSessionAnalyticsUploadOptions, + type BuildSessionAnalyticsUploadResult, + buildSessionAnalyticsUpload, +} from "./session-analytics-reader.ts"; +export { + type DiscoveredSession, + type DiscoverSessionFilesOptions, + type DiscoverSessionsOptions, + discoverSessionFiles, + discoverSessions, + type SessionDiscoveryPhase, + type SessionDiscoveryProgress, + type SessionDiscoveryProgressCallback, +} from "./session-discovery.ts"; +export { + type ActivitySyncLockResult, + type ActivitySyncState, + type ActivitySyncStatePaths, + getActivitySyncStatePaths, + getStableActivitySyncDeviceId, + loadActivitySyncState, + saveActivitySyncState, + updateActivitySyncState, + withActivitySyncLock, +} from "./state.ts"; diff --git a/packages/coding-agent/src/core/activity-sync/payload.ts b/packages/coding-agent/src/core/activity-sync/payload.ts new file mode 100644 index 000000000..96d9405e9 --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/payload.ts @@ -0,0 +1,211 @@ +import { Buffer } from "node:buffer"; +import { promisify } from "node:util"; +import { zstdCompress } from "node:zlib"; +import type { SessionAnalyticsRecord } from "./session-analytics.ts"; + +export const ACTIVITY_SYNC_CONTENT_ENCODING = "zstd"; +export const ACTIVITY_SYNC_MAX_COMPRESSED_BYTES = 25 * 1024 * 1024; +export const ACTIVITY_SYNC_MAX_DECOMPRESSED_BYTES = 75 * 1024 * 1024; + +export interface BuildActivitySyncPayloadsOptions { + records: SessionAnalyticsRecord[]; + scanCutoff: string; + serverWatermark: string | null; + maxCompressedBytes?: number; + maxDecompressedBytes?: number; + compress?: (input: Buffer) => Promise; +} + +export interface ActivitySyncPayload { + records: SessionAnalyticsRecord[]; + recordCount: number; + firstRecordTimestamp: string; + lastRecordTimestamp: string; + watermark: string; + contentEncoding: typeof ACTIVITY_SYNC_CONTENT_ENCODING; + body: Buffer; + decompressedBytes: number; + compressedBytes: number; +} + +function parseIsoTime(value: string): number { + const time = new Date(value).getTime(); + if (Number.isNaN(time)) throw new Error(`Invalid session analytics timestamp: ${value}`); + return time; +} + +export function getSessionAnalyticsRecordTimestamp(record: SessionAnalyticsRecord): string { + const timestamp = record.recordType === "entry" ? record.timestamp : (record.createdAt ?? record.modifiedAt); + if (!timestamp) { + throw new Error(`Session analytics ${record.recordType} record is missing a timestamp`); + } + parseIsoTime(timestamp); + return timestamp; +} + +export function compareSessionAnalyticsRecords(a: SessionAnalyticsRecord, b: SessionAnalyticsRecord): number { + const aTimestamp = getSessionAnalyticsRecordTimestamp(a); + const bTimestamp = getSessionAnalyticsRecordTimestamp(b); + const byTime = parseIsoTime(aTimestamp) - parseIsoTime(bTimestamp); + if (byTime !== 0) return byTime; + if (a.recordType !== b.recordType) return a.recordType === "session" ? -1 : 1; + const aSessionId = a.sessionId; + const bSessionId = b.sessionId; + const bySession = aSessionId.localeCompare(bSessionId); + if (bySession !== 0) return bySession; + const aEntryId = a.recordType === "entry" ? a.entryId : ""; + const bEntryId = b.recordType === "entry" ? b.entryId : ""; + return aEntryId.localeCompare(bEntryId); +} + +export function sortSessionAnalyticsRecords(records: SessionAnalyticsRecord[]): SessionAnalyticsRecord[] { + return [...records].sort(compareSessionAnalyticsRecords); +} + +export function serializeSessionAnalyticsNdjson(records: SessionAnalyticsRecord[]): Buffer { + if (records.length === 0) throw new Error("Session analytics upload has no records"); + return Buffer.from(`${records.map((record) => JSON.stringify(record)).join("\n")}\n`, "utf8"); +} + +async function compressActivitySyncNdjson( + input: Buffer, + compress: ((input: Buffer) => Promise) | undefined, +): Promise { + if (compress) return compress(input); + if (typeof zstdCompress !== "function") { + throw new Error( + "Activity sync requires Node zstd compression support, but node:zlib.zstdCompress is unavailable", + ); + } + const compressed = await promisify(zstdCompress)(input); + return Buffer.from(compressed); +} + +function getRecordGroups(records: SessionAnalyticsRecord[]): SessionAnalyticsRecord[][] { + const groups: SessionAnalyticsRecord[][] = []; + for (const record of records) { + const timestamp = getSessionAnalyticsRecordTimestamp(record); + const previousGroup = groups.at(-1); + if (previousGroup && getSessionAnalyticsRecordTimestamp(previousGroup[0]) === timestamp) { + previousGroup.push(record); + } else { + groups.push([record]); + } + } + return groups; +} + +function getPayloadWatermark( + records: SessionAnalyticsRecord[], + isOnlyPayload: boolean, + isFinalPayload: boolean, + scanCutoff: string, + serverWatermark: string | null, +): string { + if (isOnlyPayload || isFinalPayload) return scanCutoff; + const maxTimestamp = getSessionAnalyticsRecordTimestamp(records[records.length - 1]); + if (serverWatermark && parseIsoTime(maxTimestamp) <= parseIsoTime(serverWatermark)) return serverWatermark; + return maxTimestamp; +} + +async function createPayload( + records: SessionAnalyticsRecord[], + watermark: string, + maxCompressedBytes: number, + maxDecompressedBytes: number, + compress: ((input: Buffer) => Promise) | undefined, +): Promise { + const ndjson = serializeSessionAnalyticsNdjson(records); + if (ndjson.byteLength > maxDecompressedBytes) { + throw new Error( + `Session analytics payload exceeds decompressed size limit (${ndjson.byteLength} > ${maxDecompressedBytes} bytes)`, + ); + } + const body = await compressActivitySyncNdjson(ndjson, compress); + if (body.byteLength > maxCompressedBytes) { + throw new Error( + `Session analytics payload exceeds compressed size limit (${body.byteLength} > ${maxCompressedBytes} bytes)`, + ); + } + return { + records, + recordCount: records.length, + firstRecordTimestamp: getSessionAnalyticsRecordTimestamp(records[0]), + lastRecordTimestamp: getSessionAnalyticsRecordTimestamp(records[records.length - 1]), + watermark, + contentEncoding: ACTIVITY_SYNC_CONTENT_ENCODING, + body, + decompressedBytes: ndjson.byteLength, + compressedBytes: body.byteLength, + }; +} + +async function payloadFits( + records: SessionAnalyticsRecord[], + maxCompressedBytes: number, + maxDecompressedBytes: number, + compress: ((input: Buffer) => Promise) | undefined, +): Promise { + const ndjson = serializeSessionAnalyticsNdjson(records); + if (ndjson.byteLength > maxDecompressedBytes) return false; + const body = await compressActivitySyncNdjson(ndjson, compress); + return body.byteLength <= maxCompressedBytes; +} + +export async function buildActivitySyncPayloads( + options: BuildActivitySyncPayloadsOptions, +): Promise { + if (options.records.length === 0) throw new Error("Session analytics upload has no records"); + const maxCompressedBytes = options.maxCompressedBytes ?? ACTIVITY_SYNC_MAX_COMPRESSED_BYTES; + const maxDecompressedBytes = options.maxDecompressedBytes ?? ACTIVITY_SYNC_MAX_DECOMPRESSED_BYTES; + const sortedRecords = sortSessionAnalyticsRecords(options.records); + + if (await payloadFits(sortedRecords, maxCompressedBytes, maxDecompressedBytes, options.compress)) { + return [ + await createPayload( + sortedRecords, + options.scanCutoff, + maxCompressedBytes, + maxDecompressedBytes, + options.compress, + ), + ]; + } + + const batches: SessionAnalyticsRecord[][] = []; + let current: SessionAnalyticsRecord[] = []; + for (const group of getRecordGroups(sortedRecords)) { + const candidate = [...current, ...group]; + if ( + current.length > 0 && + !(await payloadFits(candidate, maxCompressedBytes, maxDecompressedBytes, options.compress)) + ) { + batches.push(current); + current = []; + } + const next = [...current, ...group]; + if (!(await payloadFits(next, maxCompressedBytes, maxDecompressedBytes, options.compress))) { + throw new Error("Session analytics records with the same timestamp exceed the upload size limit"); + } + current = next; + } + if (current.length > 0) batches.push(current); + + return Promise.all( + batches.map((batch, index) => + createPayload( + batch, + getPayloadWatermark( + batch, + batches.length === 1, + index === batches.length - 1, + options.scanCutoff, + options.serverWatermark, + ), + maxCompressedBytes, + maxDecompressedBytes, + options.compress, + ), + ), + ); +} diff --git a/packages/coding-agent/src/core/activity-sync/session-analytics-reader.ts b/packages/coding-agent/src/core/activity-sync/session-analytics-reader.ts new file mode 100644 index 000000000..3ddc01cb4 --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/session-analytics-reader.ts @@ -0,0 +1,141 @@ +import { createReadStream } from "node:fs"; +import { createInterface } from "node:readline"; +import type { FileEntry, SessionEntry, SessionHeader } from "../session-manager.ts"; +import { projectSessionForAnalytics, type SessionAnalyticsRecord } from "./session-analytics.ts"; +import { discoverSessions, type SessionDiscoveryProgressCallback } from "./session-discovery.ts"; + +export interface BuildSessionAnalyticsUploadOptions { + /** Server watermark from GET /analytics/activity/:deviceId. */ + serverWatermark: string | null; + /** Root sessions directory. Defaults to ~/.pi/agent/sessions. */ + sessionsRoot?: string; + scanCutoff?: Date; + signal?: AbortSignal; + onDiscoveryProgress?: SessionDiscoveryProgressCallback; +} + +export interface BuildSessionAnalyticsUploadResult { + records: SessionAnalyticsRecord[]; + scanCutoff: string; + filesScanned: number; + malformedFiles: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isSessionHeader(value: unknown): value is SessionHeader { + if (!isRecord(value)) return false; + return ( + value.type === "session" && + typeof value.id === "string" && + value.id.length > 0 && + typeof value.timestamp === "string" && + value.timestamp.length > 0 && + typeof value.cwd === "string" && + (value.version === undefined || typeof value.version === "number") && + (value.parentSession === undefined || typeof value.parentSession === "string") + ); +} + +function isSessionEntry(value: unknown): value is SessionEntry { + if (!isRecord(value)) return false; + return ( + typeof value.type === "string" && + value.type !== "session" && + typeof value.id === "string" && + value.id.length > 0 && + (value.parentId === null || typeof value.parentId === "string") && + typeof value.timestamp === "string" && + value.timestamp.length > 0 + ); +} + +function parseIsoTime(value: string): number | undefined { + const time = new Date(value).getTime(); + return Number.isNaN(time) ? undefined : time; +} + +function getRecordTimestamp(record: SessionAnalyticsRecord): string | undefined { + if (record.recordType === "entry") return record.timestamp; + return record.createdAt ?? record.modifiedAt; +} + +function recordIsBeforeScanCutoff(record: SessionAnalyticsRecord, scanCutoffTime: number): boolean { + const timestamp = getRecordTimestamp(record); + if (!timestamp) return false; + const recordTime = parseIsoTime(timestamp); + return recordTime !== undefined && recordTime < scanCutoffTime; +} + +async function readSessionFile(path: string): Promise<{ header: SessionHeader; entries: SessionEntry[] } | undefined> { + const stream = createReadStream(path, { encoding: "utf8" }); + const lines = createInterface({ input: stream, crlfDelay: Infinity }); + let header: SessionHeader | undefined; + const entries: SessionEntry[] = []; + + try { + for await (const line of lines) { + if (!line.trim()) continue; + let parsed: unknown; + try { + parsed = JSON.parse(line) as FileEntry; + } catch { + return undefined; + } + + if (!header) { + if (!isSessionHeader(parsed)) return undefined; + header = parsed; + continue; + } + + if (!isSessionEntry(parsed)) return undefined; + entries.push(parsed); + } + } finally { + lines.close(); + stream.destroy(); + } + + return header ? { header, entries } : undefined; +} + +export async function buildSessionAnalyticsUpload( + options: BuildSessionAnalyticsUploadOptions, +): Promise { + const scanCutoff = options.scanCutoff ?? new Date(); + const scanCutoffTime = scanCutoff.getTime(); + const serverWatermarkTime = options.serverWatermark ? parseIsoTime(options.serverWatermark) : undefined; + const sessions = await discoverSessions({ + sessionsRoot: options.sessionsRoot, + signal: options.signal, + onProgress: options.onDiscoveryProgress, + }); + const records: SessionAnalyticsRecord[] = []; + let filesScanned = 0; + let malformedFiles = 0; + + for (const session of sessions) { + if (options.signal?.aborted) break; + if (serverWatermarkTime !== undefined && session.modifiedAt.getTime() <= serverWatermarkTime) continue; + filesScanned++; + const parsed = await readSessionFile(session.path).catch(() => undefined); + if (!parsed) { + malformedFiles++; + continue; + } + const projectedRecords = projectSessionForAnalytics(parsed.header, parsed.entries, { + modifiedAt: session.modifiedAt, + }); + records.push(...projectedRecords.filter((record) => recordIsBeforeScanCutoff(record, scanCutoffTime))); + } + + return { + records, + scanCutoff: scanCutoff.toISOString(), + filesScanned, + malformedFiles, + }; +} diff --git a/packages/coding-agent/src/core/activity-sync/session-analytics.ts b/packages/coding-agent/src/core/activity-sync/session-analytics.ts new file mode 100644 index 000000000..7becd84bc --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/session-analytics.ts @@ -0,0 +1,331 @@ +import { createHash } from "crypto"; +import type { SessionEntry, SessionHeader } from "../session-manager.ts"; + +export const SESSION_ANALYTICS_SCHEMA_VERSION = 1; + +export interface SessionAnalyticsUsage { + input: number; + output: number; + cacheRead: number; + cacheWrite: number; + totalTokens: number; + costInput: number; + costOutput: number; + costCacheRead: number; + costCacheWrite: number; + costTotal: number; +} + +export interface SessionAnalyticsContentStats { + stringContent: boolean; + textBlocks: number; + imageBlocks: number; + thinkingBlocks: number; + redactedThinkingBlocks: number; + toolCallBlocks: number; + otherBlocks: number; +} + +export interface SessionAnalyticsSessionRecord { + recordType: "session"; + schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION; + sessionId: string; + version?: number; + createdAt?: string; + modifiedAt?: string; + parentSessionHash?: string; +} + +export interface SessionAnalyticsEntryRecord { + recordType: "entry"; + schemaVersion: typeof SESSION_ANALYTICS_SCHEMA_VERSION; + sessionId: string; + entryId: string; + parentEntryId: string | null; + entryType: string; + timestamp: string; + + // Message-level metadata. Raw content, tool arguments, thinking text, and errors are intentionally omitted. + role?: string; + api?: string; + provider?: string; + model?: string; + responseModel?: string; + stopReason?: string; + hasError?: boolean; + usage?: SessionAnalyticsUsage; + contentStats?: SessionAnalyticsContentStats; + isError?: boolean; + exitCode?: number; + cancelled?: boolean; + truncated?: boolean; + excludeFromContext?: boolean; + + // Non-message entry metadata. Raw summaries, labels, names, custom data, and details are intentionally omitted. + modelId?: string; + thinkingLevel?: string; + activeToolCount?: number; + firstKeptEntryId?: string; + tokensBefore?: number; + fromHook?: boolean; + hasDetails?: boolean; + fromId?: string; + customType?: string; + display?: boolean; + hasData?: boolean; + targetId?: string | null; + hasLabel?: boolean; + hasName?: boolean; +} + +export type SessionAnalyticsRecord = SessionAnalyticsSessionRecord | SessionAnalyticsEntryRecord; + +export interface ProjectSessionHeaderAnalyticsOptions { + modifiedAt?: Date | string; + hashString?: (value: string) => string; +} + +export interface ProjectSessionAnalyticsOptions extends ProjectSessionHeaderAnalyticsOptions {} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function maybeString(value: unknown): string | undefined { + return typeof value === "string" && value.length > 0 ? value : undefined; +} + +function maybeNumber(value: unknown): number | undefined { + return typeof value === "number" && Number.isFinite(value) ? value : undefined; +} + +function asNumber(value: unknown): number { + return maybeNumber(value) ?? 0; +} + +function modifiedAtToString(value: Date | string | undefined): string | undefined { + if (value instanceof Date) return value.toISOString(); + return maybeString(value); +} + +function cleanRecord(record: T): T { + const mutableRecord = record as Record; + for (const key of Object.keys(mutableRecord)) { + if (mutableRecord[key] === undefined) delete mutableRecord[key]; + } + return record; +} + +export function hashSessionAnalyticsString(value: string): string { + return createHash("sha256").update(value).digest("hex"); +} + +export function projectSessionHeaderForAnalytics( + header: SessionHeader, + options: ProjectSessionHeaderAnalyticsOptions = {}, +): SessionAnalyticsSessionRecord { + const hash = options.hashString ?? hashSessionAnalyticsString; + return cleanRecord({ + recordType: "session", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: header.id, + version: header.version, + createdAt: maybeString(header.timestamp), + modifiedAt: modifiedAtToString(options.modifiedAt), + parentSessionHash: header.parentSession ? hash(header.parentSession) : undefined, + }); +} + +function projectUsageForAnalytics(usage: unknown): SessionAnalyticsUsage | undefined { + if (!isRecord(usage)) return undefined; + const cost = isRecord(usage.cost) ? usage.cost : undefined; + return { + input: asNumber(usage.input), + output: asNumber(usage.output), + cacheRead: asNumber(usage.cacheRead), + cacheWrite: asNumber(usage.cacheWrite), + totalTokens: asNumber(usage.totalTokens), + costInput: asNumber(cost?.input), + costOutput: asNumber(cost?.output), + costCacheRead: asNumber(cost?.cacheRead), + costCacheWrite: asNumber(cost?.cacheWrite), + costTotal: asNumber(cost?.total), + }; +} + +function projectContentStatsForAnalytics(content: unknown): SessionAnalyticsContentStats { + const stats: SessionAnalyticsContentStats = { + stringContent: false, + textBlocks: 0, + imageBlocks: 0, + thinkingBlocks: 0, + redactedThinkingBlocks: 0, + toolCallBlocks: 0, + otherBlocks: 0, + }; + + if (typeof content === "string") { + stats.stringContent = true; + if (content.length > 0) stats.textBlocks = 1; + return stats; + } + + if (!Array.isArray(content)) { + stats.otherBlocks = 1; + return stats; + } + + for (const block of content) { + if (!isRecord(block)) { + stats.otherBlocks++; + continue; + } + switch (block.type) { + case "text": + stats.textBlocks++; + break; + case "image": + stats.imageBlocks++; + break; + case "thinking": + stats.thinkingBlocks++; + if (block.redacted === true) stats.redactedThinkingBlocks++; + break; + case "toolCall": + stats.toolCallBlocks++; + break; + default: + stats.otherBlocks++; + break; + } + } + + return stats; +} + +function createEntryBase(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord { + return { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId, + entryId: entry.id, + parentEntryId: entry.parentId, + entryType: entry.type, + timestamp: entry.timestamp, + }; +} + +function projectMessageEntryForAnalytics(sessionId: string, entry: Extract) { + const base = createEntryBase(sessionId, entry); + const message = isRecord(entry.message) ? entry.message : undefined; + const role = maybeString(message?.role) ?? "unknown"; + + if (role === "assistant") { + return cleanRecord({ + ...base, + role, + api: maybeString(message?.api), + provider: maybeString(message?.provider), + model: maybeString(message?.model), + responseModel: maybeString(message?.responseModel), + stopReason: maybeString(message?.stopReason), + hasError: + Boolean(message?.errorMessage) || message?.stopReason === "error" || message?.stopReason === "aborted", + usage: projectUsageForAnalytics(message?.usage), + contentStats: projectContentStatsForAnalytics(message?.content), + }); + } + + if (role === "user") return { ...base, role, contentStats: projectContentStatsForAnalytics(message?.content) }; + + if (role === "toolResult") { + return { + ...base, + role, + isError: message?.isError === true, + contentStats: projectContentStatsForAnalytics(message?.content), + }; + } + + if (role === "bashExecution") { + return cleanRecord({ + ...base, + role, + exitCode: maybeNumber(message?.exitCode), + cancelled: message?.cancelled === true, + truncated: message?.truncated === true, + excludeFromContext: message?.excludeFromContext === true, + }); + } + + if (role === "custom") { + return cleanRecord({ + ...base, + role, + customType: maybeString(message?.customType), + display: typeof message?.display === "boolean" ? message.display : undefined, + contentStats: projectContentStatsForAnalytics(message?.content), + }); + } + + return { ...base, role }; +} + +export function projectSessionEntryForAnalytics(sessionId: string, entry: SessionEntry): SessionAnalyticsEntryRecord { + switch (entry.type) { + case "message": + return projectMessageEntryForAnalytics(sessionId, entry); + case "model_change": + return { ...createEntryBase(sessionId, entry), provider: entry.provider, modelId: entry.modelId }; + case "thinking_level_change": + return { ...createEntryBase(sessionId, entry), thinkingLevel: entry.thinkingLevel }; + case "compaction": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + firstKeptEntryId: entry.firstKeptEntryId, + tokensBefore: entry.tokensBefore, + fromHook: entry.fromHook, + hasDetails: entry.details !== undefined, + }); + case "branch_summary": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + fromId: entry.fromId, + fromHook: entry.fromHook, + hasDetails: entry.details !== undefined, + }); + case "custom": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + customType: entry.customType, + hasData: entry.data !== undefined, + }); + case "custom_message": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + customType: entry.customType, + display: entry.display, + hasDetails: entry.details !== undefined, + contentStats: projectContentStatsForAnalytics(entry.content), + }); + case "label": + return cleanRecord({ + ...createEntryBase(sessionId, entry), + targetId: entry.targetId, + hasLabel: typeof entry.label === "string" && entry.label.length > 0, + }); + case "session_info": + return { ...createEntryBase(sessionId, entry), hasName: Boolean(entry.name?.trim()) }; + default: + return createEntryBase(sessionId, entry); + } +} + +export function projectSessionForAnalytics( + header: SessionHeader, + entries: SessionEntry[], + options: ProjectSessionAnalyticsOptions = {}, +): SessionAnalyticsRecord[] { + const session = projectSessionHeaderForAnalytics(header, options); + return [session, ...entries.map((entry) => projectSessionEntryForAnalytics(session.sessionId, entry))]; +} diff --git a/packages/coding-agent/src/core/activity-sync/session-discovery.ts b/packages/coding-agent/src/core/activity-sync/session-discovery.ts new file mode 100644 index 000000000..093bd206c --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/session-discovery.ts @@ -0,0 +1,185 @@ +import type { Dirent } from "fs"; +import { createReadStream } from "fs"; +import { readdir, stat } from "fs/promises"; +import { basename, dirname, join, relative, resolve } from "path"; +import { createInterface } from "readline"; +import { getSessionsDir } from "../../config.ts"; +import type { SessionHeader } from "../session-manager.ts"; + +export type SessionDiscoveryPhase = "scan" | "read"; + +export interface SessionDiscoveryProgress { + phase: SessionDiscoveryPhase; + foundFiles: number; + processedFiles: number; + sessions: number; + currentFile?: string; +} + +export type SessionDiscoveryProgressCallback = (progress: SessionDiscoveryProgress) => void; + +export interface DiscoverSessionFilesOptions { + /** Root sessions directory. Defaults to ~/.pi/agent/sessions. */ + sessionsRoot?: string; + signal?: AbortSignal; + onProgress?: SessionDiscoveryProgressCallback; +} + +export interface DiscoverSessionsOptions extends DiscoverSessionFilesOptions {} + +export interface DiscoveredSession { + path: string; + relativePath: string; + sessionDir: string; + sessionDirName: string; + header: SessionHeader; + sessionId: string; + cwd: string; + createdAt?: Date; + modifiedAt: Date; + sizeBytes: number; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function parseSessionHeader(value: unknown): SessionHeader | undefined { + if (!isRecord(value)) return undefined; + if (value.type !== "session") return undefined; + if (typeof value.id !== "string" || !value.id) return undefined; + if (typeof value.timestamp !== "string" || !value.timestamp) return undefined; + if (typeof value.cwd !== "string") return undefined; + if (value.version !== undefined && typeof value.version !== "number") return undefined; + if (value.parentSession !== undefined && typeof value.parentSession !== "string") return undefined; + return { + type: "session", + version: value.version, + id: value.id, + timestamp: value.timestamp, + cwd: value.cwd, + parentSession: value.parentSession, + }; +} + +function parseDate(value: string): Date | undefined { + const date = new Date(value); + return Number.isNaN(date.getTime()) ? undefined : date; +} + +function emitProgress( + onProgress: SessionDiscoveryProgressCallback | undefined, + progress: SessionDiscoveryProgress, +): void { + onProgress?.({ ...progress }); +} + +async function walkSessionFiles( + dir: string, + files: string[], + progress: SessionDiscoveryProgress, + signal: AbortSignal | undefined, + onProgress: SessionDiscoveryProgressCallback | undefined, +): Promise { + if (signal?.aborted) return; + let entries: Dirent[]; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + return; + } + + for (const entry of entries) { + if (signal?.aborted) return; + const path = join(dir, entry.name); + if (entry.isDirectory()) { + await walkSessionFiles(path, files, progress, signal, onProgress); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) continue; + files.push(path); + progress.foundFiles = files.length; + emitProgress(onProgress, progress); + } +} + +export async function discoverSessionFiles(options: DiscoverSessionFilesOptions = {}): Promise { + const sessionsRoot = resolve(options.sessionsRoot ?? getSessionsDir()); + const files: string[] = []; + const progress: SessionDiscoveryProgress = { + phase: "scan", + foundFiles: 0, + processedFiles: 0, + sessions: 0, + }; + emitProgress(options.onProgress, progress); + await walkSessionFiles(sessionsRoot, files, progress, options.signal, options.onProgress); + files.sort((a, b) => a.localeCompare(b)); + return files; +} + +async function readSessionHeader(filePath: string): Promise { + const stream = createReadStream(filePath, { encoding: "utf8" }); + const lines = createInterface({ input: stream, crlfDelay: Infinity }); + try { + for await (const line of lines) { + if (!line.trim()) return undefined; + let parsed: unknown; + try { + parsed = JSON.parse(line); + } catch { + return undefined; + } + return parseSessionHeader(parsed); + } + return undefined; + } finally { + lines.close(); + stream.destroy(); + } +} + +async function discoverSessionFromFile(sessionsRoot: string, filePath: string): Promise { + const [fileStats, header] = await Promise.all([stat(filePath), readSessionHeader(filePath)]); + if (!header) return undefined; + const sessionDir = dirname(filePath); + return { + path: filePath, + relativePath: relative(sessionsRoot, filePath), + sessionDir, + sessionDirName: basename(sessionDir), + header, + sessionId: header.id, + cwd: header.cwd, + createdAt: parseDate(header.timestamp), + modifiedAt: fileStats.mtime, + sizeBytes: fileStats.size, + }; +} + +export async function discoverSessions(options: DiscoverSessionsOptions = {}): Promise { + const sessionsRoot = resolve(options.sessionsRoot ?? getSessionsDir()); + const files = await discoverSessionFiles({ sessionsRoot, signal: options.signal, onProgress: options.onProgress }); + const sessions: DiscoveredSession[] = []; + const progress: SessionDiscoveryProgress = { + phase: "read", + foundFiles: files.length, + processedFiles: 0, + sessions: 0, + }; + emitProgress(options.onProgress, progress); + + for (const filePath of files) { + if (options.signal?.aborted) break; + emitProgress(options.onProgress, { ...progress, currentFile: filePath }); + const session = await discoverSessionFromFile(sessionsRoot, filePath).catch(() => undefined); + progress.processedFiles++; + if (session) { + sessions.push(session); + progress.sessions = sessions.length; + } + emitProgress(options.onProgress, { ...progress, currentFile: filePath }); + } + + return sessions; +} diff --git a/packages/coding-agent/src/core/activity-sync/state.ts b/packages/coding-agent/src/core/activity-sync/state.ts new file mode 100644 index 000000000..def46c42f --- /dev/null +++ b/packages/coding-agent/src/core/activity-sync/state.ts @@ -0,0 +1,114 @@ +import { randomUUID } from "node:crypto"; +import { existsSync } from "node:fs"; +import { mkdir, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import lockfile from "proper-lockfile"; +import { getAgentDir } from "../../config.ts"; +import { normalizePath } from "../../utils/paths.ts"; +import type { SettingsManager } from "../settings-manager.ts"; + +export interface ActivitySyncState { + lastAttemptAt?: string; + lastSuccessAt?: string; +} + +export type ActivitySyncLockResult = { status: "acquired"; result: T } | { status: "already_running" }; + +export interface ActivitySyncStatePaths { + agentDir: string; + statePath: string; + lockPath: string; +} + +export function getActivitySyncStatePaths(agentDir: string = getAgentDir()): ActivitySyncStatePaths { + const resolvedAgentDir = normalizePath(agentDir); + return { + agentDir: resolvedAgentDir, + statePath: join(resolvedAgentDir, "activity-sync.json"), + lockPath: join(resolvedAgentDir, "activity-sync.lock"), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null; +} + +function isString(value: unknown): value is string { + return typeof value === "string" && value.length > 0; +} + +function parseActivitySyncState(value: unknown): ActivitySyncState { + if (!isRecord(value)) return {}; + return { + lastAttemptAt: isString(value.lastAttemptAt) ? value.lastAttemptAt : undefined, + lastSuccessAt: isString(value.lastSuccessAt) ? value.lastSuccessAt : undefined, + }; +} + +export async function loadActivitySyncState(agentDir?: string): Promise { + const { statePath } = getActivitySyncStatePaths(agentDir); + try { + return parseActivitySyncState(JSON.parse(await readFile(statePath, "utf8"))); + } catch (error) { + const code = error && typeof error === "object" && "code" in error ? String(error.code) : undefined; + if (code === "ENOENT") return {}; + throw error; + } +} + +export async function saveActivitySyncState(state: ActivitySyncState, agentDir?: string): Promise { + const { statePath } = getActivitySyncStatePaths(agentDir); + await mkdir(dirname(statePath), { recursive: true, mode: 0o700 }); + await writeFile(statePath, JSON.stringify(state, null, 2), { + encoding: "utf8", + mode: 0o600, + }); +} + +export async function updateActivitySyncState( + updater: (state: ActivitySyncState) => ActivitySyncState, + agentDir?: string, +): Promise { + const next = updater(await loadActivitySyncState(agentDir)); + await saveActivitySyncState(next, agentDir); + return next; +} + +function isLockError(error: unknown): boolean { + return typeof error === "object" && error !== null && "code" in error && String(error.code) === "ELOCKED"; +} + +export async function withActivitySyncLock( + fn: () => Promise, + agentDir?: string, +): Promise> { + const { lockPath } = getActivitySyncStatePaths(agentDir); + await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 }); + if (!existsSync(lockPath)) await writeFile(lockPath, "", { mode: 0o600 }); + let release: (() => Promise) | undefined; + try { + release = await lockfile.lock(lockPath, { + stale: 10 * 60 * 1000, + update: 30 * 1000, + retries: 0, + realpath: false, + }); + } catch (error) { + if (isLockError(error)) return { status: "already_running" }; + throw error; + } + + try { + return { status: "acquired", result: await fn() }; + } finally { + if (release) await release(); + } +} + +export function getStableActivitySyncDeviceId(settingsManager: SettingsManager): string { + const existing = settingsManager.getActivitySyncDeviceId(); + if (existing) return existing; + const deviceId = randomUUID(); + settingsManager.setActivitySyncDeviceId(deviceId); + return deviceId; +} diff --git a/packages/coding-agent/src/core/auth-storage.ts b/packages/coding-agent/src/core/auth-storage.ts index 9a0564ec1..705ab0ecd 100644 --- a/packages/coding-agent/src/core/auth-storage.ts +++ b/packages/coding-agent/src/core/auth-storage.ts @@ -40,6 +40,11 @@ export type AuthStatus = { label?: string; }; +export interface RefreshOAuthCredentialOptions { + forceRefresh?: boolean; + shouldRemoveOnError?: (error: unknown) => boolean; +} + type LockResult = { result: T; next?: string; @@ -402,6 +407,52 @@ export class AuthStorage { this.remove(provider); } + /** + * Refresh a stored OAuth credential while holding the backend lock. + * If another process refreshed it first, the fresh credential is returned without calling refresh. + */ + async refreshOAuthCredentialWithLock( + providerId: string, + refresh: (credential: OAuthCredential) => Promise, + options: RefreshOAuthCredentialOptions = {}, + ): Promise { + return this.storage.withLockAsync(async (current) => { + const currentData = this.parseStorageData(current); + this.data = currentData; + this.loadError = null; + + const credential = currentData[providerId]; + if (credential?.type !== "oauth") { + return { result: undefined }; + } + + if (!options.forceRefresh && Date.now() < credential.expires) { + return { result: credential }; + } + + try { + const refreshed = await refresh(credential); + const merged: AuthStorageData = { + ...currentData, + [providerId]: refreshed, + }; + this.data = merged; + this.loadError = null; + return { result: refreshed, next: JSON.stringify(merged, null, 2) }; + } catch (error) { + if (!options.shouldRemoveOnError?.(error)) { + throw error; + } + + const merged: AuthStorageData = { ...currentData }; + delete merged[providerId]; + this.data = merged; + this.loadError = null; + return { result: undefined, next: JSON.stringify(merged, null, 2) }; + } + }); + } + /** * Refresh OAuth token with backend locking to prevent race conditions. * Multiple pi instances may try to refresh simultaneously when tokens expire. diff --git a/packages/coding-agent/src/core/index.ts b/packages/coding-agent/src/core/index.ts index 71c45e9c8..7f9209f12 100644 --- a/packages/coding-agent/src/core/index.ts +++ b/packages/coding-agent/src/core/index.ts @@ -2,6 +2,82 @@ * Core modules shared between all run modes. */ +export { + type ActivitySyncResult, + type ActivitySyncStatus, + type SyncSessionAnalyticsOptions, + syncSessionAnalytics, +} from "./activity-sync/activity-sync.ts"; +export { + ACTIVITY_SYNC_CLIENT_ID, + ACTIVITY_SYNC_SCOPE, + ActivitySyncApiError, + type ActivitySyncApiOptions, + type ActivitySyncDeviceFlowResponse, + type ActivitySyncFetch, + type ActivitySyncTokenResponse, + type ActivitySyncUploadResponse, + type ActivitySyncWatermarkResponse, + DEFAULT_PI_DEV_URL, + getActivitySyncWatermark, + pollActivitySyncDeviceToken, + refreshActivitySyncAccessToken, + startActivitySyncDeviceFlow, + type UploadSessionAnalyticsOptions, + uploadSessionAnalytics, +} from "./activity-sync/api.ts"; +export { + ACTIVITY_SYNC_CONTENT_ENCODING, + ACTIVITY_SYNC_MAX_COMPRESSED_BYTES, + ACTIVITY_SYNC_MAX_DECOMPRESSED_BYTES, + type ActivitySyncPayload, + type BuildActivitySyncPayloadsOptions, + buildActivitySyncPayloads, + compareSessionAnalyticsRecords, + getSessionAnalyticsRecordTimestamp, + serializeSessionAnalyticsNdjson, + sortSessionAnalyticsRecords, +} from "./activity-sync/payload.ts"; +export { + hashSessionAnalyticsString, + type ProjectSessionAnalyticsOptions, + type ProjectSessionHeaderAnalyticsOptions, + projectSessionEntryForAnalytics, + projectSessionForAnalytics, + projectSessionHeaderForAnalytics, + SESSION_ANALYTICS_SCHEMA_VERSION, + type SessionAnalyticsContentStats, + type SessionAnalyticsEntryRecord, + type SessionAnalyticsRecord, + type SessionAnalyticsSessionRecord, + type SessionAnalyticsUsage, +} from "./activity-sync/session-analytics.ts"; +export { + type BuildSessionAnalyticsUploadOptions, + type BuildSessionAnalyticsUploadResult, + buildSessionAnalyticsUpload, +} from "./activity-sync/session-analytics-reader.ts"; +export { + type DiscoveredSession, + type DiscoverSessionFilesOptions, + type DiscoverSessionsOptions, + discoverSessionFiles, + discoverSessions, + type SessionDiscoveryPhase, + type SessionDiscoveryProgress, + type SessionDiscoveryProgressCallback, +} from "./activity-sync/session-discovery.ts"; +export { + type ActivitySyncLockResult, + type ActivitySyncState, + type ActivitySyncStatePaths, + getActivitySyncStatePaths, + getStableActivitySyncDeviceId, + loadActivitySyncState, + saveActivitySyncState, + updateActivitySyncState, + withActivitySyncLock, +} from "./activity-sync/state.ts"; export { AgentSession, type AgentSessionConfig, @@ -25,9 +101,17 @@ export { createAgentSessionFromServices, createAgentSessionServices, } from "./agent-session-services.ts"; -export { type BashExecutorOptions, type BashResult, executeBashWithOperations } from "./bash-executor.ts"; +export { + type BashExecutorOptions, + type BashResult, + executeBashWithOperations, +} from "./bash-executor.ts"; export type { CompactionResult } from "./compaction/index.ts"; -export { createEventBus, type EventBus, type EventBusController } from "./event-bus.ts"; +export { + createEventBus, + type EventBus, + type EventBusController, +} from "./event-bus.ts"; // Extensions system export { type AgentEndEvent, @@ -74,4 +158,72 @@ export { type TurnStartEvent, type WorkingIndicatorOptions, } from "./extensions/index.ts"; +export { + formatPiDevScopes, + getPiDevBaseUrl, + normalizePiDevBaseUrl, + PI_DEV_ACTIVITY_SYNC_SCOPE, + PI_DEV_DEFAULT_BASE_URL, + PI_DEV_OAUTH_CLIENT_ID, + PI_DEV_OAUTH_PROVIDER_ID, + PI_DEV_OFFLINE_ACCESS_SCOPE, + PI_DEV_PROFILE_CONNECTED_STATUS, + PI_DEV_PROFILE_SCOPES, + PI_DEV_SESSION_SHARE_SCOPE, + PI_DEV_SETUP_PROFILE_CONNECTED_STATUS, + scopesFromString, + withPiDevOfflineAccess, +} from "./pi-dev/config.ts"; +export { + createFormBody, + getPiDevApiUrl, + getPiDevFetch, + isRecord, + numberField, + PiDevApiError, + type PiDevApiErrorCtor, + type PiDevApiOptions, + type PiDevFetch, + readJson, + readJsonObject, + requireNumber, + requireString, + stringField, + throwIfPiDevNotOk, +} from "./pi-dev/http.ts"; +export { + getPiDevAuth, + hasPiDevScopes, + introspectPiDevAccessToken, + loginPiDev, + type PiDevAccessIntrospectionResult, + type PiDevAuthOptions, + type PiDevAuthResult, + type PiDevDeviceCodeInfo, + type PiDevDeviceFlowOptions, + type PiDevDeviceFlowResponse, + type PiDevDeviceTokenOptions, + type PiDevLoginOptions, + type PiDevRefreshTokenOptions, + type PiDevTokenResponse, + pollPiDevDeviceToken, + refreshPiDevAccessToken, + startPiDevDeviceFlow, +} from "./pi-dev/oauth.ts"; +export { + formatPiDevShareSuccess, + formatPiDevShareUploadError, + getPiDevShareAuth, + loginPiDevShare, + type PiDevShareAuthResult, + type PiDevShareDeviceAuthInfo, + type PiDevShareDeviceAuthOptions, + type PiDevShareUploadOptions, + type PiDevShareUploadResult, + parseShareCommand, + type ShareCommandMode, + type ShareCommandParseResult, + uploadPiDevSessionShare, +} from "./pi-dev/session-share.ts"; + export { createSyntheticSourceInfo } from "./source-info.ts"; diff --git a/packages/coding-agent/src/core/pi-dev/config.ts b/packages/coding-agent/src/core/pi-dev/config.ts new file mode 100644 index 000000000..8e315ee28 --- /dev/null +++ b/packages/coding-agent/src/core/pi-dev/config.ts @@ -0,0 +1,29 @@ +export const PI_DEV_DEFAULT_BASE_URL = "https://pi.dev"; +export const PI_DEV_OAUTH_CLIENT_ID = "pi-coding-agent"; +export const PI_DEV_OAUTH_PROVIDER_ID = "pi.dev"; +export const PI_DEV_OFFLINE_ACCESS_SCOPE = "offline_access"; +export const PI_DEV_SESSION_SHARE_SCOPE = "session_share"; +export const PI_DEV_ACTIVITY_SYNC_SCOPE = "activity_sync"; +export const PI_DEV_PROFILE_SCOPES = [PI_DEV_ACTIVITY_SYNC_SCOPE, PI_DEV_SESSION_SHARE_SCOPE] as const; +export const PI_DEV_PROFILE_CONNECTED_STATUS = `pi.dev profile connected with activity sync enabled. It can be disabled in /settings`; +export const PI_DEV_SETUP_PROFILE_CONNECTED_STATUS = `Setup complete. ${PI_DEV_PROFILE_CONNECTED_STATUS}`; + +export function normalizePiDevBaseUrl(baseUrl: string): string { + return baseUrl.replace(/\/+$/, ""); +} + +export function getPiDevBaseUrl(): string { + return normalizePiDevBaseUrl(process.env.PI_DEV_URL ?? PI_DEV_DEFAULT_BASE_URL); +} + +export function formatPiDevScopes(scopes: readonly string[]): string { + return Array.from(new Set(scopes)).join(" "); +} + +export function scopesFromString(scope: string | undefined): string[] { + return scope?.split(/\s+/).filter((part) => part.length > 0) ?? []; +} + +export function withPiDevOfflineAccess(scopes: readonly string[]): string[] { + return Array.from(new Set([...scopes, PI_DEV_OFFLINE_ACCESS_SCOPE])); +} diff --git a/packages/coding-agent/src/core/pi-dev/http.ts b/packages/coding-agent/src/core/pi-dev/http.ts new file mode 100644 index 000000000..51080a8d3 --- /dev/null +++ b/packages/coding-agent/src/core/pi-dev/http.ts @@ -0,0 +1,106 @@ +import { getPiDevBaseUrl } from "./config.ts"; + +export type PiDevFetch = typeof fetch; + +export interface PiDevApiOptions { + fetch?: PiDevFetch; +} + +export class PiDevApiError extends Error { + status: number; + errorCode?: string; + description?: string; + operation?: string; + + constructor(status: number, errorCode: string | undefined, description: string | undefined, operation?: string) { + const detail = description ? `${errorCode ?? "pi_dev_error"}: ${description}` : (errorCode ?? `HTTP ${status}`); + super(operation ? `${operation} failed: ${detail}` : detail); + this.name = "PiDevApiError"; + this.status = status; + this.errorCode = errorCode; + this.description = description; + this.operation = operation; + } +} + +export type PiDevApiErrorCtor = new ( + status: number, + errorCode: string | undefined, + description: string | undefined, + operation?: string, +) => PiDevApiError; + +export function getPiDevFetch(fetchImpl: PiDevFetch | undefined): PiDevFetch { + return fetchImpl ?? fetch; +} + +export function getPiDevApiUrl(path: string): string { + return `${getPiDevBaseUrl()}${path.startsWith("/") ? path : `/${path}`}`; +} + +export function createFormBody(fields: Record): URLSearchParams { + const body = new URLSearchParams(); + for (const [key, value] of Object.entries(fields)) { + body.set(key, value); + } + return body; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function stringField(value: Record | undefined, key: string): string | undefined { + const field = value?.[key]; + return typeof field === "string" ? field : undefined; +} + +export function numberField(value: Record | undefined, key: string): number | undefined { + const field = value?.[key]; + return typeof field === "number" && Number.isFinite(field) ? field : undefined; +} + +export function requireString(record: Record, key: string, context: string): string { + const value = record[key]; + if (typeof value !== "string" || value.length === 0) throw new Error(`Invalid ${context}: missing ${key}`); + return value; +} + +export function requireNumber(record: Record, key: string, context: string): number { + const value = record[key]; + if (typeof value !== "number" || !Number.isFinite(value)) throw new Error(`Invalid ${context}: missing ${key}`); + return value; +} + +export async function readJson(response: Response): Promise { + const text = await response.text(); + if (!text) return undefined; + try { + return JSON.parse(text) as unknown; + } catch { + return undefined; + } +} + +export async function readJsonObject(response: Response): Promise | undefined> { + const json = await readJson(response); + return isRecord(json) ? json : undefined; +} + +export async function throwIfPiDevNotOk( + response: Response, + operation: string, + ErrorClass: PiDevApiErrorCtor = PiDevApiError, +): Promise { + if (response.ok) return; + const json = await readJson(response); + const errorCode = isRecord(json) && typeof json.error === "string" ? json.error : undefined; + const description = isRecord(json) + ? typeof json.description === "string" + ? json.description + : typeof json.error_description === "string" + ? json.error_description + : undefined + : undefined; + throw new ErrorClass(response.status, errorCode, description, operation); +} diff --git a/packages/coding-agent/src/core/pi-dev/index.ts b/packages/coding-agent/src/core/pi-dev/index.ts new file mode 100644 index 000000000..8ec62482f --- /dev/null +++ b/packages/coding-agent/src/core/pi-dev/index.ts @@ -0,0 +1,67 @@ +export { + formatPiDevScopes, + getPiDevBaseUrl, + normalizePiDevBaseUrl, + PI_DEV_ACTIVITY_SYNC_SCOPE, + PI_DEV_DEFAULT_BASE_URL, + PI_DEV_OAUTH_CLIENT_ID, + PI_DEV_OAUTH_PROVIDER_ID, + PI_DEV_OFFLINE_ACCESS_SCOPE, + PI_DEV_PROFILE_CONNECTED_STATUS, + PI_DEV_PROFILE_SCOPES, + PI_DEV_SESSION_SHARE_SCOPE, + PI_DEV_SETUP_PROFILE_CONNECTED_STATUS, + scopesFromString, + withPiDevOfflineAccess, +} from "./config.ts"; +export { + createFormBody, + getPiDevApiUrl, + getPiDevFetch, + isRecord, + numberField, + PiDevApiError, + type PiDevApiErrorCtor, + type PiDevApiOptions, + type PiDevFetch, + readJson, + readJsonObject, + requireNumber, + requireString, + stringField, + throwIfPiDevNotOk, +} from "./http.ts"; +export { + getPiDevAuth, + hasPiDevScopes, + introspectPiDevAccessToken, + loginPiDev, + type PiDevAccessIntrospectionResult, + type PiDevAuthOptions, + type PiDevAuthResult, + type PiDevDeviceCodeInfo, + type PiDevDeviceFlowOptions, + type PiDevDeviceFlowResponse, + type PiDevDeviceTokenOptions, + type PiDevLoginOptions, + type PiDevRefreshTokenOptions, + type PiDevTokenResponse, + pollPiDevDeviceToken, + refreshPiDevAccessToken, + startPiDevDeviceFlow, +} from "./oauth.ts"; +export { + formatPiDevShareSuccess, + formatPiDevShareUploadError, + getPiDevShareAuth, + loginPiDevShare, + type PiDevShareAuthResult, + type PiDevShareDeviceAuthInfo, + type PiDevShareDeviceAuthOptions, + type PiDevShareUploadOptions, + type PiDevShareUploadResult, + parseShareCommand, + type ShareCommandMode, + type ShareCommandParseResult, + uploadPiDevSessionShare, +} from "./session-share.ts"; diff --git a/packages/coding-agent/src/core/pi-dev/oauth.ts b/packages/coding-agent/src/core/pi-dev/oauth.ts new file mode 100644 index 000000000..dd4751c5e --- /dev/null +++ b/packages/coding-agent/src/core/pi-dev/oauth.ts @@ -0,0 +1,325 @@ +import { type OAuthDeviceCodeInfo, pollOAuthDeviceCodeFlow } from "@earendil-works/pi-ai/oauth"; +import type { AuthStorage, OAuthCredential } from "../auth-storage.ts"; +import { + formatPiDevScopes, + PI_DEV_OAUTH_CLIENT_ID, + PI_DEV_OAUTH_PROVIDER_ID, + PI_DEV_SESSION_SHARE_SCOPE, + scopesFromString, + withPiDevOfflineAccess, +} from "./config.ts"; +import { + createFormBody, + getPiDevApiUrl, + getPiDevFetch, + PiDevApiError, + type PiDevApiErrorCtor, + type PiDevApiOptions, + readJson, + readJsonObject, + requireNumber, + requireString, + stringField, + throwIfPiDevNotOk, +} from "./http.ts"; + +const PI_DEV_DEVICE_CODE_GRANT = "urn:ietf:params:oauth:grant-type:device_code"; + +export interface PiDevDeviceFlowResponse { + device_code: string; + user_code: string; + verification_uri: string; + verification_uri_complete: string; + expires_in: number; + interval: number; +} + +export interface PiDevTokenResponse { + token_type: "Bearer"; + access_token: string; + refresh_token: string; + expires_in: number; + scope: string; +} + +export interface PiDevDeviceFlowOptions extends PiDevApiOptions { + scopes: readonly string[]; + deviceId?: string; + signal?: AbortSignal; + errorClass?: PiDevApiErrorCtor; +} + +export interface PiDevDeviceTokenOptions extends PiDevApiOptions { + signal?: AbortSignal; + errorClass?: PiDevApiErrorCtor; +} + +export interface PiDevRefreshTokenOptions extends PiDevApiOptions { + errorClass?: PiDevApiErrorCtor; +} + +export interface PiDevAccessIntrospectionResult { + active: boolean; + scope?: string; + sessionShareAccess?: boolean; +} + +export interface PiDevAuthOptions extends PiDevApiOptions { + forceRefresh?: boolean; +} + +export type PiDevAuthResult = + | { available: true; accessToken: string } + | { + available: false; + reason: "unauthenticated" | "invalid_token" | "missing_scope"; + }; + +export interface PiDevDeviceCodeInfo extends OAuthDeviceCodeInfo { + displayVerificationUri?: string; +} + +export interface PiDevLoginOptions extends PiDevApiOptions { + scopes: readonly string[]; + deviceId?: string; + signal?: AbortSignal; + onDeviceCode?: (info: PiDevDeviceCodeInfo) => void; +} + +function parseDeviceFlowResponse(json: unknown): PiDevDeviceFlowResponse { + if (!json || typeof json !== "object" || Array.isArray(json)) throw new Error("Invalid pi.dev device flow response"); + const record = json as Record; + return { + device_code: requireString(record, "device_code", "pi.dev device flow response"), + user_code: requireString(record, "user_code", "pi.dev device flow response"), + verification_uri: requireString(record, "verification_uri", "pi.dev device flow response"), + verification_uri_complete: requireString(record, "verification_uri_complete", "pi.dev device flow response"), + expires_in: requireNumber(record, "expires_in", "pi.dev device flow response"), + interval: requireNumber(record, "interval", "pi.dev device flow response"), + }; +} + +function parseTokenResponse(json: unknown): PiDevTokenResponse { + if (!json || typeof json !== "object" || Array.isArray(json)) throw new Error("Invalid pi.dev token response"); + const record = json as Record; + const tokenType = stringField(record, "token_type") ?? "Bearer"; + if (tokenType !== "Bearer") throw new Error(`Invalid pi.dev token type: ${tokenType}`); + return { + token_type: "Bearer", + access_token: requireString(record, "access_token", "pi.dev token response"), + refresh_token: requireString(record, "refresh_token", "pi.dev token response"), + expires_in: requireNumber(record, "expires_in", "pi.dev token response"), + scope: requireString(record, "scope", "pi.dev token response"), + }; +} + +function credentialFromTokenResponse(response: PiDevTokenResponse): OAuthCredential { + return { + type: "oauth", + access: response.access_token, + refresh: response.refresh_token, + expires: Date.now() + response.expires_in * 1000, + scope: response.scope, + }; +} + +function credentialScope(credential: OAuthCredential): string | undefined { + const scope = credential.scope; + return typeof scope === "string" ? scope : undefined; +} + +export function hasPiDevScopes(scope: string | undefined, requiredScopes: readonly string[]): boolean { + const availableScopes = new Set(scopesFromString(scope)); + return requiredScopes.every((requiredScope) => availableScopes.has(requiredScope)); +} + +function getMergedLoginScopes(authStorage: AuthStorage, requiredScopes: readonly string[]): string[] { + const credential = authStorage.get(PI_DEV_OAUTH_PROVIDER_ID); + const existingScopes = credential?.type === "oauth" ? scopesFromString(credentialScope(credential)) : []; + return withPiDevOfflineAccess([...existingScopes, ...requiredScopes]); +} + +export async function startPiDevDeviceFlow(options: PiDevDeviceFlowOptions): Promise { + const fields: Record = { + client_id: PI_DEV_OAUTH_CLIENT_ID, + scope: formatPiDevScopes(withPiDevOfflineAccess(options.scopes)), + }; + if (options.deviceId) fields.device_id = options.deviceId; + const response = await getPiDevFetch(options.fetch)(getPiDevApiUrl("/api/oauth/device"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: createFormBody(fields), + signal: options.signal, + }); + await throwIfPiDevNotOk(response, "POST /api/oauth/device", options.errorClass); + return parseDeviceFlowResponse(await readJson(response)); +} + +export async function pollPiDevDeviceToken( + deviceCode: string, + options: PiDevDeviceTokenOptions = {}, +): Promise { + const response = await getPiDevFetch(options.fetch)(getPiDevApiUrl("/api/oauth/token"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: createFormBody({ + grant_type: PI_DEV_DEVICE_CODE_GRANT, + client_id: PI_DEV_OAUTH_CLIENT_ID, + device_code: deviceCode, + }), + signal: options.signal, + }); + await throwIfPiDevNotOk(response, "POST /api/oauth/token device_code", options.errorClass); + return parseTokenResponse(await readJson(response)); +} + +export async function refreshPiDevAccessToken( + refreshToken: string, + options: PiDevRefreshTokenOptions = {}, +): Promise { + const response = await getPiDevFetch(options.fetch)(getPiDevApiUrl("/api/oauth/token"), { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: createFormBody({ + grant_type: "refresh_token", + client_id: PI_DEV_OAUTH_CLIENT_ID, + refresh_token: refreshToken, + }), + }); + await throwIfPiDevNotOk(response, "POST /api/oauth/token refresh_token", options.errorClass); + return parseTokenResponse(await readJson(response)); +} + +export async function introspectPiDevAccessToken( + accessToken: string, + options: PiDevApiOptions = {}, +): Promise { + const response = await getPiDevFetch(options.fetch)(getPiDevApiUrl("/api/oauth/introspect"), { + method: "POST", + headers: { Authorization: `Bearer ${accessToken}` }, + }); + const data = await readJsonObject(response); + if (!response.ok || data?.active !== true) { + return { active: false }; + } + return { + active: true, + scope: stringField(data, "scope"), + sessionShareAccess: data.session_share_access === true, + }; +} + +async function refreshPiDevCredential(credential: OAuthCredential, options: PiDevApiOptions): Promise { + return credentialFromTokenResponse(await refreshPiDevAccessToken(credential.refresh, options)); +} + +function shouldRemoveRejectedPiDevCredential(error: unknown): boolean { + return error instanceof PiDevApiError && error.errorCode === "invalid_grant"; +} + +function introspectionSatisfiesScopes( + introspection: PiDevAccessIntrospectionResult, + requiredScopes: readonly string[], +): boolean { + if (!introspection.active) return false; + if (hasPiDevScopes(introspection.scope, requiredScopes)) return true; + return ( + requiredScopes.length === 1 && + requiredScopes[0] === PI_DEV_SESSION_SHARE_SCOPE && + introspection.sessionShareAccess === true + ); +} + +export async function getPiDevAuth( + authStorage: AuthStorage, + requiredScopes: readonly string[], + options: PiDevAuthOptions = {}, +): Promise { + let credential = authStorage.get(PI_DEV_OAUTH_PROVIDER_ID); + if (credential?.type !== "oauth") { + return { available: false, reason: "unauthenticated" }; + } + + if (options.forceRefresh || Date.now() >= credential.expires) { + try { + const refreshedCredential = await authStorage.refreshOAuthCredentialWithLock( + PI_DEV_OAUTH_PROVIDER_ID, + (currentCredential) => refreshPiDevCredential(currentCredential, options), + { + forceRefresh: options.forceRefresh, + shouldRemoveOnError: shouldRemoveRejectedPiDevCredential, + }, + ); + if (!refreshedCredential) { + return { available: false, reason: "invalid_token" }; + } + credential = refreshedCredential; + } catch { + return { available: false, reason: "invalid_token" }; + } + } + + const scope = credentialScope(credential); + if (scope) { + return hasPiDevScopes(scope, requiredScopes) + ? { available: true, accessToken: credential.access } + : { available: false, reason: "missing_scope" }; + } + + try { + const introspection = await introspectPiDevAccessToken(credential.access, options); + if (!introspection.active) return { available: false, reason: "invalid_token" }; + return introspectionSatisfiesScopes(introspection, requiredScopes) + ? { available: true, accessToken: credential.access } + : { available: false, reason: "missing_scope" }; + } catch { + return { available: false, reason: "invalid_token" }; + } +} + +export async function loginPiDev(authStorage: AuthStorage, options: PiDevLoginOptions): Promise { + const scopes = getMergedLoginScopes(authStorage, options.scopes); + const started = await startPiDevDeviceFlow({ + fetch: options.fetch, + signal: options.signal, + scopes, + deviceId: options.deviceId, + }); + options.onDeviceCode?.({ + userCode: started.user_code, + verificationUri: started.verification_uri_complete || started.verification_uri, + displayVerificationUri: started.verification_uri, + intervalSeconds: started.interval, + expiresInSeconds: started.expires_in, + }); + const token = await pollOAuthDeviceCodeFlow({ + intervalSeconds: started.interval, + expiresInSeconds: started.expires_in, + signal: options.signal, + poll: async () => { + try { + return { + status: "complete", + value: await pollPiDevDeviceToken(started.device_code, { + fetch: options.fetch, + signal: options.signal, + }), + }; + } catch (error) { + if (error instanceof PiDevApiError && error.errorCode === "authorization_pending") { + return { status: "pending" }; + } + if (error instanceof PiDevApiError && error.errorCode === "slow_down") { + return { status: "slow_down" }; + } + return { + status: "failed", + message: error instanceof Error ? error.message : String(error), + }; + } + }, + }); + const credential = credentialFromTokenResponse(token); + authStorage.set(PI_DEV_OAUTH_PROVIDER_ID, credential); + return credential; +} diff --git a/packages/coding-agent/src/core/pi-dev/session-share.ts b/packages/coding-agent/src/core/pi-dev/session-share.ts new file mode 100644 index 000000000..df903a436 --- /dev/null +++ b/packages/coding-agent/src/core/pi-dev/session-share.ts @@ -0,0 +1,124 @@ +import type { AuthStorage, OAuthCredential } from "../auth-storage.ts"; +import { PI_DEV_SESSION_SHARE_SCOPE } from "./config.ts"; +import { getPiDevApiUrl, getPiDevFetch, numberField, type PiDevFetch, readJsonObject, stringField } from "./http.ts"; +import { getPiDevAuth, loginPiDev, type PiDevAuthResult, type PiDevLoginOptions } from "./oauth.ts"; + +export type ShareCommandMode = "auto" | "pi.dev" | "github"; + +export type ShareCommandParseResult = { ok: true; mode: ShareCommandMode } | { ok: false; message: string }; + +export type PiDevShareAuthResult = PiDevAuthResult; + +export interface PiDevShareUploadResult { + id: string; + url: string; +} + +export interface PiDevShareDeviceAuthInfo { + userCode: string; + verificationUri: string; + displayVerificationUri?: string; + intervalSeconds?: number; + expiresInSeconds?: number; +} + +export interface PiDevShareDeviceAuthOptions { + signal?: AbortSignal; + onDeviceCode?: (info: PiDevShareDeviceAuthInfo) => void; +} + +export interface PiDevShareUploadOptions { + accessToken: string; + bytes: Uint8Array; + byteSize: number; + signal?: AbortSignal; + fetchFn?: PiDevFetch; +} + +export function parseShareCommand(text: string): ShareCommandParseResult { + if (text === "/share") { + return { ok: true, mode: "auto" }; + } + if (!text.startsWith("/share ")) { + return { ok: false, message: "Usage: /share [pi.dev|github]" }; + } + + const args = text.slice("/share".length).trim().split(/\s+/).filter(Boolean); + if (args.length === 0) { + return { ok: true, mode: "auto" }; + } + if (args.length === 1 && (args[0] === "pi.dev" || args[0] === "github")) { + return { ok: true, mode: args[0] }; + } + return { ok: false, message: "Usage: /share [pi.dev|github]" }; +} + +export async function loginPiDevShare( + authStorage: AuthStorage, + options: PiDevShareDeviceAuthOptions = {}, +): Promise { + const loginOptions: PiDevLoginOptions = { + scopes: [PI_DEV_SESSION_SHARE_SCOPE], + signal: options.signal, + onDeviceCode: options.onDeviceCode, + }; + return loginPiDev(authStorage, loginOptions); +} + +export async function getPiDevShareAuth(authStorage: AuthStorage): Promise { + return getPiDevAuth(authStorage, [PI_DEV_SESSION_SHARE_SCOPE]); +} + +export async function uploadPiDevSessionShare(options: PiDevShareUploadOptions): Promise { + const response = await getPiDevFetch(options.fetchFn)(getPiDevApiUrl("/api/session-shares"), { + method: "POST", + headers: { + Authorization: `Bearer ${options.accessToken}`, + "Content-Type": "text/html; charset=utf-8", + "Content-Length": String(options.byteSize), + }, + body: options.bytes, + signal: options.signal, + }); + const data = await readJsonObject(response); + if (response.status !== 201) { + throw new Error(formatPiDevShareUploadError(response, data)); + } + + const id = stringField(data, "id"); + const url = stringField(data, "url"); + if (!id || !url) { + throw new Error("pi.dev returned an invalid share response."); + } + return { id, url }; +} + +export function formatPiDevShareSuccess(url: string): string { + return `Share URL: ${url}\nStored on pi.dev as an unlisted session share.\nAnyone with this link can view it.`; +} + +export function formatPiDevShareUploadError(response: Response, data: Record | undefined): string { + if (response.status === 401) { + return "authentication failed (token is invalid or expired)"; + } + if (response.status === 403) { + return "your pi.dev login does not include session sharing permission"; + } + if (response.status === 411) { + return "pi.dev requires Content-Length for session uploads"; + } + if (response.status === 413) { + const maxBytes = numberField(data, "max_bytes"); + return maxBytes === undefined + ? "session export is too large" + : `session export is too large (max ${maxBytes} bytes)`; + } + if (response.status === 415) { + return "pi.dev rejected the upload content type"; + } + return ( + stringField(data, "error_description") || + stringField(data, "error") || + `HTTP ${response.status} ${response.statusText}`.trim() + ); +} diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 2ef32d6d6..326f60ffe 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -57,6 +57,20 @@ export interface WarningSettings { anthropicExtraUsage?: boolean; // default: true } +export interface PiDevActivitySyncSettings { + deviceId?: string; + enabled?: boolean; + intervalHours?: number; +} + +export interface PiDevSettings { + activitySync?: PiDevActivitySyncSettings; +} + +export interface TelemetrySettings { + enabled?: boolean; // default: true +} + export type TransportSetting = Transport; /** @@ -110,42 +124,37 @@ export interface Settings { showHardwareCursor?: boolean; // Show terminal cursor while still positioning it for IME markdown?: MarkdownSettings; warnings?: WarningSettings; + piDev?: PiDevSettings; sessionDir?: string; // Custom session storage directory (same format as --session-dir CLI flag) httpIdleTimeoutMs?: number; // HTTP header/body idle timeout in milliseconds; 0 disables it websocketConnectTimeoutMs?: number; // WebSocket connect/open handshake timeout in milliseconds; 0 disables it } /** Deep merge settings: project/overrides take precedence, nested objects merge recursively */ -function deepMergeSettings(base: Settings, overrides: Settings): Settings { - const result: Settings = { ...base }; +function isPlainSettingsObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} - for (const key of Object.keys(overrides) as (keyof Settings)[]) { - const overrideValue = overrides[key]; - const baseValue = base[key]; +function deepMergeSettingsValue(base: unknown, overrides: unknown): unknown { + if (!isPlainSettingsObject(base) || !isPlainSettingsObject(overrides)) { + return overrides; + } + const result: Record = { ...base }; + for (const [key, overrideValue] of Object.entries(overrides)) { if (overrideValue === undefined) { continue; } - - // For nested objects, merge recursively - if ( - typeof overrideValue === "object" && - overrideValue !== null && - !Array.isArray(overrideValue) && - typeof baseValue === "object" && - baseValue !== null && - !Array.isArray(baseValue) - ) { - (result as Record)[key] = { ...baseValue, ...overrideValue }; - } else { - // For primitives and arrays, override value wins - (result as Record)[key] = overrideValue; - } + const baseValue = result[key]; + result[key] = deepMergeSettingsValue(baseValue, overrideValue); } - return result; } +function deepMergeSettings(base: Settings, overrides: Settings): Settings { + return deepMergeSettingsValue(base, overrides) as Settings; +} + function parseTimeoutSetting(value: unknown, settingName: string): number | undefined { const timeoutMs = parseHttpIdleTimeoutMs(value); if (timeoutMs !== undefined) { @@ -1148,4 +1157,46 @@ export class SettingsManager { this.markModified("warnings"); this.save(); } + + private ensureGlobalPiDevActivitySyncSettings(): PiDevActivitySyncSettings { + if (!this.globalSettings.piDev) { + this.globalSettings.piDev = {}; + } + if (!this.globalSettings.piDev.activitySync) { + this.globalSettings.piDev.activitySync = {}; + } + return this.globalSettings.piDev.activitySync; + } + + getActivitySyncDeviceId(): string | undefined { + const deviceId = this.globalSettings.piDev?.activitySync?.deviceId; + return deviceId && /^[0-9a-fA-F-]{36}$/.test(deviceId) ? deviceId : undefined; + } + + setActivitySyncDeviceId(deviceId: string): void { + const activitySync = this.ensureGlobalPiDevActivitySyncSettings(); + activitySync.deviceId = deviceId; + this.markModified("piDev", "activitySync"); + this.save(); + } + + getActivitySyncSettings(): { enabled: boolean; intervalHours: number } { + const activitySync = this.globalSettings.piDev?.activitySync; + const intervalHours = activitySync?.intervalHours; + return { + enabled: activitySync?.enabled ?? false, + intervalHours: + typeof intervalHours === "number" && Number.isFinite(intervalHours) ? Math.max(1, intervalHours) : 24, + }; + } + + setActivitySyncEnabled(enabled: boolean): void { + const activitySync = this.ensureGlobalPiDevActivitySyncSettings(); + activitySync.enabled = enabled; + if (enabled) { + activitySync.intervalHours = 24; + } + this.markModified("piDev", "activitySync"); + this.save(); + } } diff --git a/packages/coding-agent/src/core/setup-state.ts b/packages/coding-agent/src/core/setup-state.ts new file mode 100644 index 000000000..fa81e1e85 --- /dev/null +++ b/packages/coding-agent/src/core/setup-state.ts @@ -0,0 +1,184 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { getAgentDir } from "../config.ts"; + +export const SETUP_STEPS = [ + { id: "theme", introducedIn: 1 }, + { id: "pi-dev-profile", introducedIn: 1 }, +] as const; + +export const CURRENT_SETUP_VERSION = SETUP_STEPS.reduce( + (maxVersion, step) => Math.max(maxVersion, step.introducedIn), + 0, +); + +export type SetupStepId = (typeof SETUP_STEPS)[number]["id"]; + +export interface SetupStepState { + completedAt: string; + setupVersion: number; +} + +export interface SetupState { + schemaVersion: 1; + completedVersion: number; + completedAt?: string; + steps: Record; +} + +export interface PendingSetupStepOptions { + themeConfigured?: boolean; +} + +function createEmptySetupState(): SetupState { + return { + schemaVersion: 1, + completedVersion: 0, + steps: {}, + }; +} + +function isSetupStepState(value: unknown): value is SetupStepState { + return ( + typeof value === "object" && + value !== null && + "completedAt" in value && + typeof value.completedAt === "string" && + "setupVersion" in value && + typeof value.setupVersion === "number" && + Number.isFinite(value.setupVersion) + ); +} + +function normalizeSetupState(value: unknown): SetupState | undefined { + if (typeof value !== "object" || value === null) { + return undefined; + } + + const record = value as Record; + const rawSteps = record.steps; + const steps: Record = {}; + if (typeof rawSteps === "object" && rawSteps !== null) { + for (const [stepId, stepState] of Object.entries(rawSteps)) { + if (isSetupStepState(stepState)) { + steps[stepId] = { + completedAt: stepState.completedAt, + setupVersion: Math.floor(stepState.setupVersion), + }; + } + } + } + + const completedVersion = + typeof record.completedVersion === "number" && Number.isFinite(record.completedVersion) + ? Math.max(0, Math.floor(record.completedVersion)) + : computeCompletedVersion(steps); + + const state: SetupState = { + schemaVersion: 1, + completedVersion, + steps, + }; + if (typeof record.completedAt === "string") { + state.completedAt = record.completedAt; + } + return state; +} + +function computeCompletedVersion(steps: Record): number { + const versions = Array.from(new Set(SETUP_STEPS.map((step) => step.introducedIn))).sort((a, b) => a - b); + let completedVersion = 0; + for (const version of versions) { + const completeThroughVersion = SETUP_STEPS.every( + (step) => step.introducedIn > version || steps[step.id] !== undefined, + ); + if (!completeThroughVersion) { + break; + } + completedVersion = version; + } + return completedVersion; +} + +function finalizeSetupState(state: SetupState): SetupState { + const completedVersion = computeCompletedVersion(state.steps); + const next: SetupState = { + ...state, + schemaVersion: 1, + completedVersion, + steps: { ...state.steps }, + }; + if (SETUP_STEPS.every((step) => next.steps[step.id] !== undefined)) { + next.completedAt = SETUP_STEPS.reduce((latest, step) => { + const completedAt = next.steps[step.id]?.completedAt; + if (!completedAt) { + return latest; + } + return latest === undefined || completedAt > latest ? completedAt : latest; + }, next.completedAt); + } else { + delete next.completedAt; + } + return next; +} + +export function getSetupStatePath(agentDir: string = getAgentDir()): string { + return join(agentDir, "setup.json"); +} + +export function readSetupState(agentDir: string = getAgentDir()): SetupState | undefined { + const setupPath = getSetupStatePath(agentDir); + if (!existsSync(setupPath)) { + return undefined; + } + try { + return normalizeSetupState(JSON.parse(readFileSync(setupPath, "utf-8"))); + } catch { + return undefined; + } +} + +export function writeSetupState(state: SetupState, agentDir: string = getAgentDir()): void { + const setupPath = getSetupStatePath(agentDir); + mkdirSync(dirname(setupPath), { recursive: true }); + writeFileSync(setupPath, `${JSON.stringify(finalizeSetupState(state), null, 2)}\n`, "utf-8"); +} + +export function getAllSetupStepIds(): SetupStepId[] { + return SETUP_STEPS.map((step) => step.id); +} + +function setupStepIsSatisfiedBySettings(stepId: SetupStepId, options: PendingSetupStepOptions): boolean { + return stepId === "theme" && options.themeConfigured === true; +} + +export function getPendingSetupStepIds( + agentDir: string = getAgentDir(), + options: PendingSetupStepOptions = {}, +): SetupStepId[] { + const state = readSetupState(agentDir) ?? createEmptySetupState(); + return SETUP_STEPS.filter( + (step) => state.steps[step.id] === undefined && !setupStepIsSatisfiedBySettings(step.id, options), + ).map((step) => step.id); +} + +export function hasPendingSetupSteps(agentDir: string = getAgentDir(), options: PendingSetupStepOptions = {}): boolean { + return getPendingSetupStepIds(agentDir, options).length > 0; +} + +export function markSetupStepComplete( + stepId: SetupStepId, + agentDir: string = getAgentDir(), + completedAt: Date = new Date(), +): void { + const state = readSetupState(agentDir) ?? createEmptySetupState(); + const step = SETUP_STEPS.find((candidate) => candidate.id === stepId); + if (!step) { + return; + } + state.steps[stepId] = { + completedAt: completedAt.toISOString(), + setupVersion: step.introducedIn, + }; + writeSetupState(state, agentDir); +} diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index 77dd79aad..097ab42d6 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -21,7 +21,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "scoped-models", description: "Enable/disable models for Ctrl+P cycling" }, { name: "export", description: "Export session (HTML default, or specify path: .html/.jsonl)" }, { name: "import", description: "Import and resume a session from a JSONL file" }, - { name: "share", description: "Share session as a secret GitHub gist" }, + { name: "share", description: "Share session as HTML link backed by pi.dev or GitHub gist" }, + { name: "pi.dev", description: "Create or sign in to a pi.dev profile" }, { name: "copy", description: "Copy last agent message to clipboard" }, { name: "name", description: "Set session display name" }, { name: "session", description: "Show session info and stats" }, diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 398f5430d..ceba8d9a3 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -4,6 +4,82 @@ export { type Args, parseArgs } from "./cli/args.ts"; // Config paths export { getAgentDir, getDocsPath, getExamplesPath, getPackageDir, getReadmePath, VERSION } from "./config.ts"; +export { + type ActivitySyncResult, + type ActivitySyncStatus, + type SyncSessionAnalyticsOptions, + syncSessionAnalytics, +} from "./core/activity-sync/activity-sync.ts"; +export { + ACTIVITY_SYNC_CLIENT_ID, + ACTIVITY_SYNC_SCOPE, + ActivitySyncApiError, + type ActivitySyncApiOptions, + type ActivitySyncDeviceFlowResponse, + type ActivitySyncFetch, + type ActivitySyncTokenResponse, + type ActivitySyncUploadResponse, + type ActivitySyncWatermarkResponse, + DEFAULT_PI_DEV_URL, + getActivitySyncWatermark, + pollActivitySyncDeviceToken, + refreshActivitySyncAccessToken, + startActivitySyncDeviceFlow, + type UploadSessionAnalyticsOptions, + uploadSessionAnalytics, +} from "./core/activity-sync/api.ts"; +export { + ACTIVITY_SYNC_CONTENT_ENCODING, + ACTIVITY_SYNC_MAX_COMPRESSED_BYTES, + ACTIVITY_SYNC_MAX_DECOMPRESSED_BYTES, + type ActivitySyncPayload, + type BuildActivitySyncPayloadsOptions, + buildActivitySyncPayloads, + compareSessionAnalyticsRecords, + getSessionAnalyticsRecordTimestamp, + serializeSessionAnalyticsNdjson, + sortSessionAnalyticsRecords, +} from "./core/activity-sync/payload.ts"; +export { + hashSessionAnalyticsString, + type ProjectSessionAnalyticsOptions, + type ProjectSessionHeaderAnalyticsOptions, + projectSessionEntryForAnalytics, + projectSessionForAnalytics, + projectSessionHeaderForAnalytics, + SESSION_ANALYTICS_SCHEMA_VERSION, + type SessionAnalyticsContentStats, + type SessionAnalyticsEntryRecord, + type SessionAnalyticsRecord, + type SessionAnalyticsSessionRecord, + type SessionAnalyticsUsage, +} from "./core/activity-sync/session-analytics.ts"; +export { + type BuildSessionAnalyticsUploadOptions, + type BuildSessionAnalyticsUploadResult, + buildSessionAnalyticsUpload, +} from "./core/activity-sync/session-analytics-reader.ts"; +export { + type DiscoveredSession, + type DiscoverSessionFilesOptions, + type DiscoverSessionsOptions, + discoverSessionFiles, + discoverSessions, + type SessionDiscoveryPhase, + type SessionDiscoveryProgress, + type SessionDiscoveryProgressCallback, +} from "./core/activity-sync/session-discovery.ts"; +export { + type ActivitySyncLockResult, + type ActivitySyncState, + type ActivitySyncStatePaths, + getActivitySyncStatePaths, + getStableActivitySyncDeviceId, + loadActivitySyncState, + saveActivitySyncState, + updateActivitySyncState, + withActivitySyncLock, +} from "./core/activity-sync/state.ts"; export { AgentSession, type AgentSessionConfig, @@ -49,7 +125,11 @@ export { serializeConversation, shouldCompact, } from "./core/compaction/index.ts"; -export { createEventBus, type EventBus, type EventBusController } from "./core/event-bus.ts"; +export { + createEventBus, + type EventBus, + type EventBusController, +} from "./core/event-bus.ts"; // Extension system export type { AgentEndEvent, @@ -165,8 +245,82 @@ export type { ResolvedResource, } from "./core/package-manager.ts"; export { DefaultPackageManager } from "./core/package-manager.ts"; -export type { ResourceCollision, ResourceDiagnostic, ResourceLoader } from "./core/resource-loader.ts"; -export { DefaultResourceLoader, loadProjectContextFiles } from "./core/resource-loader.ts"; +export { + formatPiDevScopes, + getPiDevBaseUrl, + normalizePiDevBaseUrl, + PI_DEV_ACTIVITY_SYNC_SCOPE, + PI_DEV_DEFAULT_BASE_URL, + PI_DEV_OAUTH_CLIENT_ID, + PI_DEV_OAUTH_PROVIDER_ID, + PI_DEV_OFFLINE_ACCESS_SCOPE, + PI_DEV_PROFILE_CONNECTED_STATUS, + PI_DEV_PROFILE_SCOPES, + PI_DEV_SESSION_SHARE_SCOPE, + PI_DEV_SETUP_PROFILE_CONNECTED_STATUS, + scopesFromString, + withPiDevOfflineAccess, +} from "./core/pi-dev/config.ts"; +export { + createFormBody, + getPiDevApiUrl, + getPiDevFetch, + isRecord, + numberField, + PiDevApiError, + type PiDevApiErrorCtor, + type PiDevApiOptions, + type PiDevFetch, + readJson, + readJsonObject, + requireNumber, + requireString, + stringField, + throwIfPiDevNotOk, +} from "./core/pi-dev/http.ts"; +export { + getPiDevAuth, + hasPiDevScopes, + introspectPiDevAccessToken, + loginPiDev, + type PiDevAccessIntrospectionResult, + type PiDevAuthOptions, + type PiDevAuthResult, + type PiDevDeviceCodeInfo, + type PiDevDeviceFlowOptions, + type PiDevDeviceFlowResponse, + type PiDevDeviceTokenOptions, + type PiDevLoginOptions, + type PiDevRefreshTokenOptions, + type PiDevTokenResponse, + pollPiDevDeviceToken, + refreshPiDevAccessToken, + startPiDevDeviceFlow, +} from "./core/pi-dev/oauth.ts"; +export { + formatPiDevShareSuccess, + formatPiDevShareUploadError, + getPiDevShareAuth, + loginPiDevShare, + type PiDevShareAuthResult, + type PiDevShareDeviceAuthInfo, + type PiDevShareDeviceAuthOptions, + type PiDevShareUploadOptions, + type PiDevShareUploadResult, + parseShareCommand, + type ShareCommandMode, + type ShareCommandParseResult, + uploadPiDevSessionShare, +} from "./core/pi-dev/session-share.ts"; +export type { + ResourceCollision, + ResourceDiagnostic, + ResourceLoader, +} from "./core/resource-loader.ts"; +export { + DefaultResourceLoader, + loadProjectContextFiles, +} from "./core/resource-loader.ts"; // SDK for programmatic usage export { AgentSessionRuntime, @@ -195,6 +349,7 @@ export { createWriteTool, type PromptTemplate, } from "./core/sdk.ts"; + export { type BranchSummaryEntry, buildSessionContext, @@ -222,9 +377,12 @@ export { type CompactionSettings, type ImageSettings, type PackageSource, + type PiDevActivitySyncSettings, + type PiDevSettings, type RetrySettings, SettingsManager, type SettingsManagerCreateOptions, + type TelemetrySettings, } from "./core/settings-manager.ts"; // Skills export { @@ -287,7 +445,11 @@ export { type WriteToolOptions, withFileMutationQueue, } from "./core/tools/index.ts"; -export { hasProjectTrustInputs, type ProjectTrustDecision, ProjectTrustStore } from "./core/trust-manager.ts"; +export { + hasProjectTrustInputs, + type ProjectTrustDecision, + ProjectTrustStore, +} from "./core/trust-manager.ts"; // Main entry point export { type MainOptions, main } from "./main.ts"; // Run modes for programmatic SDK usage @@ -361,6 +523,10 @@ export { export { copyToClipboard } from "./utils/clipboard.ts"; export { parseFrontmatter, stripFrontmatter } from "./utils/frontmatter.ts"; export { convertToPng } from "./utils/image-convert.ts"; -export { formatDimensionNote, type ResizedImage, resizeImage } from "./utils/image-resize.ts"; +export { + formatDimensionNote, + type ResizedImage, + resizeImage, +} from "./utils/image-resize.ts"; // Shell utilities export { getShellConfig } from "./utils/shell.ts"; diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index 335d814aa..d23c30975 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -14,6 +14,7 @@ import { processFileArguments } from "./cli/file-processor.ts"; import { buildInitialMessage } from "./cli/initial-message.ts"; import { listModels } from "./cli/list-models.ts"; import { selectSession } from "./cli/session-picker.ts"; +import { runStartupSetupIfNeeded } from "./cli/startup-setup.ts"; import { ENV_SESSION_DIR, expandTildePath, getAgentDir, getPackageDir, VERSION } from "./config.ts"; import { type CreateAgentSessionRuntimeFactory, createAgentSessionRuntime } from "./core/agent-session-runtime.ts"; import { @@ -799,6 +800,20 @@ export async function main(args: string[], options?: MainOptions) { const sessionCwd = sessionManager.getCwd(); const autoTrustOnReloadCwd = parsed.projectTrustOverride === undefined && !hasProjectTrustInputs(sessionCwd) ? sessionCwd : undefined; + const startupBenchmark = isTruthyEnvFlag(process.env.PI_STARTUP_BENCHMARK); + const authStorage = AuthStorage.create(); + const startupSetupResult = await runStartupSetupIfNeeded({ + agentDir, + settingsManager: startupSettingsManager, + authStorage, + skip: + appMode !== "interactive" || + parsed.help || + parsed.listModels !== undefined || + startupBenchmark || + offlineMode || + parsed.noSetup, + }); const trustPromptMode: AppMode = parsed.help || parsed.listModels !== undefined ? "print" : appMode; const projectTrustByCwd = new Map(); @@ -806,7 +821,6 @@ export async function main(args: string[], options?: MainOptions) { const resolvedSkillPaths = resolveCliPaths(cwd, parsed.skills); const resolvedPromptTemplatePaths = resolveCliPaths(cwd, parsed.promptTemplates); const resolvedThemePaths = resolveCliPaths(cwd, parsed.themes); - const authStorage = AuthStorage.create(); const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, agentDir, @@ -992,7 +1006,6 @@ export async function main(args: string[], options?: MainOptions) { process.exit(1); } - const startupBenchmark = isTruthyEnvFlag(process.env.PI_STARTUP_BENCHMARK); if (startupBenchmark && appMode !== "interactive") { console.error(chalk.red("Error: PI_STARTUP_BENCHMARK only supports interactive mode")); process.exit(1); @@ -1010,6 +1023,8 @@ export async function main(args: string[], options?: MainOptions) { initialImages, initialMessages: parsed.messages, verbose: parsed.verbose, + setupErrorMessage: startupSetupResult.errorMessage, + setupStatusMessage: startupSetupResult.statusMessage, }); if (startupBenchmark) { await interactiveMode.init(); diff --git a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts index 84d9a2c1b..5d68786e5 100644 --- a/packages/coding-agent/src/modes/interactive/components/login-dialog.ts +++ b/packages/coding-agent/src/modes/interactive/components/login-dialog.ts @@ -109,18 +109,38 @@ export class LoginDialogComponent extends Container implements Focusable { * Called by onDeviceCode callback - show URL and user code. */ showDeviceCode(info: OAuthDeviceCodeInfo): void { + this.showDeviceAuthorization({ + verificationUri: info.verificationUri, + displayUri: info.verificationUri, + userCode: info.userCode, + }); + } + + /** + * Show a device authorization link when the URL already embeds the user code. + */ + showDeviceAuthorizationLink(info: { verificationUri: string; displayVerificationUri?: string }): void { + this.showDeviceAuthorization({ + verificationUri: info.verificationUri, + displayUri: info.displayVerificationUri ?? info.verificationUri, + }); + } + + private showDeviceAuthorization(options: { verificationUri: string; displayUri: string; userCode?: string }): void { this.contentContainer.clear(); this.contentContainer.addChild(new Spacer(1)); - const linkedUrl = `\x1b]8;;${info.verificationUri}\x07${info.verificationUri}\x1b]8;;\x07`; + const linkedUrl = `\x1b]8;;${options.verificationUri}\x07${options.displayUri}\x1b]8;;\x07`; this.contentContainer.addChild(new Text(theme.fg("accent", linkedUrl), 1, 0)); const clickHint = process.platform === "darwin" ? "Cmd+click to open" : "Ctrl+click to open"; - const hyperlink = `\x1b]8;;${info.verificationUri}\x07${clickHint}\x1b]8;;\x07`; + const hyperlink = `\x1b]8;;${options.verificationUri}\x07${clickHint}\x1b]8;;\x07`; this.contentContainer.addChild(new Text(theme.fg("dim", hyperlink), 1, 0)); - this.contentContainer.addChild(new Spacer(1)); - this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${info.userCode}`), 1, 0)); + if (options.userCode !== undefined) { + this.contentContainer.addChild(new Spacer(1)); + this.contentContainer.addChild(new Text(theme.fg("warning", `Enter code: ${options.userCode}`), 1, 0)); + } - openBrowser(info.verificationUri); + openBrowser(options.verificationUri); this.tui.requestRender(); } diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 7d210028a..2cd6bcae8 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -49,6 +49,7 @@ export interface SettingsConfig { hideThinkingBlock: boolean; collapseChangelog: boolean; enableInstallTelemetry: boolean; + activitySyncEnabled: boolean; doubleEscapeAction: "fork" | "tree" | "none"; treeFilterMode: "default" | "no-tools" | "user-only" | "labeled-only" | "all"; showHardwareCursor: boolean; @@ -77,6 +78,7 @@ export interface SettingsCallbacks { onHideThinkingBlockChange: (hidden: boolean) => void; onCollapseChangelogChange: (collapsed: boolean) => void; onEnableInstallTelemetryChange: (enabled: boolean) => void; + onActivitySyncChange: (enabled: boolean) => void; onDoubleEscapeActionChange: (action: "fork" | "tree" | "none") => void; onTreeFilterModeChange: (mode: "default" | "no-tools" | "user-only" | "labeled-only" | "all") => void; onShowHardwareCursorChange: (enabled: boolean) => void; @@ -272,11 +274,19 @@ export class SettingsSelectorComponent extends Container { }, { id: "install-telemetry", - label: "Install telemetry", - description: "Send an anonymous version/update ping after changelog-detected updates", + label: "Crash reporting and analytics", + description: + "Allow anonymous diagnostics, including version/update analytics and crash reports when available", currentValue: config.enableInstallTelemetry ? "true" : "false", values: ["true", "false"], }, + { + id: "activity-sync", + label: "Activity sync", + description: "Sync session activity metadata to your pi.dev profile", + currentValue: config.activitySyncEnabled ? "true" : "false", + values: ["true", "false"], + }, { id: "double-escape-action", label: "Double-escape action", @@ -512,6 +522,9 @@ export class SettingsSelectorComponent extends Container { case "install-telemetry": callbacks.onEnableInstallTelemetryChange(newValue === "true"); break; + case "activity-sync": + callbacks.onActivitySyncChange(newValue === "true"); + break; case "double-escape-action": callbacks.onDoubleEscapeActionChange(newValue as "fork" | "tree"); break; diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 193fb70c6..c0e4d7061 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -59,6 +59,11 @@ import { getShareViewerUrl, VERSION, } from "../../config.ts"; +import { + getStableActivitySyncDeviceId, + loadActivitySyncState, + syncSessionAnalytics, +} from "../../core/activity-sync/index.ts"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.ts"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.ts"; import type { @@ -78,6 +83,16 @@ import { type AppKeybinding, KeybindingsManager } from "../../core/keybindings.t import { createCompactionSummaryMessage } from "../../core/messages.ts"; import { defaultModelPerProvider, findExactModelReferenceMatch, resolveModelScope } from "../../core/model-resolver.ts"; import { DefaultPackageManager } from "../../core/package-manager.ts"; +import { + formatPiDevShareSuccess, + getPiDevAuth, + PI_DEV_PROFILE_CONNECTED_STATUS, + PI_DEV_PROFILE_SCOPES, + PI_DEV_SESSION_SHARE_SCOPE, + parseShareCommand, + type ShareCommandMode, + uploadPiDevSessionShare, +} from "../../core/pi-dev/index.ts"; import { BUILT_IN_PROVIDER_DISPLAY_NAMES } from "../../core/provider-display-names.ts"; import type { ResourceDiagnostic } from "../../core/resource-loader.ts"; import { formatMissingSessionCwdPrompt, MissingSessionCwdError } from "../../core/session-cwd.ts"; @@ -125,6 +140,7 @@ import { TreeSelectorComponent } from "./components/tree-selector.ts"; import { TrustSelectorComponent } from "./components/trust-selector.ts"; import { UserMessageComponent } from "./components/user-message.ts"; import { UserMessageSelectorComponent } from "./components/user-message-selector.ts"; +import { runPiDevLoginDialog } from "./pi-dev-login-dialog.ts"; import { getAvailableThemes, getAvailableThemesWithPaths, @@ -260,6 +276,10 @@ export interface InteractiveModeOptions { initialMessages?: string[]; /** Force verbose startup (overrides quietStartup setting) */ verbose?: boolean; + /** Error from setup completed before interactive mode starts */ + setupErrorMessage?: string; + /** Status from setup completed before interactive mode starts */ + setupStatusMessage?: string; } export class InteractiveMode { @@ -522,7 +542,7 @@ export class InteractiveMode { })); // Convert extension commands to SlashCommand format - const builtinCommandNames = new Set(slashCommands.map((c) => c.name)); + const builtinCommandNames = new Set(BUILTIN_SLASH_COMMANDS.map((c) => c.name)); const extensionCommands: SlashCommand[] = this.session.extensionRunner .getRegisteredCommands() .filter((cmd) => !builtinCommandNames.has(cmd.name)) @@ -601,14 +621,6 @@ export class InteractiveMode { this.registerSignalHandlers(); - // Load changelog (only show new entries, skip for resumed sessions) - this.changelogMarkdown = this.getChangelogForDisplay(); - - // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) - // Both are needed: fd for autocomplete, rg for grep tool and bash commands - const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); - this.fdPath = fdPath; - if (this.session.scopedModels.length > 0 && (this.options.verbose || !this.settingsManager.getQuietStartup())) { const modelList = this.session.scopedModels .map((sm) => { @@ -701,23 +713,30 @@ export class InteractiveMode { this.setupKeyHandlers(); this.setupEditorSubmitHandler(); - // Start the UI before initializing extensions so session_start handlers can use interactive dialogs + // Start the UI before extension initialization so session_start handlers can use interactive dialogs. this.ui.start(); this.isInitialized = true; + // Set up theme file watcher. + onThemeChange(() => { + this.ui.invalidate(); + this.updateEditorBorderColor(); + this.ui.requestRender(); + }); + + this.changelogMarkdown = this.getChangelogForDisplay(); + + // Ensure fd and rg are available (downloads if missing, adds to PATH via getBinDir) + // Both are needed: fd for autocomplete, rg for grep tool and bash commands + const [fdPath] = await Promise.all([ensureTool("fd"), ensureTool("rg")]); + this.fdPath = fdPath; + // Initialize extensions first so resources are shown before messages await this.rebindCurrentSession(); // Render initial messages AFTER showing loaded resources this.renderInitialMessages(); - // Set up theme file watcher - onThemeChange(() => { - this.ui.invalidate(); - this.updateEditorBorderColor(); - this.ui.requestRender(); - }); - // Set up git branch watcher (uses provider instead of footer) this.footerDataProvider.onBranchChange(() => { this.ui.requestRender(); @@ -768,8 +787,25 @@ export class InteractiveMode { } }); + this.maybeRunBackgroundActivitySync(); + // Show startup warnings - const { migratedProviders, modelFallbackMessage, initialMessage, initialImages, initialMessages } = this.options; + const { + migratedProviders, + modelFallbackMessage, + initialMessage, + initialImages, + initialMessages, + setupErrorMessage, + setupStatusMessage, + } = this.options; + + if (setupErrorMessage) { + this.showError(setupErrorMessage); + } + if (setupStatusMessage) { + this.showStatus(setupStatusMessage); + } if (migratedProviders && migratedProviders.length > 0) { this.showWarning(`Migrated credentials to auth.json: ${migratedProviders.join(", ")}`); @@ -780,7 +816,9 @@ export class InteractiveMode { this.showError(`models.json error: ${modelsJsonError}`); } - if (modelFallbackMessage) { + const staleNoModelsWarning = + modelFallbackMessage?.startsWith("No models available.") === true && !isUnknownModel(this.session.model); + if (modelFallbackMessage && !staleNoModelsWarning) { this.showWarning(modelFallbackMessage); } @@ -915,6 +953,29 @@ export class InteractiveMode { return undefined; } + private maybeRunBackgroundActivitySync(): void { + const settings = this.settingsManager.getActivitySyncSettings(); + if (!settings.enabled || process.env.PI_OFFLINE) return; + + void loadActivitySyncState(getAgentDir()) + .then((state) => { + const lastAttemptTime = state.lastAttemptAt ? new Date(state.lastAttemptAt).getTime() : 0; + if ( + Number.isFinite(lastAttemptTime) && + Date.now() - lastAttemptTime < settings.intervalHours * 60 * 60 * 1000 + ) { + return undefined; + } + return syncSessionAnalytics({ + sessionsRoot: this.sessionManager.getSessionDir(), + settingsManager: this.settingsManager, + authStorage: this.session.modelRegistry.authStorage, + }); + }) + .then(() => undefined) + .catch(() => undefined); + } + private reportInstallTelemetry(version: string): void { if (process.env.PI_OFFLINE) { return; @@ -1140,7 +1201,11 @@ export class InteractiveMode { } if (source === "cli") { - return { label: "path", scopeLabel: scope === "temporary" ? "temp" : undefined, color: "muted" }; + return { + label: "path", + scopeLabel: scope === "temporary" ? "temp" : undefined, + color: "muted", + }; } const scopeLabel = @@ -1380,7 +1445,9 @@ export class InteractiveMode { if (showListing) { const contextFiles = this.session.resourceLoader.getAgentsFiles().agentsFiles; if (contextFiles.length > 0) { - this.chatContainer.addChild(new Spacer(1)); + if (this.chatContainer.children.length > 0) { + this.chatContainer.addChild(new Spacer(1)); + } const contextList = contextFiles .map((f) => theme.fg("dim", ` ${this.formatDisplayPath(f.path)}`)) .join("\n"); @@ -1394,7 +1461,10 @@ export class InteractiveMode { const skills = skillsResult.skills; if (skills.length > 0) { const groups = this.buildScopeGroups( - skills.map((skill) => ({ path: skill.filePath, sourceInfo: skill.sourceInfo })), + skills.map((skill) => ({ + path: skill.filePath, + sourceInfo: skill.sourceInfo, + })), ); const skillList = this.formatScopeGroups(groups, { formatPath: (item) => this.formatDisplayPath(item.path), @@ -1407,7 +1477,10 @@ export class InteractiveMode { const templates = this.session.promptTemplates; if (templates.length > 0) { const groups = this.buildScopeGroups( - templates.map((template) => ({ path: template.filePath, sourceInfo: template.sourceInfo })), + templates.map((template) => ({ + path: template.filePath, + sourceInfo: template.sourceInfo, + })), ); const templateByPath = new Map(templates.map((t) => [t.filePath, t])); const templateList = this.formatScopeGroups(groups, { @@ -1480,7 +1553,11 @@ export class InteractiveMode { const extensionErrors = this.session.resourceLoader.getExtensions().errors; if (extensionErrors.length > 0) { for (const error of extensionErrors) { - extensionDiagnostics.push({ type: "error", message: error.error, path: error.path }); + extensionDiagnostics.push({ + type: "error", + message: error.error, + path: error.path, + }); } } @@ -2099,7 +2176,11 @@ export class InteractiveMode { this.hideExtensionSelector(); resolve(undefined); }, - { tui: this.ui, timeout: opts?.timeout, onToggleToolsExpanded: () => this.toggleToolOutputExpansion() }, + { + tui: this.ui, + timeout: opts?.timeout, + onToggleToolsExpanded: () => this.toggleToolOutputExpansion(), + }, ); this.editorContainer.clear(); @@ -2541,8 +2622,8 @@ export class InteractiveMode { this.editor.setText(""); return; } - if (text === "/share") { - await this.handleShareCommand(); + if (text === "/share" || text.startsWith("/share ")) { + await this.handleShareCommand(text); this.editor.setText(""); return; } @@ -2591,6 +2672,11 @@ export class InteractiveMode { this.editor.setText(""); return; } + if (text === "/pi.dev") { + this.editor.setText(""); + await this.handlePiDevCommand(); + return; + } if (text === "/login") { this.showOAuthSelector("login"); this.editor.setText(""); @@ -3221,7 +3307,10 @@ export class InteractiveMode { } else { errorMessage = message.errorMessage || "Error"; } - component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); + component.updateResult({ + content: [{ type: "text", text: errorMessage }], + isError: true, + }); } else { renderedPendingTools.set(content.id, component); } @@ -3962,6 +4051,7 @@ export class InteractiveMode { hideThinkingBlock: this.hideThinkingBlock, collapseChangelog: this.settingsManager.getCollapseChangelog(), enableInstallTelemetry: this.settingsManager.getEnableInstallTelemetry(), + activitySyncEnabled: this.settingsManager.getActivitySyncSettings().enabled, doubleEscapeAction: this.settingsManager.getDoubleEscapeAction(), treeFilterMode: this.settingsManager.getTreeFilterMode(), showHardwareCursor: this.settingsManager.getShowHardwareCursor(), @@ -4055,6 +4145,9 @@ export class InteractiveMode { onEnableInstallTelemetryChange: (enabled) => { this.settingsManager.setEnableInstallTelemetry(enabled); }, + onActivitySyncChange: (enabled) => { + void this.handleActivitySyncSettingsChange(enabled); + }, onQuietStartupChange: (enabled) => { this.settingsManager.setQuietStartup(enabled); }, @@ -4831,7 +4924,6 @@ export class InteractiveMode { this.ui.setFocus(this.editor); this.ui.requestRender(); }; - const dialog = new LoginDialogComponent( this.ui, providerId, @@ -4888,8 +4980,8 @@ export class InteractiveMode { await this.completeProviderAuthentication(providerId, providerName, "api_key", previousModel); } catch (error: unknown) { restoreEditor(); - const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg !== "Login cancelled") { + if (!dialog.signal.aborted) { + const errorMsg = error instanceof Error ? error.message : String(error); this.showError(`Failed to save API key for ${providerName}: ${errorMsg}`); } } @@ -5014,8 +5106,8 @@ export class InteractiveMode { await this.completeProviderAuthentication(providerId, providerName, "oauth", previousModel); } catch (error: unknown) { restoreEditor(); - const errorMsg = error instanceof Error ? error.message : String(error); - if (errorMsg !== "Login cancelled") { + if (!dialog.signal.aborted) { + const errorMsg = error instanceof Error ? error.message : String(error); this.showError(`Failed to login to ${providerName}: ${errorMsg}`); } } @@ -5206,10 +5298,176 @@ export class InteractiveMode { } } - private async handleShareCommand(): Promise { + private async handleShareCommand(text: string): Promise { + const parsed = parseShareCommand(text); + if (!parsed.ok) { + this.showError(parsed.message); + return; + } + + if (parsed.mode === "github") { + await this.handleGitHubShareCommand(); + return; + } + + if (parsed.mode === "auto") { + const auth = await getPiDevAuth(this.session.modelRegistry.authStorage, [PI_DEV_SESSION_SHARE_SCOPE]); + if (!auth.available) { + await this.handleGitHubShareCommand(); + return; + } + await this.handlePiDevShareCommand(auth.accessToken, parsed.mode); + return; + } + + const accessToken = await this.ensurePiDevAuthenticated([PI_DEV_SESSION_SHARE_SCOPE], { + title: "Create pi.dev profile to share sessions", + }); + if (!accessToken) { + this.showStatus("Share cancelled"); + return; + } + + await this.handlePiDevShareCommand(accessToken, parsed.mode); + } + + private async ensurePiDevAuthenticated( + requiredScopes: readonly string[], + options: { title: string; deviceId?: string; forceLogin?: boolean }, + ): Promise { + if (!options.forceLogin) { + const auth = await getPiDevAuth(this.session.modelRegistry.authStorage, requiredScopes); + if (auth.available) return auth.accessToken; + } + + return this.showPiDevLoginDialog(requiredScopes, options); + } + + private async connectPiDevProfile(options: { title: string; forceLogin?: boolean }): Promise { + const deviceId = getStableActivitySyncDeviceId(this.settingsManager); + await this.settingsManager.flush(); + const accessToken = await this.ensurePiDevAuthenticated(PI_DEV_PROFILE_SCOPES, { + title: options.title, + deviceId, + forceLogin: options.forceLogin, + }); + if (!accessToken) { + return undefined; + } + + this.settingsManager.setActivitySyncEnabled(true); + await this.settingsManager.flush(); + return accessToken; + } + + private async showPiDevLoginDialog( + requiredScopes: readonly string[], + options: { title: string; deviceId?: string }, + ): Promise { + const restoreEditor = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }; + + try { + const credential = await runPiDevLoginDialog({ + tui: this.ui, + container: this.editorContainer, + authStorage: this.session.modelRegistry.authStorage, + scopes: requiredScopes, + deviceId: options.deviceId, + title: options.title, + }); + return credential?.access; + } catch (error: unknown) { + const message = error instanceof Error ? error.message : String(error); + this.showError(`Failed to login to pi.dev: ${message}`); + return undefined; + } finally { + restoreEditor(); + } + } + + private createShareHtmlTempPath(): string { + return path.join(os.tmpdir(), `pi-session-${crypto.randomUUID()}.html`); + } + + private async exportShareHtml(tmpFile: string): Promise { + try { + await this.session.exportToHtml(tmpFile); + const byteSize = fs.statSync(tmpFile).size; + if (byteSize <= 0) { + this.showError("Failed to export session: exported HTML is empty"); + return undefined; + } + return byteSize; + } catch (error: unknown) { + this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + return undefined; + } + } + + private async handlePiDevShareCommand(accessToken: string, mode: ShareCommandMode): Promise { + const tmpFile = this.createShareHtmlTempPath(); + const byteSize = await this.exportShareHtml(tmpFile); + if (byteSize === undefined) { + return; + } + + const loader = new BorderedLoader(this.ui, theme, "Uploading to pi.dev..."); + this.editorContainer.clear(); + this.editorContainer.addChild(loader); + this.ui.setFocus(loader); + this.ui.requestRender(); + + const abortController = new AbortController(); + const restoreEditor = () => { + loader.dispose(); + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + try { + fs.unlinkSync(tmpFile); + } catch { + // Ignore cleanup errors + } + }; + + loader.onAbort = () => { + abortController.abort(); + restoreEditor(); + this.showStatus("Share cancelled"); + }; + + try { + const bytes = fs.readFileSync(tmpFile); + const result = await uploadPiDevSessionShare({ + accessToken, + bytes, + byteSize, + signal: abortController.signal, + }); + if (loader.signal.aborted) return; + restoreEditor(); + this.showStatus(formatPiDevShareSuccess(result.url)); + } catch (error: unknown) { + if (!loader.signal.aborted) { + restoreEditor(); + const reason = error instanceof Error ? error.message : "Unknown error"; + const suggestion = mode === "auto" ? "\nRun /share github to use the GitHub gist fallback." : ""; + this.showError(`Failed to upload session to pi.dev: ${reason}${suggestion}`); + } + } + } + + private async handleGitHubShareCommand(): Promise { // Check if gh is available and logged in try { - const authResult = spawnSync("gh", ["auth", "status"], { encoding: "utf-8" }); + const authResult = spawnSync("gh", ["auth", "status"], { + encoding: "utf-8", + }); if (authResult.status !== 0) { this.showError("GitHub CLI is not logged in. Run 'gh auth login' first."); return; @@ -5220,11 +5478,9 @@ export class InteractiveMode { } // Export to a temp file - const tmpFile = path.join(os.tmpdir(), "session.html"); - try { - await this.session.exportToHtml(tmpFile); - } catch (error: unknown) { - this.showError(`Failed to export session: ${error instanceof Error ? error.message : "Unknown error"}`); + const tmpFile = this.createShareHtmlTempPath(); + const byteSize = await this.exportShareHtml(tmpFile); + if (byteSize === undefined) { return; } @@ -5257,7 +5513,11 @@ export class InteractiveMode { }; try { - const result = await new Promise<{ stdout: string; stderr: string; code: number | null }>((resolve) => { + const result = await new Promise<{ + stdout: string; + stderr: string; + code: number | null; + }>((resolve) => { proc = spawn("gh", ["gist", "create", "--public=false", tmpFile]); let stdout = ""; let stderr = ""; @@ -5372,6 +5632,35 @@ export class InteractiveMode { this.ui.requestRender(); } + private async handlePiDevCommand(): Promise { + const accessToken = await this.connectPiDevProfile({ title: "Create or sign in to pi.dev" }); + if (!accessToken) { + this.showStatus("pi.dev login cancelled"); + return; + } + this.showStatus(PI_DEV_PROFILE_CONNECTED_STATUS); + this.maybeRunBackgroundActivitySync(); + } + + private async handleActivitySyncSettingsChange(enabled: boolean): Promise { + if (!enabled) { + this.settingsManager.setActivitySyncEnabled(false); + await this.settingsManager.flush(); + this.showStatus("Activity sync disabled"); + return; + } + + const accessToken = await this.connectPiDevProfile({ title: "Create pi.dev profile for activity sync" }); + if (!accessToken) { + this.settingsManager.setActivitySyncEnabled(false); + await this.settingsManager.flush(); + this.showStatus("Activity sync unchanged"); + return; + } + this.showStatus("Activity sync enabled"); + this.maybeRunBackgroundActivitySync(); + } + private handleChangelogCommand(): void { const changelogPath = getChangelogPath(); const allEntries = parseChangelog(changelogPath); diff --git a/packages/coding-agent/src/modes/interactive/pi-dev-login-dialog.ts b/packages/coding-agent/src/modes/interactive/pi-dev-login-dialog.ts new file mode 100644 index 000000000..f8470e58e --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/pi-dev-login-dialog.ts @@ -0,0 +1,47 @@ +import type { Container, TUI } from "@earendil-works/pi-tui"; +import type { AuthStorage, OAuthCredential } from "../../core/auth-storage.ts"; +import { loginPiDev } from "../../core/pi-dev/index.ts"; +import { LoginDialogComponent } from "./components/login-dialog.ts"; + +export interface PiDevLoginDialogOptions { + tui: TUI; + container: Container; + authStorage: AuthStorage; + scopes: readonly string[]; + title: string; + deviceId?: string; +} + +export async function runPiDevLoginDialog(options: PiDevLoginDialogOptions): Promise { + const dialog = new LoginDialogComponent( + options.tui, + "pi.dev", + (_success, _message) => { + // Completion handled below. + }, + "pi.dev", + options.title, + ); + + options.container.clear(); + options.container.addChild(dialog); + options.tui.setFocus(dialog); + options.tui.requestRender(); + + try { + return await loginPiDev(options.authStorage, { + scopes: options.scopes, + deviceId: options.deviceId, + signal: dialog.signal, + onDeviceCode: (info) => { + dialog.showDeviceAuthorizationLink(info); + dialog.showWaiting("Waiting for authentication..."); + }, + }); + } catch (error: unknown) { + if (dialog.signal.aborted) { + return undefined; + } + throw error; + } +} diff --git a/packages/coding-agent/src/modes/interactive/setup-wizard.ts b/packages/coding-agent/src/modes/interactive/setup-wizard.ts new file mode 100644 index 000000000..da585f448 --- /dev/null +++ b/packages/coding-agent/src/modes/interactive/setup-wizard.ts @@ -0,0 +1,404 @@ +import { + type Component, + type Container, + type SelectItem, + SelectList, + type TUI, + truncateToWidth, +} from "@earendil-works/pi-tui"; +import type { SettingsManager } from "../../core/settings-manager.ts"; +import { + getAllSetupStepIds, + getPendingSetupStepIds, + markSetupStepComplete, + type SetupStepId, +} from "../../core/setup-state.ts"; +import { getDefaultTheme, getSelectListTheme, setTheme, theme } from "./theme/theme.ts"; + +type SetupWizardMode = "automatic" | "manual"; +type SetupStepOutcome = "completed" | "cancelled" | "back" | { profileRequested: true }; +type ThemeSetupChoice = "dark" | "light"; + +const SETUP_LOGO_LINES = ["██████", "██ ██", "████ ██", "██ ██"]; + +interface SetupWizardMountOptions { + parent: Container; + before: Component; +} + +export interface SetupWizardOptions { + tui: TUI; + settingsManager: SettingsManager; + agentDir: string; + mode: SetupWizardMode; + steps?: readonly SetupStepId[]; + container: Container; + mount?: SetupWizardMountOptions; + focusAfter?: Component; +} + +export interface SetupWizardResult { + completed: boolean; + cancelled: boolean; + completedSteps: SetupStepId[]; + profileRequested?: boolean; +} + +function mountSetupContainer(options: SetupWizardOptions): void { + if (!options.mount || options.mount.parent.children.includes(options.container)) { + return; + } + + const insertIndex = options.mount.parent.children.indexOf(options.mount.before); + if (insertIndex === -1) { + options.mount.parent.addChild(options.container); + return; + } + options.mount.parent.children.splice(insertIndex, 0, options.container); +} + +function unmountSetupContainer(options: SetupWizardOptions): void { + if (options.mount) { + options.mount.parent.removeChild(options.container); + } +} + +function showSetupComponent(options: SetupWizardOptions, component: Component): () => void { + mountSetupContainer(options); + options.container.clear(); + options.container.addChild(component); + options.tui.setFocus(component); + options.tui.requestRender(); + return () => { + options.container.clear(); + unmountSetupContainer(options); + options.tui.setFocus(options.focusAfter ?? null); + options.tui.requestRender(); + }; +} + +function pushSetupLogo(lines: string[], width: number): void { + for (const line of SETUP_LOGO_LINES) { + lines.push(truncateToWidth(` ${theme.fg("accent", line)}`, width, "")); + } + lines.push(""); +} + +function toThemeSetupChoice(value: string | undefined): ThemeSetupChoice { + return value === "light" ? "light" : "dark"; +} + +class ThemeSetupComponent implements Component { + private readonly selectList: SelectList; + private readonly currentTheme: ThemeSetupChoice; + private readonly canGoBack: boolean; + + constructor( + currentTheme: string | undefined, + canGoBack: boolean, + onSelectTheme: (themeName: ThemeSetupChoice) => void, + onPreviewTheme: (themeName: ThemeSetupChoice) => void, + onBack: () => void, + onCancel: () => void, + ) { + this.currentTheme = toThemeSetupChoice(currentTheme); + this.canGoBack = canGoBack; + const items: SelectItem[] = [ + { value: "dark", label: "Dark" }, + { value: "light", label: "Light" }, + ]; + this.selectList = new SelectList(items, items.length, getSelectListTheme(), { + minPrimaryColumnWidth: 10, + maxPrimaryColumnWidth: 12, + }); + this.selectList.setSelectedIndex(items.findIndex((item) => item.value === this.currentTheme)); + this.selectList.onSelect = (item) => { + onSelectTheme(toThemeSetupChoice(item.value)); + }; + this.selectList.onCancel = () => { + if (this.canGoBack) { + onBack(); + } else { + onCancel(); + } + }; + this.selectList.onSelectionChange = (item) => { + onPreviewTheme(toThemeSetupChoice(item.value)); + }; + } + + render(width: number): string[] { + const lines: string[] = []; + const push = (line = "") => lines.push(truncateToWidth(line, width, "")); + + pushSetupLogo(lines, width); + push(` ${theme.fg("accent", theme.bold("Welcome to Pi, the minimal coding agent."))}`); + push(); + push(` ${theme.fg("text", "Choose your theme")}`); + push(); + lines.push(...this.selectList.render(width)); + push(); + const backHint = this.canGoBack ? " · Esc to go back" : " · Esc to skip setup"; + push(` ${theme.fg("dim", `Enter to continue · ↑/↓ to preview${backHint}`)}`); + + return lines; + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } + + invalidate(): void { + this.selectList.invalidate(); + } +} + +class PiDevProfileSetupComponent implements Component { + private readonly selectList: SelectList; + private readonly canGoBack: boolean; + + constructor( + canGoBack: boolean, + onCreateProfile: () => void, + onSkip: () => void, + onBack: () => void, + onCancel: () => void, + ) { + this.canGoBack = canGoBack; + const items: SelectItem[] = [ + { + value: "create-profile", + label: "Create profile or sign in", + }, + { + value: "skip", + label: "Continue without pi.dev profile", + }, + ]; + this.selectList = new SelectList(items, items.length, getSelectListTheme(), { + minPrimaryColumnWidth: 30, + maxPrimaryColumnWidth: 34, + }); + this.selectList.onSelect = (item) => { + if (item.value === "create-profile") { + onCreateProfile(); + return; + } + onSkip(); + }; + this.selectList.onCancel = () => { + if (this.canGoBack) { + onBack(); + } else { + onCancel(); + } + }; + } + + render(width: number): string[] { + const lines: string[] = []; + const push = (line = "") => lines.push(truncateToWidth(line, width, "")); + + pushSetupLogo(lines, width); + push(` ${theme.fg("accent", theme.bold("Welcome to Pi, the minimal coding agent."))}`); + push(); + push(` ${theme.fg("text", "Create a pi.dev profile to enable activity sync and storing of sessions")}`); + push(); + lines.push(...this.selectList.render(width)); + push(); + const backHint = this.canGoBack ? " · Esc to go back" : " · Esc to skip setup"; + push(` ${theme.fg("dim", `Enter to continue${backHint}`)}`); + + return lines; + } + + handleInput(data: string): void { + this.selectList.handleInput(data); + } + + invalidate(): void { + this.selectList.invalidate(); + } +} + +async function runThemeSetupStep(options: SetupWizardOptions, canGoBack: boolean): Promise { + return new Promise((resolve) => { + let closeComponent: (() => void) | undefined; + let closed = false; + + const previewTheme = (themeName: ThemeSetupChoice) => { + setTheme(themeName); + options.tui.requestRender(); + }; + + const finish = (themeName: ThemeSetupChoice) => { + if (closed) { + return; + } + closed = true; + setTheme(themeName); + options.settingsManager.setTheme(themeName); + markSetupStepComplete("theme", options.agentDir); + const close = () => { + closeComponent?.(); + options.tui.requestRender(); + resolve("completed"); + }; + void options.settingsManager.flush().then(close, close); + }; + + const closeWithoutSaving = (outcome: "back" | "cancelled") => { + if (closed) { + return; + } + closed = true; + setTheme(options.settingsManager.getTheme() ?? getDefaultTheme()); + closeComponent?.(); + options.tui.requestRender(); + resolve(outcome); + }; + + const goBack = () => closeWithoutSaving("back"); + const cancel = () => closeWithoutSaving("cancelled"); + + const themeSetup = new ThemeSetupComponent( + options.settingsManager.getTheme() ?? getDefaultTheme(), + canGoBack, + finish, + previewTheme, + goBack, + cancel, + ); + closeComponent = showSetupComponent(options, themeSetup); + }); +} + +async function runPiDevProfileSetupStep(options: SetupWizardOptions, canGoBack: boolean): Promise { + return new Promise((resolve) => { + let closeComponent: (() => void) | undefined; + let closed = false; + + const finish = (outcome: SetupStepOutcome) => { + if (closed) { + return; + } + closed = true; + markSetupStepComplete("pi-dev-profile", options.agentDir); + closeComponent?.(); + options.tui.requestRender(); + resolve(outcome); + }; + + const closeWithoutCompleting = (outcome: "back" | "cancelled") => { + if (closed) { + return; + } + closed = true; + closeComponent?.(); + options.tui.requestRender(); + resolve(outcome); + }; + + const goBack = () => closeWithoutCompleting("back"); + const cancel = () => closeWithoutCompleting("cancelled"); + + const profile = new PiDevProfileSetupComponent( + canGoBack, + () => finish({ profileRequested: true }), + () => finish("completed"), + goBack, + cancel, + ); + closeComponent = showSetupComponent(options, profile); + }); +} + +async function runSetupStep( + options: SetupWizardOptions, + step: SetupStepId, + canGoBack: boolean, +): Promise { + switch (step) { + case "theme": + return runThemeSetupStep(options, canGoBack); + case "pi-dev-profile": + return runPiDevProfileSetupStep(options, canGoBack); + } +} + +async function completeAutomaticSetupWithDefaults( + options: SetupWizardOptions, + steps: SetupStepId[], + completedSteps: SetupStepId[], + profileRequested: boolean, +): Promise { + const completedSet = new Set(completedSteps); + const completeStep = (step: SetupStepId) => { + if (completedSet.has(step)) { + return; + } + markSetupStepComplete(step, options.agentDir); + completedSet.add(step); + completedSteps.push(step); + }; + + if (steps.includes("theme") && !completedSet.has("theme")) { + completeStep("theme"); + } + if (steps.includes("pi-dev-profile") && !completedSet.has("pi-dev-profile")) { + completeStep("pi-dev-profile"); + } + + await options.settingsManager.flush(); + return { completed: true, cancelled: false, completedSteps, profileRequested }; +} + +export async function runSetupWizard(options: SetupWizardOptions): Promise { + const steps = [ + ...(options.steps ?? + (options.mode === "manual" + ? getAllSetupStepIds() + : getPendingSetupStepIds(options.agentDir, { + themeConfigured: options.settingsManager.getTheme() !== undefined, + }))), + ]; + const completedSteps: SetupStepId[] = []; + let profileRequested = false; + let index = 0; + const removeCompletedStepsFromIndex = (fromIndex: number) => { + for (let completedIndex = completedSteps.length - 1; completedIndex >= 0; completedIndex--) { + const stepIndex = steps.indexOf(completedSteps[completedIndex]); + if (stepIndex >= fromIndex) { + if (completedSteps[completedIndex] === "pi-dev-profile") { + profileRequested = false; + } + completedSteps.splice(completedIndex, 1); + } + } + }; + + while (index < steps.length) { + const step = steps[index]; + const outcome = await runSetupStep(options, step, index > 0); + if (outcome === "back") { + if (index > 0) { + index--; + removeCompletedStepsFromIndex(index); + } + continue; + } + if (outcome === "cancelled") { + if (options.mode === "automatic") { + return completeAutomaticSetupWithDefaults(options, steps, completedSteps, profileRequested); + } + return { completed: false, cancelled: true, completedSteps, profileRequested }; + } + if (typeof outcome === "object") { + profileRequested = outcome.profileRequested; + } + completedSteps.push(step); + index++; + } + + return { completed: true, cancelled: false, completedSteps, profileRequested }; +} diff --git a/packages/coding-agent/test/activity-sync-api.test.ts b/packages/coding-agent/test/activity-sync-api.test.ts new file mode 100644 index 000000000..384f6efa4 --- /dev/null +++ b/packages/coding-agent/test/activity-sync-api.test.ts @@ -0,0 +1,136 @@ +import { Buffer } from "node:buffer"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { + type ActivitySyncApiError, + getActivitySyncWatermark, + refreshActivitySyncAccessToken, + startActivitySyncDeviceFlow, + uploadSessionAnalytics, +} from "../src/core/activity-sync/api.ts"; + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" } }); +} + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("activity sync api", () => { + it("starts the OAuth device flow with the activity sync scope", async () => { + vi.stubEnv("PI_DEV_URL", "https://example.test/"); + let request: Request | undefined; + const fetchMock: typeof fetch = async (input, init) => { + request = new Request(input, init); + return jsonResponse({ + device_code: "pigd_1", + user_code: "ABCD-EFGH", + verification_uri: "https://pi.dev/pair", + verification_uri_complete: "https://pi.dev/pair?code=ABCD-EFGH", + expires_in: 300, + interval: 2, + }); + }; + + const response = await startActivitySyncDeviceFlow("00000000-0000-4000-8000-000000000000", { + fetch: fetchMock, + }); + + expect(response.device_code).toBe("pigd_1"); + expect(request?.url).toBe("https://example.test/api/oauth/device"); + expect(request?.method).toBe("POST"); + const body = new URLSearchParams(await request?.text()); + expect(body.get("client_id")).toBe("pi-coding-agent"); + expect(body.get("scope")).toBe("activity_sync offline_access"); + expect(body.get("device_id")).toBe("00000000-0000-4000-8000-000000000000"); + }); + + it("refreshes tokens and reads watermarks", async () => { + vi.stubEnv("PI_DEV_URL", "https://example.test"); + const urls: string[] = []; + const fetchMock: typeof fetch = async (input, init) => { + const request = new Request(input, init); + urls.push(request.url); + if (request.url.endsWith("/api/oauth/token")) { + return jsonResponse({ + token_type: "Bearer", + access_token: "access-2", + refresh_token: "refresh-2", + expires_in: 86400, + scope: "activity_sync offline_access", + }); + } + expect(request.headers.get("Authorization")).toBe("Bearer access-2"); + return jsonResponse({ ok: true, watermark: "2026-01-01T00:00:00.000Z" }); + }; + + const token = await refreshActivitySyncAccessToken("refresh-1", { + fetch: fetchMock, + }); + const watermark = await getActivitySyncWatermark(token.access_token, "device-1", { + fetch: fetchMock, + }); + + expect(token.refresh_token).toBe("refresh-2"); + expect(watermark.watermark).toBe("2026-01-01T00:00:00.000Z"); + expect(urls).toEqual([ + "https://example.test/api/oauth/token", + "https://example.test/analytics/activity/device-1", + ]); + }); + + it("uploads compressed NDJSON with sync headers and surfaces API errors", async () => { + vi.stubEnv("PI_DEV_URL", "https://example.test"); + let request: Request | undefined; + const fetchMock: typeof fetch = async (input, init) => { + request = new Request(input, init); + return jsonResponse( + { + ok: true, + accepted: true, + received_bytes: 10, + watermark: "2026-01-02T00:00:00.000Z", + }, + 202, + ); + }; + + const response = await uploadSessionAnalytics({ + fetch: fetchMock, + accessToken: "access-1", + deviceId: "device-1", + watermark: "2026-01-02T00:00:00.000Z", + idempotencyKey: "retry-key", + body: Buffer.from("payload"), + contentEncoding: "zstd", + }); + + expect(response.accepted).toBe(true); + expect(request?.headers.get("Authorization")).toBe("Bearer access-1"); + expect(request?.headers.get("Content-Type")).toBe("application/x-ndjson"); + expect(request?.headers.get("Content-Encoding")).toBe("zstd"); + expect(request?.headers.get("Pi-Sync-Watermark")).toBe("2026-01-02T00:00:00.000Z"); + expect(request?.headers.get("Idempotency-Key")).toBe("retry-key"); + + const failingFetch: typeof fetch = async () => + jsonResponse({ ok: false, error: "invalid_payload", description: "bad line" }, 400); + await expect( + uploadSessionAnalytics({ + fetch: failingFetch, + accessToken: "access-1", + deviceId: "device-1", + watermark: "2026-01-02T00:00:00.000Z", + idempotencyKey: "retry-key", + body: Buffer.from("payload"), + contentEncoding: "zstd", + }), + ).rejects.toMatchObject({ + name: "ActivitySyncApiError", + status: 400, + errorCode: "invalid_payload", + description: "bad line", + operation: "POST /analytics/activity/:deviceId", + message: "POST /analytics/activity/:deviceId failed: invalid_payload: bad line", + } satisfies Partial); + }); +}); diff --git a/packages/coding-agent/test/activity-sync-payload.test.ts b/packages/coding-agent/test/activity-sync-payload.test.ts new file mode 100644 index 000000000..52a932bda --- /dev/null +++ b/packages/coding-agent/test/activity-sync-payload.test.ts @@ -0,0 +1,86 @@ +import { promisify } from "node:util"; +import { zstdDecompress } from "node:zlib"; +import { describe, expect, it } from "vitest"; +import { + buildActivitySyncPayloads, + serializeSessionAnalyticsNdjson, + sortSessionAnalyticsRecords, +} from "../src/core/activity-sync/payload.ts"; +import type { SessionAnalyticsRecord } from "../src/core/activity-sync/session-analytics.ts"; + +const zstdDecompressAsync = promisify(zstdDecompress); + +function session(id: string, createdAt: string): SessionAnalyticsRecord { + return { recordType: "session", schemaVersion: 1, sessionId: id, createdAt }; +} + +function entry(id: string, timestamp: string): SessionAnalyticsRecord { + return { + recordType: "entry", + schemaVersion: 1, + sessionId: "session-1", + entryId: id, + parentEntryId: null, + entryType: "model_change", + timestamp, + }; +} + +describe("activity sync payloads", () => { + it("sorts records oldest-first with sessions before entries at the same timestamp", () => { + const records = [ + entry("entry-b", "2026-01-02T00:00:00.000Z"), + session("session-1", "2026-01-01T00:00:00.000Z"), + entry("entry-a", "2026-01-01T00:00:00.000Z"), + ]; + + expect(sortSessionAnalyticsRecords(records)).toEqual([ + session("session-1", "2026-01-01T00:00:00.000Z"), + entry("entry-a", "2026-01-01T00:00:00.000Z"), + entry("entry-b", "2026-01-02T00:00:00.000Z"), + ]); + }); + + it("globally sorts, serializes, and zstd-compresses NDJSON", async () => { + const records = [entry("entry-1", "2026-01-01T00:00:01.000Z"), session("session-1", "2026-01-01T00:00:00.000Z")]; + const [payload] = await buildActivitySyncPayloads({ + records, + scanCutoff: "2026-01-02T00:00:00.000Z", + serverWatermark: null, + }); + + expect(payload.contentEncoding).toBe("zstd"); + expect(payload.watermark).toBe("2026-01-02T00:00:00.000Z"); + expect((await zstdDecompressAsync(payload.body)).toString("utf8")).toBe( + serializeSessionAnalyticsNdjson(sortSessionAnalyticsRecords(records)).toString("utf8"), + ); + }); + + it("globally sorts split payloads and uses scanCutoff only for the final batch watermark", async () => { + const records = [ + entry("new", "2026-01-03T00:00:00.000Z"), + entry("same-b", "2026-01-02T00:00:00.000Z"), + entry("old", "2026-01-01T00:00:00.000Z"), + entry("same-a", "2026-01-02T00:00:00.000Z"), + ]; + const payloads = await buildActivitySyncPayloads({ + records, + scanCutoff: "2026-01-04T00:00:00.000Z", + serverWatermark: "2026-01-01T12:00:00.000Z", + maxCompressedBytes: 400, + maxDecompressedBytes: 400, + compress: async (input) => input, + }); + + expect( + payloads.map((payload) => + payload.records.map((record) => (record.recordType === "entry" ? record.entryId : record.sessionId)), + ), + ).toEqual([["old"], ["same-a", "same-b"], ["new"]]); + expect(payloads.map((payload) => payload.watermark)).toEqual([ + "2026-01-01T12:00:00.000Z", + "2026-01-02T00:00:00.000Z", + "2026-01-04T00:00:00.000Z", + ]); + }); +}); diff --git a/packages/coding-agent/test/activity-sync-state.test.ts b/packages/coding-agent/test/activity-sync-state.test.ts new file mode 100644 index 000000000..f1482e092 --- /dev/null +++ b/packages/coding-agent/test/activity-sync-state.test.ts @@ -0,0 +1,93 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getStableActivitySyncDeviceId, + loadActivitySyncState, + saveActivitySyncState, + withActivitySyncLock, +} from "../src/core/activity-sync/state.ts"; +import { InMemorySettingsStorage, SettingsManager } from "../src/core/settings-manager.ts"; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-activity-sync-state-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("activity sync state", () => { + it("loads and saves sync state", async () => { + const agentDir = createTempDir(); + await saveActivitySyncState({ lastAttemptAt: "2026-01-01T00:00:00.000Z" }, agentDir); + + expect(await loadActivitySyncState(agentDir)).toEqual({ + lastAttemptAt: "2026-01-01T00:00:00.000Z", + lastSuccessAt: undefined, + }); + }); + + it("stores stable sync settings under piDev", () => { + const settings = SettingsManager.inMemory({ + piDev: { activitySync: { intervalHours: 12 } }, + }); + const first = getStableActivitySyncDeviceId(settings); + const second = getStableActivitySyncDeviceId(settings); + settings.setActivitySyncEnabled(true); + + expect(first).toBe(second); + expect(first).toMatch(/^[0-9a-f-]{36}$/); + expect(settings.getGlobalSettings().piDev?.activitySync?.deviceId).toBe(first); + expect(settings.getActivitySyncSettings()).toEqual({ + enabled: true, + intervalHours: 24, + }); + expect(settings.getGlobalSettings().piDev?.activitySync).toEqual({ + deviceId: first, + enabled: true, + intervalHours: 24, + }); + }); + + it("ignores project-local activity sync overrides", () => { + const globalDeviceId = "00000000-0000-4000-8000-000000000001"; + const projectDeviceId = "00000000-0000-4000-8000-000000000002"; + const storage = new InMemorySettingsStorage(); + storage.withLock("global", () => + JSON.stringify({ + piDev: { activitySync: { deviceId: globalDeviceId, enabled: false, intervalHours: 12 } }, + telemetry: { enabled: true }, + }), + ); + storage.withLock("project", () => + JSON.stringify({ + piDev: { activitySync: { deviceId: projectDeviceId, enabled: true, intervalHours: 1 } }, + }), + ); + const settings = SettingsManager.fromStorage(storage, { projectTrusted: true }); + + expect(settings.getActivitySyncDeviceId()).toBe(globalDeviceId); + expect(settings.getActivitySyncSettings()).toEqual({ enabled: false, intervalHours: 12 }); + }); + + it("returns already_running when the sync lock is held", async () => { + const agentDir = createTempDir(); + const result = await withActivitySyncLock( + async () => withActivitySyncLock(async () => "inner", agentDir), + agentDir, + ); + + expect(result).toEqual({ + status: "acquired", + result: { status: "already_running" }, + }); + }); +}); diff --git a/packages/coding-agent/test/activity-sync.test.ts b/packages/coding-agent/test/activity-sync.test.ts new file mode 100644 index 000000000..64e1495b1 --- /dev/null +++ b/packages/coding-agent/test/activity-sync.test.ts @@ -0,0 +1,235 @@ +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { syncSessionAnalytics } from "../src/core/activity-sync/activity-sync.ts"; +import { loadActivitySyncState } from "../src/core/activity-sync/state.ts"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { SettingsManager } from "../src/core/settings-manager.ts"; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-activity-sync-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { "Content-Type": "application/json" }, + }); +} + +function writeSessionFile(sessionsRoot: string): void { + const sessionDir = join(sessionsRoot, "default"); + mkdirSync(sessionDir, { recursive: true }); + writeFileSync( + join(sessionDir, "session-1.jsonl"), + `${[ + { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/tmp", + }, + { + type: "model_change", + id: "entry-1", + parentId: null, + timestamp: "2026-01-01T00:00:01.000Z", + provider: "openai", + modelId: "gpt-4.1", + }, + ] + .map((record) => JSON.stringify(record)) + .join("\n")}\n`, + ); +} + +function createActivitySyncAuthStorage(refresh = "refresh-1"): AuthStorage { + return AuthStorage.inMemory({ + "pi.dev": { + type: "oauth", + access: "access-old", + refresh, + expires: Date.now() - 1, + scope: "activity_sync offline_access", + }, + }); +} + +describe("syncSessionAnalytics", () => { + it("returns not_authenticated after recording lastAttemptAt", async () => { + const agentDir = createTempDir(); + const result = await syncSessionAnalytics({ + agentDir, + settingsManager: SettingsManager.inMemory(), + now: new Date("2026-01-01T00:00:00.000Z"), + }); + + expect(result).toEqual({ status: "not_authenticated" }); + expect(await loadActivitySyncState(agentDir)).toMatchObject({ + lastAttemptAt: "2026-01-01T00:00:00.000Z", + }); + }); + + it("updates lastAttemptAt on no_changes", async () => { + const agentDir = createTempDir(); + const sessionsRoot = createTempDir(); + const authStorage = createActivitySyncAuthStorage(); + const fetchMock: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if (request.url.endsWith("/api/oauth/token")) { + return jsonResponse({ + token_type: "Bearer", + access_token: "access-1", + refresh_token: "refresh-2", + expires_in: 86400, + scope: "activity_sync offline_access", + }); + } + return jsonResponse({ ok: true, watermark: null }); + }; + + const result = await syncSessionAnalytics({ + agentDir, + sessionsRoot, + settingsManager: SettingsManager.inMemory(), + authStorage, + fetch: fetchMock, + now: new Date("2026-01-02T00:00:00.000Z"), + }); + + expect(result).toMatchObject({ + status: "no_changes", + filesScanned: 0, + serverWatermark: null, + }); + expect(await loadActivitySyncState(agentDir)).toMatchObject({ + lastAttemptAt: "2026-01-02T00:00:00.000Z", + }); + expect(authStorage.get("pi.dev")).toMatchObject({ refresh: "refresh-2" }); + }); + + it("clears stale pi.dev credentials that the server rejects", async () => { + const agentDir = createTempDir(); + const authStorage = createActivitySyncAuthStorage(); + const fetchMock: typeof fetch = async () => + jsonResponse({ error: "invalid_grant", description: "stale refresh token" }, 400); + + const result = await syncSessionAnalytics({ + agentDir, + settingsManager: SettingsManager.inMemory(), + authStorage, + fetch: fetchMock, + now: new Date("2026-01-02T00:00:00.000Z"), + }); + + expect(result).toEqual({ status: "not_authenticated" }); + expect(authStorage.get("pi.dev")).toBeUndefined(); + expect(await loadActivitySyncState(agentDir)).toMatchObject({ + lastAttemptAt: "2026-01-02T00:00:00.000Z", + }); + }); + + it("uploads with an idempotency key without persisting payload files", async () => { + const agentDir = createTempDir(); + const sessionsRoot = createTempDir(); + writeSessionFile(sessionsRoot); + const authStorage = createActivitySyncAuthStorage(); + const idempotencyKeys: string[] = []; + const fetchMock: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if (request.url.endsWith("/api/oauth/token")) { + return jsonResponse({ + token_type: "Bearer", + access_token: "access-1", + refresh_token: "refresh-2", + expires_in: 86400, + scope: "activity_sync offline_access", + }); + } + if (request.method === "GET") return jsonResponse({ ok: true, watermark: null }); + idempotencyKeys.push(request.headers.get("Idempotency-Key") ?? ""); + expect((await request.arrayBuffer()).byteLength).toBeGreaterThan(0); + return jsonResponse({ + ok: true, + accepted: true, + received_bytes: 21, + watermark: "2026-01-02T00:00:00.000Z", + }); + }; + + const result = await syncSessionAnalytics({ + agentDir, + sessionsRoot, + settingsManager: SettingsManager.inMemory(), + authStorage, + fetch: fetchMock, + now: new Date("2026-01-03T00:00:00.000Z"), + }); + + expect(result).toMatchObject({ + status: "uploaded", + recordsSent: 2, + serverWatermark: null, + watermark: "2026-01-02T00:00:00.000Z", + }); + expect(idempotencyKeys).toHaveLength(1); + expect(idempotencyKeys[0]).toMatch(/^[0-9a-f-]{36}$/); + expect(existsSync(join(agentDir, "activity-sync-payloads"))).toBe(false); + expect(await loadActivitySyncState(agentDir)).toMatchObject({ + lastSuccessAt: "2026-01-03T00:00:00.000Z", + }); + expect(authStorage.get("pi.dev")).toMatchObject({ refresh: "refresh-2" }); + }); + + it("includes the server watermark when upload fails", async () => { + const agentDir = createTempDir(); + const sessionsRoot = createTempDir(); + writeSessionFile(sessionsRoot); + const authStorage = createActivitySyncAuthStorage(); + const fetchMock: typeof fetch = async (input, init) => { + const request = new Request(input, init); + if (request.url.endsWith("/api/oauth/token")) { + return jsonResponse({ + token_type: "Bearer", + access_token: "access-1", + refresh_token: "refresh-2", + expires_in: 86400, + scope: "activity_sync offline_access", + }); + } + if (request.method === "GET") + return jsonResponse({ + ok: true, + watermark: "2026-01-01T00:00:00.000Z", + }); + return jsonResponse({}, 503); + }; + + const result = await syncSessionAnalytics({ + agentDir, + sessionsRoot, + settingsManager: SettingsManager.inMemory(), + authStorage, + fetch: fetchMock, + now: new Date("2026-01-03T00:00:00.000Z"), + }); + + expect(result).toMatchObject({ + status: "failed", + serverWatermark: "2026-01-01T00:00:00.000Z", + error: "POST /analytics/activity/:deviceId failed: HTTP 503", + }); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-share-command.test.ts b/packages/coding-agent/test/interactive-mode-share-command.test.ts new file mode 100644 index 000000000..86b6ffa06 --- /dev/null +++ b/packages/coding-agent/test/interactive-mode-share-command.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import type { ShareCommandMode } from "../src/core/pi-dev/index.ts"; +import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts"; + +type PiDevAuthOptions = { title: string; deviceId?: string; forceLogin?: boolean }; + +type ShareCommandContext = { + session: { modelRegistry: { authStorage: AuthStorage } }; + ensurePiDevAuthenticated: ( + requiredScopes: readonly string[], + options: PiDevAuthOptions, + ) => Promise; + handleGitHubShareCommand: () => Promise; + handlePiDevShareCommand: (accessToken: string, mode: ShareCommandMode) => Promise; + showError: (message: string) => void; + showStatus: (message: string) => void; +}; + +type InteractiveModePrototype = { + handleShareCommand(this: ShareCommandContext, text: string): Promise; +}; + +const interactiveModePrototype = InteractiveMode.prototype as unknown as InteractiveModePrototype; + +function createContext(authStorage: AuthStorage): ShareCommandContext { + return { + session: { modelRegistry: { authStorage } }, + ensurePiDevAuthenticated: vi.fn(async () => "piga_login"), + handleGitHubShareCommand: vi.fn(async () => {}), + handlePiDevShareCommand: vi.fn(async () => {}), + showError: vi.fn(), + showStatus: vi.fn(), + }; +} + +describe("InteractiveMode /share", () => { + it("falls back to GitHub gist for default shares when pi.dev is not authenticated", async () => { + const context = createContext(AuthStorage.inMemory()); + + await interactiveModePrototype.handleShareCommand.call(context, "/share"); + + expect(context.handleGitHubShareCommand).toHaveBeenCalledTimes(1); + expect(context.ensurePiDevAuthenticated).not.toHaveBeenCalled(); + expect(context.handlePiDevShareCommand).not.toHaveBeenCalled(); + }); + + it("uses pi.dev for default shares when pi.dev auth is available", async () => { + const context = createContext( + AuthStorage.inMemory({ + "pi.dev": { + type: "oauth", + access: "piga_share", + refresh: "pigr_share", + expires: Date.now() + 60_000, + scope: "session_share offline_access", + }, + }), + ); + + await interactiveModePrototype.handleShareCommand.call(context, "/share"); + + expect(context.handlePiDevShareCommand).toHaveBeenCalledWith("piga_share", "auto"); + expect(context.ensurePiDevAuthenticated).not.toHaveBeenCalled(); + expect(context.handleGitHubShareCommand).not.toHaveBeenCalled(); + }); + + it("keeps explicit pi.dev shares as an authenticated pi.dev flow", async () => { + const context = createContext(AuthStorage.inMemory()); + + await interactiveModePrototype.handleShareCommand.call(context, "/share pi.dev"); + + expect(context.ensurePiDevAuthenticated).toHaveBeenCalledWith(["session_share"], { + title: "Create pi.dev profile to share sessions", + }); + expect(context.handlePiDevShareCommand).toHaveBeenCalledWith("piga_login", "pi.dev"); + expect(context.handleGitHubShareCommand).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/coding-agent/test/session-analytics-reader.test.ts b/packages/coding-agent/test/session-analytics-reader.test.ts new file mode 100644 index 000000000..d7240a841 --- /dev/null +++ b/packages/coding-agent/test/session-analytics-reader.test.ts @@ -0,0 +1,83 @@ +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { buildSessionAnalyticsUpload } from "../src/core/activity-sync/session-analytics-reader.ts"; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-session-analytics-reader-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function writeJsonl(path: string, lines: unknown[]): void { + writeFileSync(path, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); +} + +function sessionHeader(id: string, timestamp: string): unknown { + return { type: "session", version: 3, id, timestamp, cwd: `/work/${id}` }; +} + +function entry(id: string, timestamp: string): unknown { + return { type: "model_change", id, parentId: null, timestamp, provider: "anthropic", modelId: "model" }; +} + +describe("buildSessionAnalyticsUpload", () => { + it("selects files by mtime and includes old records before the scan cutoff", async () => { + const root = createTempDir(); + const project = join(root, "--project--"); + mkdirSync(project, { recursive: true }); + const oldFile = join(project, "old.jsonl"); + const changedFile = join(project, "changed.jsonl"); + writeJsonl(oldFile, [ + sessionHeader("old", "2026-01-01T00:00:00.000Z"), + entry("old-entry", "2026-01-01T00:00:01.000Z"), + ]); + writeJsonl(changedFile, [ + sessionHeader("changed", "2026-01-01T00:00:00.000Z"), + entry("included-old-entry", "2026-01-01T00:00:01.000Z"), + entry("future-entry", "2026-01-03T00:00:00.000Z"), + ]); + utimesSync(oldFile, new Date("2026-01-01T00:10:00.000Z"), new Date("2026-01-01T00:10:00.000Z")); + utimesSync(changedFile, new Date("2026-01-02T00:10:00.000Z"), new Date("2026-01-02T00:10:00.000Z")); + + const result = await buildSessionAnalyticsUpload({ + sessionsRoot: root, + serverWatermark: "2026-01-02T00:00:00.000Z", + scanCutoff: new Date("2026-01-02T12:00:00.000Z"), + }); + + expect(result.filesScanned).toBe(1); + expect(result.malformedFiles).toBe(0); + expect(result.scanCutoff).toBe("2026-01-02T12:00:00.000Z"); + expect( + result.records.map((record) => (record.recordType === "entry" ? record.entryId : record.sessionId)), + ).toEqual(["changed", "included-old-entry"]); + }); + + it("skips malformed selected files", async () => { + const root = createTempDir(); + const project = join(root, "--project--"); + mkdirSync(project, { recursive: true }); + const malformed = join(project, "malformed.jsonl"); + writeFileSync(malformed, `${JSON.stringify(sessionHeader("bad", "2026-01-01T00:00:00.000Z"))}\nnot json\n`); + + const result = await buildSessionAnalyticsUpload({ + sessionsRoot: root, + serverWatermark: null, + scanCutoff: new Date("2026-01-02T00:00:00.000Z"), + }); + + expect(result.records).toEqual([]); + expect(result.filesScanned).toBe(1); + expect(result.malformedFiles).toBe(1); + }); +}); diff --git a/packages/coding-agent/test/session-analytics.test.ts b/packages/coding-agent/test/session-analytics.test.ts new file mode 100644 index 000000000..4c750f8f6 --- /dev/null +++ b/packages/coding-agent/test/session-analytics.test.ts @@ -0,0 +1,276 @@ +import { describe, expect, it } from "vitest"; +import { + projectSessionForAnalytics, + SESSION_ANALYTICS_SCHEMA_VERSION, + type SessionAnalyticsRecord, +} from "../src/core/activity-sync/session-analytics.ts"; +import type { SessionEntry, SessionHeader } from "../src/core/session-manager.ts"; + +const header: SessionHeader = { + type: "session", + version: 3, + id: "session-1", + timestamp: "2026-01-02T03:04:05.000Z", + cwd: "/tmp/project", + parentSession: "/tmp/parent.jsonl", +}; + +const entries: SessionEntry[] = [ + { + type: "model_change", + id: "model-1", + parentId: null, + timestamp: "2026-01-02T03:04:06.000Z", + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }, + { + type: "message", + id: "user-1", + parentId: "model-1", + timestamp: "2026-01-02T03:04:07.000Z", + message: { + role: "user", + content: [ + { type: "text", text: "secret user prompt" }, + { type: "image", data: "secret-image-data", mimeType: "image/png" }, + ], + timestamp: 1767323047000, + }, + }, + { + type: "message", + id: "assistant-1", + parentId: "user-1", + timestamp: "2026-01-02T03:04:08.000Z", + message: { + role: "assistant", + content: [ + { type: "text", text: "secret assistant answer" }, + { type: "thinking", thinking: "secret reasoning" }, + { type: "thinking", thinking: "", thinkingSignature: "secret-signature", redacted: true }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "secret/path.ts" } }, + ], + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + responseModel: "claude-sonnet-4-5-20260101", + responseId: "secret-response-id", + usage: { + input: 100, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 190, + cost: { + input: 0.1, + output: 0.2, + cacheRead: 0.03, + cacheWrite: 0.04, + total: 0.37, + }, + }, + stopReason: "toolUse", + timestamp: 1767323048000, + }, + }, + { + type: "message", + id: "tool-result-1", + parentId: "assistant-1", + timestamp: "2026-01-02T03:04:09.000Z", + message: { + role: "toolResult", + toolCallId: "call-1", + toolName: "read", + content: [{ type: "text", text: "secret tool result" }], + isError: true, + timestamp: 1767323049000, + }, + }, + { + type: "compaction", + id: "compaction-1", + parentId: "tool-result-1", + timestamp: "2026-01-02T03:04:10.000Z", + summary: "secret compaction summary", + firstKeptEntryId: "user-1", + tokensBefore: 1234, + details: { secret: "compaction details" }, + fromHook: true, + }, + { + type: "label", + id: "label-1", + parentId: "compaction-1", + timestamp: "2026-01-02T03:04:11.000Z", + targetId: "assistant-1", + label: "secret label", + }, + { + type: "session_info", + id: "session-info-1", + parentId: "label-1", + timestamp: "2026-01-02T03:04:12.000Z", + name: "secret session name", + }, +]; + +describe("projectSessionForAnalytics", () => { + it("projects a complete session into ordered analytics records", () => { + const records = projectSessionForAnalytics(header, entries, { + modifiedAt: "2026-01-03T04:05:06.000Z", + hashString: (value) => `hashed:${value}`, + }); + + expect(records).toEqual([ + { + recordType: "session", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + version: 3, + createdAt: "2026-01-02T03:04:05.000Z", + modifiedAt: "2026-01-03T04:05:06.000Z", + parentSessionHash: "hashed:/tmp/parent.jsonl", + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "model-1", + parentEntryId: null, + entryType: "model_change", + timestamp: "2026-01-02T03:04:06.000Z", + provider: "anthropic", + modelId: "claude-sonnet-4-5", + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "user-1", + parentEntryId: "model-1", + entryType: "message", + timestamp: "2026-01-02T03:04:07.000Z", + role: "user", + contentStats: { + stringContent: false, + textBlocks: 1, + imageBlocks: 1, + thinkingBlocks: 0, + redactedThinkingBlocks: 0, + toolCallBlocks: 0, + otherBlocks: 0, + }, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "assistant-1", + parentEntryId: "user-1", + entryType: "message", + timestamp: "2026-01-02T03:04:08.000Z", + role: "assistant", + api: "anthropic-messages", + provider: "anthropic", + model: "claude-sonnet-4-5", + responseModel: "claude-sonnet-4-5-20260101", + stopReason: "toolUse", + hasError: false, + usage: { + input: 100, + output: 20, + cacheRead: 30, + cacheWrite: 40, + totalTokens: 190, + costInput: 0.1, + costOutput: 0.2, + costCacheRead: 0.03, + costCacheWrite: 0.04, + costTotal: 0.37, + }, + contentStats: { + stringContent: false, + textBlocks: 1, + imageBlocks: 0, + thinkingBlocks: 2, + redactedThinkingBlocks: 1, + toolCallBlocks: 1, + otherBlocks: 0, + }, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "tool-result-1", + parentEntryId: "assistant-1", + entryType: "message", + timestamp: "2026-01-02T03:04:09.000Z", + role: "toolResult", + isError: true, + contentStats: { + stringContent: false, + textBlocks: 1, + imageBlocks: 0, + thinkingBlocks: 0, + redactedThinkingBlocks: 0, + toolCallBlocks: 0, + otherBlocks: 0, + }, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "compaction-1", + parentEntryId: "tool-result-1", + entryType: "compaction", + timestamp: "2026-01-02T03:04:10.000Z", + firstKeptEntryId: "user-1", + tokensBefore: 1234, + fromHook: true, + hasDetails: true, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "label-1", + parentEntryId: "compaction-1", + entryType: "label", + timestamp: "2026-01-02T03:04:11.000Z", + targetId: "assistant-1", + hasLabel: true, + }, + { + recordType: "entry", + schemaVersion: SESSION_ANALYTICS_SCHEMA_VERSION, + sessionId: "session-1", + entryId: "session-info-1", + parentEntryId: "label-1", + entryType: "session_info", + timestamp: "2026-01-02T03:04:12.000Z", + hasName: true, + }, + ] satisfies SessionAnalyticsRecord[]); + }); + + it("omits raw private payload fields", () => { + const serialized = JSON.stringify(projectSessionForAnalytics(header, entries)); + + expect(serialized).not.toContain("secret user prompt"); + expect(serialized).not.toContain("secret assistant answer"); + expect(serialized).not.toContain("secret reasoning"); + expect(serialized).not.toContain("secret-image-data"); + expect(serialized).not.toContain("secret/path.ts"); + expect(serialized).not.toContain("secret tool result"); + expect(serialized).not.toContain("secret compaction summary"); + expect(serialized).not.toContain("compaction details"); + expect(serialized).not.toContain("secret label"); + expect(serialized).not.toContain("secret session name"); + expect(serialized).not.toContain("secret-response-id"); + expect(serialized).not.toContain("/tmp/project"); + }); +}); diff --git a/packages/coding-agent/test/session-discovery.test.ts b/packages/coding-agent/test/session-discovery.test.ts new file mode 100644 index 000000000..be7990154 --- /dev/null +++ b/packages/coding-agent/test/session-discovery.test.ts @@ -0,0 +1,98 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "fs"; +import { tmpdir } from "os"; +import { basename, join, relative } from "path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + discoverSessionFiles, + discoverSessions, + type SessionDiscoveryProgress, +} from "../src/core/activity-sync/session-discovery.ts"; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-session-discovery-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +function writeJsonl(path: string, lines: unknown[]): void { + writeFileSync(path, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`); +} + +describe("session discovery", () => { + it("finds session jsonl files recursively", async () => { + const root = createTempDir(); + const projectA = join(root, "--project-a--"); + const projectB = join(root, "--project-b--", "nested"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + writeJsonl(join(projectA, "a.jsonl"), [ + { type: "session", version: 3, id: "a", timestamp: "2026-01-01T00:00:00.000Z", cwd: "/a" }, + ]); + writeJsonl(join(projectB, "b.jsonl"), [ + { type: "session", version: 3, id: "b", timestamp: "2026-01-02T00:00:00.000Z", cwd: "/b" }, + ]); + writeFileSync(join(projectA, "notes.txt"), "not a session"); + + const files = await discoverSessionFiles({ sessionsRoot: root }); + + expect(files.map((file) => relative(root, file))).toEqual([ + join("--project-a--", "a.jsonl"), + join("--project-b--", "nested", "b.jsonl"), + ]); + }); + + it("returns metadata for valid sessions and skips invalid jsonl files", async () => { + const root = createTempDir(); + const projectA = join(root, "--project-a--"); + const projectB = join(root, "--project-b--"); + mkdirSync(projectA, { recursive: true }); + mkdirSync(projectB, { recursive: true }); + writeJsonl(join(projectA, "a.jsonl"), [ + { + type: "session", + version: 3, + id: "session-a", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/work/a", + parentSession: "/parent/session.jsonl", + }, + ]); + writeJsonl(join(projectB, "b.jsonl"), [ + { type: "session", version: 3, id: "session-b", timestamp: "2026-01-02T00:00:00.000Z", cwd: "/work/b" }, + ]); + writeFileSync(join(projectB, "invalid.jsonl"), "not json\n"); + + const progress: SessionDiscoveryProgress[] = []; + const sessions = await discoverSessions({ sessionsRoot: root, onProgress: (update) => progress.push(update) }); + + expect(sessions.map((session) => session.sessionId)).toEqual(["session-a", "session-b"]); + expect(sessions[0]).toMatchObject({ + path: join(projectA, "a.jsonl"), + relativePath: join("--project-a--", "a.jsonl"), + sessionDir: projectA, + sessionDirName: basename(projectA), + sessionId: "session-a", + cwd: "/work/a", + header: { + type: "session", + version: 3, + id: "session-a", + timestamp: "2026-01-01T00:00:00.000Z", + cwd: "/work/a", + parentSession: "/parent/session.jsonl", + }, + }); + expect(sessions[0].createdAt?.toISOString()).toBe("2026-01-01T00:00:00.000Z"); + expect(sessions[0].modifiedAt).toBeInstanceOf(Date); + expect(sessions[0].sizeBytes).toBeGreaterThan(0); + expect(progress.at(-1)).toMatchObject({ phase: "read", foundFiles: 3, processedFiles: 3, sessions: 2 }); + }); +}); diff --git a/packages/coding-agent/test/session-share.test.ts b/packages/coding-agent/test/session-share.test.ts new file mode 100644 index 000000000..7d92a1df8 --- /dev/null +++ b/packages/coding-agent/test/session-share.test.ts @@ -0,0 +1,226 @@ +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { AuthStorage } from "../src/core/auth-storage.ts"; +import { + formatPiDevShareSuccess, + getPiDevAuth, + getPiDevBaseUrl, + getPiDevShareAuth, + loginPiDevShare, + PI_DEV_SESSION_SHARE_SCOPE, + parseShareCommand, + uploadPiDevSessionShare, +} from "../src/core/pi-dev/index.ts"; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-session-share-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("session share client", () => { + it("parses share modes", () => { + expect(parseShareCommand("/share")).toEqual({ ok: true, mode: "auto" }); + expect(parseShareCommand("/share pi.dev")).toEqual({ ok: true, mode: "pi.dev" }); + expect(parseShareCommand("/share github")).toEqual({ ok: true, mode: "github" }); + expect(parseShareCommand("/share nope")).toEqual({ ok: false, message: "Usage: /share [pi.dev|github]" }); + }); + + it("treats missing pi.dev auth as unavailable", async () => { + const authStorage = AuthStorage.inMemory(); + await expect(getPiDevShareAuth(authStorage)).resolves.toEqual({ available: false, reason: "unauthenticated" }); + }); + + it("treats pi.dev auth without session_share scope as unavailable", async () => { + const authStorage = AuthStorage.inMemory({ + "pi.dev": { + type: "oauth", + access: "piga_old", + refresh: "pigr_old", + expires: Date.now() + 60_000, + scope: "offline_access", + }, + }); + + await expect(getPiDevShareAuth(authStorage)).resolves.toEqual({ available: false, reason: "missing_scope" }); + }); + + it("accepts pi.dev auth with session_share scope", async () => { + const authStorage = AuthStorage.inMemory({ + "pi.dev": { + type: "oauth", + access: "piga_share", + refresh: "pigr_share", + expires: Date.now() + 60_000, + scope: "session_share offline_access", + }, + }); + + await expect(getPiDevShareAuth(authStorage)).resolves.toEqual({ available: true, accessToken: "piga_share" }); + }); + + it("refreshes expired pi.dev share tokens without a pi-ai OAuth provider", async () => { + vi.stubEnv("PI_DEV_URL", "http://127.0.0.1:8787/"); + const authStorage = AuthStorage.inMemory({ + "pi.dev": { + type: "oauth", + access: "piga_old", + refresh: "pigr_old", + expires: Date.now() - 1, + scope: "session_share offline_access", + }, + }); + const fetchMock = vi.fn(async (_input: string | URL, init?: RequestInit) => { + if (!(init?.body instanceof URLSearchParams)) { + throw new Error("Expected form body"); + } + expect(init.body.get("grant_type")).toBe("refresh_token"); + expect(init.body.get("client_id")).toBe("pi-coding-agent"); + expect(init.body.get("refresh_token")).toBe("pigr_old"); + return Response.json({ + access_token: "piga_new", + refresh_token: "pigr_new", + expires_in: 86400, + scope: "session_share offline_access", + }); + }); + vi.stubGlobal("fetch", fetchMock); + + await expect(getPiDevShareAuth(authStorage)).resolves.toEqual({ available: true, accessToken: "piga_new" }); + expect(fetchMock).toHaveBeenCalledWith("http://127.0.0.1:8787/api/oauth/token", expect.any(Object)); + expect(authStorage.get("pi.dev")).toMatchObject({ access: "piga_new", refresh: "pigr_new" }); + }); + + it("serializes expired pi.dev token refreshes across auth storage instances", async () => { + vi.stubEnv("PI_DEV_URL", "http://127.0.0.1:8787/"); + const authPath = join(createTempDir(), "auth.json"); + const firstAuthStorage = AuthStorage.create(authPath); + firstAuthStorage.set("pi.dev", { + type: "oauth", + access: "piga_old", + refresh: "pigr_old", + expires: Date.now() - 1, + scope: "session_share offline_access", + }); + const secondAuthStorage = AuthStorage.create(authPath); + let refreshCalls = 0; + const fetchMock: typeof fetch = async () => { + const call = ++refreshCalls; + if (call === 1) { + await new Promise((resolve) => setTimeout(resolve, 25)); + } + return Response.json({ + access_token: `piga_new_${call}`, + refresh_token: `pigr_new_${call}`, + expires_in: 86400, + scope: "session_share offline_access", + }); + }; + + const [first, second] = await Promise.all([ + getPiDevAuth(firstAuthStorage, [PI_DEV_SESSION_SHARE_SCOPE], { fetch: fetchMock }), + getPiDevAuth(secondAuthStorage, [PI_DEV_SESSION_SHARE_SCOPE], { fetch: fetchMock }), + ]); + + expect(first).toEqual({ available: true, accessToken: "piga_new_1" }); + expect(second).toEqual({ available: true, accessToken: "piga_new_1" }); + expect(refreshCalls).toBe(1); + }); + + it("runs pi.dev share device auth without a pi-ai OAuth provider", async () => { + vi.stubEnv("PI_DEV_URL", "http://127.0.0.1:8787/"); + const authStorage = AuthStorage.inMemory(); + const fetchMock = vi.fn(async (input: string | URL, init?: RequestInit) => { + if (!(init?.body instanceof URLSearchParams)) { + throw new Error("Expected form body"); + } + if (input === "http://127.0.0.1:8787/api/oauth/device") { + expect(init.body.get("client_id")).toBe("pi-coding-agent"); + expect(init.body.get("scope")).toBe("session_share offline_access"); + return Response.json({ + device_code: "pigd_123", + user_code: "ABCD-EFGH", + verification_uri: "http://127.0.0.1:8787/pair", + verification_uri_complete: "http://127.0.0.1:8787/pair?code=ABCD-EFGH", + expires_in: 300, + interval: 1, + }); + } + if (input === "http://127.0.0.1:8787/api/oauth/token") { + expect(init.body.get("grant_type")).toBe("urn:ietf:params:oauth:grant-type:device_code"); + expect(init.body.get("client_id")).toBe("pi-coding-agent"); + expect(init.body.get("device_code")).toBe("pigd_123"); + return Response.json({ + access_token: "piga_new", + refresh_token: "pigr_new", + expires_in: 86400, + scope: "session_share offline_access", + }); + } + throw new Error(`Unexpected fetch URL: ${String(input)}`); + }); + vi.stubGlobal("fetch", fetchMock); + const deviceCodes: Array<{ userCode: string; verificationUri: string }> = []; + + const credential = await loginPiDevShare(authStorage, { + onDeviceCode: (info) => deviceCodes.push({ userCode: info.userCode, verificationUri: info.verificationUri }), + }); + + expect(credential.access).toBe("piga_new"); + expect(authStorage.get("pi.dev")).toMatchObject({ access: "piga_new", refresh: "pigr_new" }); + expect(deviceCodes).toEqual([ + { userCode: "ABCD-EFGH", verificationUri: "http://127.0.0.1:8787/pair?code=ABCD-EFGH" }, + ]); + }); + + it("uploads HTML to the pi.dev share endpoint", async () => { + const bytes = Buffer.from("ok"); + const calls: Array> = []; + const fetchFn: typeof fetch = async (input, init) => { + calls.push([input, init]); + return Response.json({ ok: true, id: "psh_123", url: "https://pi.dev/session/#pi/psh_123" }, { status: 201 }); + }; + + const result = await uploadPiDevSessionShare({ + accessToken: "piga_share", + bytes, + byteSize: bytes.byteLength, + fetchFn, + }); + + expect(result).toEqual({ id: "psh_123", url: "https://pi.dev/session/#pi/psh_123" }); + expect(calls).toHaveLength(1); + const [url, init] = calls[0]!; + expect(url).toBe("https://pi.dev/api/session-shares"); + expect(init?.method).toBe("POST"); + expect(init?.headers).toEqual({ + Authorization: "Bearer piga_share", + "Content-Type": "text/html; charset=utf-8", + "Content-Length": String(bytes.byteLength), + }); + expect(init?.body).toBe(bytes); + }); + + it("uses PI_DEV_URL for pi.dev API base URL", () => { + vi.stubEnv("PI_DEV_URL", "http://127.0.0.1:8787/"); + expect(getPiDevBaseUrl()).toBe("http://127.0.0.1:8787"); + }); + + it("formats successful pi.dev share output with unlisted warning", () => { + expect(formatPiDevShareSuccess("https://pi.dev/session/#pi/psh_123")).toBe( + "Share URL: https://pi.dev/session/#pi/psh_123\nStored on pi.dev as an unlisted session share.\nAnyone with this link can view it.", + ); + }); +}); diff --git a/packages/coding-agent/test/setup-state.test.ts b/packages/coding-agent/test/setup-state.test.ts new file mode 100644 index 000000000..10d22d5be --- /dev/null +++ b/packages/coding-agent/test/setup-state.test.ts @@ -0,0 +1,61 @@ +import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { + getPendingSetupStepIds, + hasPendingSetupSteps, + markSetupStepComplete, + readSetupState, +} from "../src/core/setup-state.ts"; + +const tempDirs: string[] = []; + +function createTempDir(): string { + const dir = mkdtempSync(join(tmpdir(), "pi-setup-state-")); + tempDirs.push(dir); + return dir; +} + +afterEach(() => { + for (const dir of tempDirs.splice(0)) { + rmSync(dir, { recursive: true, force: true }); + } +}); + +describe("setup state", () => { + it("prompts new users for theme before pi.dev profiles", () => { + const agentDir = createTempDir(); + + expect(getPendingSetupStepIds(agentDir)).toEqual(["theme", "pi-dev-profile"]); + }); + + it("skips the theme setup step when a theme is already configured", () => { + const agentDir = createTempDir(); + + expect(getPendingSetupStepIds(agentDir, { themeConfigured: true })).toEqual(["pi-dev-profile"]); + }); + + it("prompts existing setup users for theme and pi.dev profile steps", () => { + const agentDir = createTempDir(); + writeFileSync( + join(agentDir, "setup.json"), + JSON.stringify({ + schemaVersion: 1, + completedVersion: 1, + steps: { + telemetry: { completedAt: "2026-01-01T00:00:00.000Z", setupVersion: 1 }, + login: { completedAt: "2026-01-01T00:00:00.000Z", setupVersion: 1 }, + }, + }), + ); + + expect(getPendingSetupStepIds(agentDir)).toEqual(["theme", "pi-dev-profile"]); + + markSetupStepComplete("theme", agentDir, new Date("2026-01-02T00:00:00.000Z")); + markSetupStepComplete("pi-dev-profile", agentDir, new Date("2026-01-02T00:00:00.000Z")); + + expect(hasPendingSetupSteps(agentDir)).toBe(false); + expect(readSetupState(agentDir)?.completedVersion).toBe(1); + }); +}); diff --git a/packages/coding-agent/test/slash-commands.test.ts b/packages/coding-agent/test/slash-commands.test.ts new file mode 100644 index 000000000..e7755bf0e --- /dev/null +++ b/packages/coding-agent/test/slash-commands.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from "vitest"; +import { BUILTIN_SLASH_COMMANDS } from "../src/core/slash-commands.ts"; + +describe("slash commands", () => { + it("exposes pi.dev as a slash command", () => { + expect(BUILTIN_SLASH_COMMANDS.map((command) => command.name)).toContain("pi.dev"); + }); + + it("does not expose activity sync as a slash command", () => { + expect(BUILTIN_SLASH_COMMANDS.map((command) => command.name)).not.toContain("activity-sync"); + }); +});