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.
This commit is contained in:
Vegard Stikbakke
2026-06-09 09:26:10 +02:00
parent 20b78eafb4
commit 71918c6575
42 changed files with 5097 additions and 88 deletions
+4 -2
View File
@@ -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 <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.
+1 -1
View File
@@ -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
+26
View File
@@ -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 |
+17 -4
View File
@@ -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 <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 |
+9 -2
View File
@@ -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} <command> --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 <name> 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
@@ -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<StartupSetupResult> {
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();
}
}
@@ -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<string | undefined> {
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<ActivitySyncPayload, "watermark" | "contentEncoding" | "body">,
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<ActivitySyncResult> {
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<ActivitySyncResult> {
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;
}
@@ -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<ActivitySyncDeviceFlowResponse> {
return startPiDevDeviceFlow({
...options,
deviceId,
scopes: [PI_DEV_ACTIVITY_SYNC_SCOPE],
errorClass: ActivitySyncApiError,
});
}
export async function pollActivitySyncDeviceToken(
deviceCode: string,
options: ActivitySyncApiOptions = {},
): Promise<ActivitySyncTokenResponse> {
return pollPiDevDeviceToken(deviceCode, {
...options,
errorClass: ActivitySyncApiError,
});
}
export async function refreshActivitySyncAccessToken(
refreshToken: string,
options: ActivitySyncApiOptions = {},
): Promise<ActivitySyncTokenResponse> {
return refreshPiDevAccessToken(refreshToken, {
...options,
errorClass: ActivitySyncApiError,
});
}
export async function getActivitySyncWatermark(
accessToken: string,
deviceId: string,
options: ActivitySyncApiOptions = {},
): Promise<ActivitySyncWatermarkResponse> {
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<ActivitySyncUploadResponse> {
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));
}
@@ -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";
@@ -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<Buffer>;
}
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<Buffer>) | undefined,
): Promise<Buffer> {
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<Buffer>) | undefined,
): Promise<ActivitySyncPayload> {
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<Buffer>) | undefined,
): Promise<boolean> {
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<ActivitySyncPayload[]> {
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,
),
),
);
}
@@ -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<string, unknown> {
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<BuildSessionAnalyticsUploadResult> {
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,
};
}
@@ -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<string, unknown> {
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<T extends object>(record: T): T {
const mutableRecord = record as Record<string, unknown>;
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<SessionEntry, { type: "message" }>) {
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))];
}
@@ -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<string, unknown> {
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<void> {
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<string[]> {
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<SessionHeader | undefined> {
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<DiscoveredSession | undefined> {
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<DiscoveredSession[]> {
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;
}
@@ -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<T> = { 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<string, unknown> {
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<ActivitySyncState> {
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<void> {
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<ActivitySyncState> {
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<T>(
fn: () => Promise<T>,
agentDir?: string,
): Promise<ActivitySyncLockResult<T>> {
const { lockPath } = getActivitySyncStatePaths(agentDir);
await mkdir(dirname(lockPath), { recursive: true, mode: 0o700 });
if (!existsSync(lockPath)) await writeFile(lockPath, "", { mode: 0o600 });
let release: (() => Promise<void>) | 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;
}
@@ -40,6 +40,11 @@ export type AuthStatus = {
label?: string;
};
export interface RefreshOAuthCredentialOptions {
forceRefresh?: boolean;
shouldRemoveOnError?: (error: unknown) => boolean;
}
type LockResult<T> = {
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<OAuthCredential>,
options: RefreshOAuthCredentialOptions = {},
): Promise<OAuthCredential | undefined> {
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.
+154 -2
View File
@@ -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";
@@ -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]));
}
@@ -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<string, string>): 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<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
export function stringField(value: Record<string, unknown> | undefined, key: string): string | undefined {
const field = value?.[key];
return typeof field === "string" ? field : undefined;
}
export function numberField(value: Record<string, unknown> | undefined, key: string): number | undefined {
const field = value?.[key];
return typeof field === "number" && Number.isFinite(field) ? field : undefined;
}
export function requireString(record: Record<string, unknown>, 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<string, unknown>, 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<unknown> {
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<Record<string, unknown> | undefined> {
const json = await readJson(response);
return isRecord(json) ? json : undefined;
}
export async function throwIfPiDevNotOk(
response: Response,
operation: string,
ErrorClass: PiDevApiErrorCtor = PiDevApiError,
): Promise<void> {
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);
}
@@ -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";
@@ -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<string, unknown>;
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<string, unknown>;
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<PiDevDeviceFlowResponse> {
const fields: Record<string, string> = {
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<PiDevTokenResponse> {
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<PiDevTokenResponse> {
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<PiDevAccessIntrospectionResult> {
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<OAuthCredential> {
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<PiDevAuthResult> {
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<OAuthCredential> {
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<PiDevTokenResponse>({
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;
}
@@ -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<OAuthCredential> {
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<PiDevShareAuthResult> {
return getPiDevAuth(authStorage, [PI_DEV_SESSION_SHARE_SCOPE]);
}
export async function uploadPiDevSessionShare(options: PiDevShareUploadOptions): Promise<PiDevShareUploadResult> {
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<string, unknown> | 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()
);
}
@@ -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<string, unknown> {
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<string, unknown> = { ...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<string, unknown>)[key] = { ...baseValue, ...overrideValue };
} else {
// For primitives and arrays, override value wins
(result as Record<string, unknown>)[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();
}
}
@@ -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<string, SetupStepState>;
}
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<string, unknown>;
const rawSteps = record.steps;
const steps: Record<string, SetupStepState> = {};
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<string, SetupStepState>): 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<string | undefined>((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);
}
@@ -21,7 +21,8 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray<BuiltinSlashCommand> = [
{ 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" },
+171 -5
View File
@@ -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";
+17 -2
View File
@@ -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<string, boolean>();
@@ -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();
@@ -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();
}
@@ -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;
@@ -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<void> {
private async handleShareCommand(text: string): Promise<void> {
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<string | undefined> {
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<string | undefined> {
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<string | undefined> {
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<number | undefined> {
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<void> {
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<void> {
// 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<void> {
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<void> {
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);
@@ -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<OAuthCredential | undefined> {
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;
}
}
@@ -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<SetupStepOutcome> {
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<SetupStepOutcome> {
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<SetupStepOutcome> {
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<SetupWizardResult> {
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<SetupWizardResult> {
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 };
}
@@ -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<ActivitySyncApiError>);
});
});
@@ -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",
]);
});
});
@@ -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" },
});
});
});
@@ -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",
});
});
});
@@ -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<string | undefined>;
handleGitHubShareCommand: () => Promise<void>;
handlePiDevShareCommand: (accessToken: string, mode: ShareCommandMode) => Promise<void>;
showError: (message: string) => void;
showStatus: (message: string) => void;
};
type InteractiveModePrototype = {
handleShareCommand(this: ShareCommandContext, text: string): Promise<void>;
};
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();
});
});
@@ -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);
});
});
@@ -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");
});
});
@@ -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 });
});
});
@@ -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("<html>ok</html>");
const calls: Array<Parameters<typeof fetch>> = [];
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.",
);
});
});
@@ -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);
});
});
@@ -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");
});
});