mirror of
https://github.com/earendil-works/pi.git
synced 2026-06-18 15:54:04 +08:00
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:
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user