From 3c5a99fc5b6b3e639ee71275b66a11a79ae3c0e5 Mon Sep 17 00:00:00 2001 From: happier-dev Date: Sun, 26 Jul 2026 23:32:48 +0700 Subject: [PATCH 01/11] feat(pi): forward resolved system prompt via --append-system-prompt (claude pattern) Resolve the coding system prompt with resolveEffectiveCodingPromptText (providerId: 'pi') in createPiAcpRuntime and forward it as the --append-system-prompt spawn flag through buildPiRpcArgs, mirroring how the claude backend resolves and forwards its system prompt. Adds a generic async resolveBackendOptions hook to createCatalogProviderAcpRuntime. --- .../createCatalogProviderAcpRuntime.ts | 12 ++++ apps/cli/src/backends/pi/acp/backend.test.ts | 55 +++++++++++++++++++ apps/cli/src/backends/pi/acp/backend.ts | 12 +++- apps/cli/src/backends/pi/acp/runtime.ts | 32 +++++++++++ apps/cli/src/backends/pi/runPi.ts | 2 + 5 files changed, 111 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts b/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts index 0f7707a25..7f4122f66 100644 --- a/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts +++ b/apps/cli/src/agent/acp/runtime/createCatalogProviderAcpRuntime.ts @@ -37,6 +37,13 @@ type CatalogAcpProviderRuntimeParams = { onThinkingChange: (thinking: boolean) => void; getSessionOpenAbortSignal?: () => AbortSignal | undefined; backendOptions?: Omit; + /** + * Async resolver invoked inside `ensureBackend` before the backend is constructed. + * Returns additional backend options merged on top of `backendOptions`. Useful when + * an option (e.g. a resolved system prompt) depends on the live session and cannot + * be computed synchronously at runtime-construction time. + */ + resolveBackendOptions?: (ctx: { session: ApiSessionClient }) => Promise>; getPermissionMode?: () => PermissionMode | null | undefined; resolvePermissionMode?: (args: { getPermissionMode?: () => PermissionMode | null | undefined; @@ -160,10 +167,15 @@ export function createCatalogProviderAcpRuntime(params.provider, { cwd: params.directory, mcpServers: params.mcpServers, ...(params.backendOptions ?? {}), + ...(resolvedBackendOptions ?? {}), permissionHandler: params.permissionHandler, permissionMode, happierSessionId: params.session.sessionId, diff --git a/apps/cli/src/backends/pi/acp/backend.test.ts b/apps/cli/src/backends/pi/acp/backend.test.ts index bb56b96d4..a5d6ffe4c 100644 --- a/apps/cli/src/backends/pi/acp/backend.test.ts +++ b/apps/cli/src/backends/pi/acp/backend.test.ts @@ -156,6 +156,61 @@ describe('pi backend argv', () => { resolvePiBrokerExtensionPath(agentDir), ])); }); + + it('forwards appendSystemPromptText as --append-system-prompt', () => { + process.env.PATH = ''; + process.env.HAPPIER_PI_PATH = createFakeBin('pi'); + + const backend = createPiBackend({ + cwd: '/tmp', + env: {}, + permissionMode: 'default', + appendSystemPromptText: 'CLAUDE_PATTERN_PROMPT', + }); + + const args = (backend as any).options?.args as string[] | undefined; + expect(Array.isArray(args)).toBe(true); + const flagIndex = args!.indexOf('--append-system-prompt'); + expect(flagIndex).toBeGreaterThan(-1); + expect(args![flagIndex + 1]).toBe('CLAUDE_PATTERN_PROMPT'); + }); + + it('omits --append-system-prompt when appendSystemPromptText is blank', () => { + process.env.PATH = ''; + process.env.HAPPIER_PI_PATH = createFakeBin('pi'); + + const backend = createPiBackend({ + cwd: '/tmp', + env: {}, + permissionMode: 'default', + appendSystemPromptText: ' ', + }); + + const args = (backend as any).options?.args as string[] | undefined; + expect(Array.isArray(args)).toBe(true); + expect(args).not.toContain('--append-system-prompt'); + }); +}); + +describe('buildPiRpcArgs', () => { + it('includes --append-system-prompt when appendSystemPromptText is provided', () => { + const args = buildPiRpcArgs({ appendSystemPromptText: 'extra instructions' }); + const flagIndex = args.indexOf('--append-system-prompt'); + expect(flagIndex).toBeGreaterThan(-1); + expect(args[flagIndex + 1]).toBe('extra instructions'); + }); + + it('trims appendSystemPromptText before forwarding', () => { + const args = buildPiRpcArgs({ appendSystemPromptText: ' spaced ' }); + const flagIndex = args.indexOf('--append-system-prompt'); + expect(args[flagIndex + 1]).toBe('spaced'); + }); + + it('omits --append-system-prompt when appendSystemPromptText is empty/whitespace', () => { + expect(buildPiRpcArgs({ appendSystemPromptText: '' })).not.toContain('--append-system-prompt'); + expect(buildPiRpcArgs({ appendSystemPromptText: ' ' })).not.toContain('--append-system-prompt'); + expect(buildPiRpcArgs({})).not.toContain('--append-system-prompt'); + }); }); describe('buildPiToolsForPermissionMode', () => { diff --git a/apps/cli/src/backends/pi/acp/backend.ts b/apps/cli/src/backends/pi/acp/backend.ts index 2b071a552..175c43471 100644 --- a/apps/cli/src/backends/pi/acp/backend.ts +++ b/apps/cli/src/backends/pi/acp/backend.ts @@ -15,6 +15,12 @@ export interface PiBackendOptions extends AgentFactoryOptions { mcpServers?: Record; permissionMode?: PermissionMode; happierSessionId?: string | null; + /** + * System prompt text appended to pi's default system prompt via the + * `--append-system-prompt` spawn flag. Applied once at process startup + * (pi has no runtime RPC command to change it mid-session). + */ + appendSystemPromptText?: string; } // `null` means Happier must not override Pi's native tool catalog. Passing @@ -41,13 +47,15 @@ export function buildPiToolsForPermissionMode(permissionMode?: PermissionMode): return ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls']; } -export function buildPiRpcArgs(opts?: Readonly<{ permissionMode?: PermissionMode; thinkingLevel?: string | null }>): string[] { +export function buildPiRpcArgs(opts?: Readonly<{ permissionMode?: PermissionMode; thinkingLevel?: string | null; appendSystemPromptText?: string | null }>): string[] { const permissionMode = opts?.permissionMode; const tools = buildPiToolsForPermissionMode(permissionMode); const args: string[] = ['--mode', 'rpc']; if (tools) args.push('--tools', tools.join(',')); const thinking = providers.pi.normalizePiThinkingLevel(opts?.thinkingLevel); if (thinking) args.push('--thinking', thinking); + const appendSystemPromptText = typeof opts?.appendSystemPromptText === 'string' ? opts.appendSystemPromptText.trim() : ''; + if (appendSystemPromptText) args.push('--append-system-prompt', appendSystemPromptText); return args; } @@ -116,7 +124,7 @@ export function createPiBackend(options: PiBackendOptions): AgentBackend { launchSelection.modelScope, ] : []), - ...buildPiRpcArgs({ permissionMode: options.permissionMode, thinkingLevel }), + ...buildPiRpcArgs({ permissionMode: options.permissionMode, thinkingLevel, appendSystemPromptText: options.appendSystemPromptText }), ], happierSessionId: options.happierSessionId ?? null, env: { diff --git a/apps/cli/src/backends/pi/acp/runtime.ts b/apps/cli/src/backends/pi/acp/runtime.ts index 07c776693..abe6f731a 100644 --- a/apps/cli/src/backends/pi/acp/runtime.ts +++ b/apps/cli/src/backends/pi/acp/runtime.ts @@ -4,7 +4,10 @@ import { createCatalogProviderAcpRuntime } from '@/agent/acp/runtime/createCatal import type { SessionProviderInputConsumer } from '@/agent/runtime/sessionInput/types'; import type { ApiSessionClient } from '@/api/session/sessionClient'; import type { PermissionMode } from '@/api/types'; +import type { Credentials } from '@/persistence'; import type { MessageBuffer } from '@/ui/ink/messageBuffer'; +import { resolveEffectiveCodingPromptText } from '@/agent/prompting/coding/resolveEffectiveCodingPrompt'; +import { resolveCliFeatureDecision } from '@/features/featureDecisionService'; import type { PiBackendOptions } from '@/backends/pi/acp/backend'; import { publishPiSessionIdMetadata } from '@/backends/pi/utils/piSessionIdMetadata'; @@ -23,6 +26,13 @@ export function createPiAcpRuntime(params: { getPermissionMode?: () => PermissionMode | null | undefined; pendingQueueDrainMaxPopPerWake?: number; providerInputConsumer: SessionProviderInputConsumer; + /** + * When provided, the resolved coding system prompt is appended to pi's default + * system prompt via the `--append-system-prompt` spawn flag. Mirrors how the + * claude backend resolves and forwards its system prompt. + */ + credentials?: Credentials; + accountSettings?: Record | null; }) { const lastPublishedPiSessionId: { value: string | null; sessionFile?: string | null } = { value: null }; let lastPiIdentityGeneration: number | null = null; @@ -67,5 +77,27 @@ export function createPiAcpRuntime(params: { pendingQueueDrainMaxPopPerWake: params.pendingQueueDrainMaxPopPerWake, providerInputConsumer: params.providerInputConsumer, inFlightSteer: { enabled: true }, + resolveBackendOptions: params.credentials + ? async ({ session }) => { + try { + const text = await resolveEffectiveCodingPromptText({ + credentials: params.credentials as Credentials, + settings: params.accountSettings ?? null, + profileId: session.getMetadataSnapshot()?.profileId ?? null, + providerId: 'pi', + executionRunsFeatureEnabled: resolveCliFeatureDecision({ + featureId: 'execution.runs', + env: process.env, + }).state === 'enabled', + }); + const trimmed = typeof text === 'string' ? text.trim() : ''; + return { appendSystemPromptText: trimmed || undefined }; + } catch { + // Best-effort: if the prompt cannot be resolved, spawn pi with no + // append flag so it uses its own default system prompt. + return {}; + } + } + : undefined, }); } diff --git a/apps/cli/src/backends/pi/runPi.ts b/apps/cli/src/backends/pi/runPi.ts index a03780319..f05627d8b 100644 --- a/apps/cli/src/backends/pi/runPi.ts +++ b/apps/cli/src/backends/pi/runPi.ts @@ -39,6 +39,8 @@ export async function runPi(opts: StandardAcpProviderRunOptions & { getPermissionMode, pendingQueueDrainMaxPopPerWake, providerInputConsumer, + credentials: opts.credentials, + accountSettings: opts.accountSettingsContext?.settings ?? null, }), onAttachMetadataSnapshotMissing: (error) => { logger.debug( From 5d399f1085b31a2d0b19570a9372e3d162561cc0 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 16 Aug 2026 17:49:00 +0700 Subject: [PATCH 02/11] fix(pi): deliver the system prompt at spawn without first-message base duplication Pi sessions received the base system prompt sections twice: once via --append-system-prompt at every process spawn, and again in the generic fresh-session first-message prepend. Mark pi as delivering the system prompt at spawn (deliversSystemPromptAtSpawn) so the prepend renders only tool-delivery blocks (resolveEffectiveCodingPrompt renderBlockScopes); explicit per-message base overrides still reach the provider ahead of the tool appendix since they cannot ride the spawn flag. --- .../resolveEffectiveCodingPrompt.test.ts | 22 ++++++++++++ .../coding/resolveEffectiveCodingPrompt.ts | 16 +++++++-- .../runtime/runStandardAcpProvider.test.ts | 30 ++++++++++++++++ .../agent/runtime/runStandardAcpProvider.ts | 34 ++++++++++++++++--- apps/cli/src/backends/pi/runPi.test.ts | 3 +- apps/cli/src/backends/pi/runPi.ts | 1 + 6 files changed, 99 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.test.ts b/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.test.ts index 7931e10b6..44f28399e 100644 --- a/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.test.ts +++ b/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.test.ts @@ -241,6 +241,28 @@ describe('resolveEffectiveCodingPromptText', () => { expect(out).not.toContain('again if the task changes significantly'); }); + it('renders only tool-delivery blocks when renderBlockScopes restricts the rendered scopes', async () => { + const credentials = createCredentials(); + + const out = await resolveEffectiveCodingPromptText({ + credentials, + settings: {}, + profileId: null, + baseOverride: 'BASE', + executionRunsFeatureEnabled: false, + toolDelivery: 'shell_bridge', + toolDeliverySessionId: 's1', + toolDeliveryDirectory: '/tmp/worktree', + renderBlockScopes: ['tool_delivery'], + fetchPromptArtifactRecord: async () => null, + }); + + expect(out).toContain('Happier tools are available through the CLI bridge'); + expect(out).toContain("'--session-id' 's1'"); + expect(out).not.toContain('BASE'); + expect(out).not.toContain('# Attachments'); + }); + it('applies prompt personalization settings to the effective coding prompt', async () => { const credentials = createCredentials(); diff --git a/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.ts b/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.ts index ce16e1dd9..9137bcf33 100644 --- a/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.ts +++ b/apps/cli/src/agent/prompting/coding/resolveEffectiveCodingPrompt.ts @@ -2,7 +2,8 @@ import { buildCodingSessionPromptPlanBaseV1, buildPromptPlanDiagnosticsV1, buildPromptPlanV1, - renderPromptPlanV1, + renderPromptBlocksV1, + type PromptBlockScopeV1, type PromptBlockV1, type PromptPlanV1, } from '@happier-dev/protocol'; @@ -34,6 +35,14 @@ type ResolveEffectiveCodingPromptArgs = Readonly<{ memoryMachineId?: string | null; cache?: Map; fetchPromptArtifactRecord?: FetchPromptArtifactRecord; + /** + * When set, only blocks whose scope is listed here render into `text`; the + * plan and diagnostics still include every composed block. Used when the + * backend already delivers the session-scope system prompt at process spawn + * and a first-message prepend must carry only the remaining blocks (e.g. + * tool delivery). + */ + renderBlockScopes?: readonly PromptBlockScopeV1[]; }>; export async function resolveEffectiveCodingPromptText( @@ -103,10 +112,13 @@ export async function resolveEffectiveCodingPromptPlan( modality: 'coding', blocks: [...basePlan.blocks, ...promptStackBlocks, ...providerBehaviorBlocks, ...toolDeliveryBlocks], }); + const renderedBlocks = args.renderBlockScopes + ? plan.blocks.filter((block) => args.renderBlockScopes!.includes(block.scope)) + : plan.blocks; return { plan, - text: renderPromptPlanV1(plan), + text: renderPromptBlocksV1(renderedBlocks), diagnostics: buildPromptPlanDiagnosticsV1(plan), }; } diff --git a/apps/cli/src/agent/runtime/runStandardAcpProvider.test.ts b/apps/cli/src/agent/runtime/runStandardAcpProvider.test.ts index 03734f535..be2a01967 100644 --- a/apps/cli/src/agent/runtime/runStandardAcpProvider.test.ts +++ b/apps/cli/src/agent/runtime/runStandardAcpProvider.test.ts @@ -569,6 +569,36 @@ describe('runStandardAcpProvider', () => { expect(resolvedPrompt).not.toContain('vendor-session-123'); }); + it('prepends only tool-delivery blocks when the backend delivers the system prompt at spawn', async () => { + const harness = createHarness(); + harness.config.deliversSystemPromptAtSpawn = true; + + let defaultPrepend = ''; + let overridePrepend = ''; + harness.deps.runPermissionModePromptLoopFn = async (params: Readonly<{ + resolveFreshSessionSystemPrompt?: (args: { baseOverride?: string | null }) => Promise; + }>) => { + defaultPrepend = await params.resolveFreshSessionSystemPrompt?.({}) ?? ''; + overridePrepend = await params.resolveFreshSessionSystemPrompt?.({ baseOverride: 'USER SYSTEM OVERRIDE' }) ?? ''; + }; + + await runStandardAcpProvider(harness.opts, harness.config, harness.deps); + + // The spawn flag (--append-system-prompt) already carries the shared base + // sections, so the first-message prepend must not duplicate them. + expect(defaultPrepend).toContain('Happier tools are available through the CLI bridge'); + expect(defaultPrepend).toContain("'--session-id' 'session-1'"); + expect(defaultPrepend).not.toContain('# Session title'); + expect(defaultPrepend).not.toContain('# Attachments'); + // Explicit per-message base overrides cannot ride the spawn flag and must + // still reach the provider, ahead of the tool-delivery appendix. + expect(overridePrepend).toContain('USER SYSTEM OVERRIDE'); + expect(overridePrepend).toContain('Happier tools are available through the CLI bridge'); + expect(overridePrepend).not.toContain('# Attachments'); + expect(overridePrepend.indexOf('USER SYSTEM OVERRIDE')) + .toBeLessThan(overridePrepend.indexOf('Happier tools are available through the CLI bridge')); + }); + it('in-flight steer controller calls steerPrompt with correct receiver', async () => { const harness = createHarness(); diff --git a/apps/cli/src/agent/runtime/runStandardAcpProvider.ts b/apps/cli/src/agent/runtime/runStandardAcpProvider.ts index b09b1dbe3..ffb982669 100644 --- a/apps/cli/src/agent/runtime/runStandardAcpProvider.ts +++ b/apps/cli/src/agent/runtime/runStandardAcpProvider.ts @@ -155,6 +155,14 @@ export type StandardAcpProviderConfig = { onDispose?: (params: { session: ApiSessionClient; runtime: RuntimeForLoop }) => void | Promise; startRuntimeBeforeFirstPrompt?: boolean; failClosedOnResumeFailure?: boolean; + /** + * True when the backend applies the effective coding system prompt itself at + * process spawn (e.g. pi's --append-system-prompt flag). The fresh-session + * first-message prepend then carries only tool-delivery blocks plus any + * explicit per-message base override, instead of duplicating the + * spawn-delivered system prompt. + */ + deliversSystemPromptAtSpawn?: boolean; onTerminalDisplayControllerReady?: (controller: TerminalDisplayController) => void; shouldRenderTerminalDisplay?: (params: { opts: StandardAcpProviderRunOptions; session: ApiSessionClient; metadata: Metadata }) => boolean; resolveKeepAliveMode?: () => KeepAliveMode; @@ -657,12 +665,11 @@ export async function runStandardAcpProvider( strictInitialResume: initialResumeId.length > 0, failClosedOnResumeFailure: config.failClosedOnResumeFailure === true, startRuntimeBeforeFirstPrompt: config.startRuntimeBeforeFirstPrompt === true, - resolveFreshSessionSystemPrompt: async ({ baseOverride }) => - await resolveEffectiveCodingPromptText({ + resolveFreshSessionSystemPrompt: async ({ baseOverride }) => { + const commonArgs = { credentials: opts.credentials, settings: opts.accountSettingsContext?.settings ?? null, profileId: session.getMetadataSnapshot()?.profileId ?? null, - baseOverride, executionRunsFeatureEnabled: resolveCliFeatureDecision({ featureId: 'execution.runs', env: process.env, @@ -674,7 +681,26 @@ export async function runStandardAcpProvider( memoryMachineId: machineId, memoryRecallGuidanceEnabled, cache: promptArtifactBodyCache, - }), + }; + if (config.deliversSystemPromptAtSpawn !== true) { + return await resolveEffectiveCodingPromptText({ ...commonArgs, baseOverride }); + } + // The backend applies the session system prompt at process spawn (e.g. + // pi's --append-system-prompt flag); the first-message prepend must not + // duplicate it. Carry only the tool-delivery bridge blocks, plus an + // explicit per-message base override, which cannot ride the spawn flag. + const explicitBaseOverride = typeof baseOverride === 'string' && baseOverride.trim() + ? baseOverride.trim() + : ''; + const toolDeliveryText = await resolveEffectiveCodingPromptText({ + ...commonArgs, + renderBlockScopes: ['tool_delivery'], + }); + if (explicitBaseOverride && toolDeliveryText) { + return `${explicitBaseOverride}\n\n${toolDeliveryText}`; + } + return explicitBaseOverride || toolDeliveryText; + }, onAfterStart: config.onAfterStart ? () => config.onAfterStart?.({ session, runtime }) : undefined, onAfterReset: config.onAfterReset ? () => config.onAfterReset?.({ session, runtime }) : undefined, formatPromptErrorMessage: config.formatPromptErrorMessage, diff --git a/apps/cli/src/backends/pi/runPi.test.ts b/apps/cli/src/backends/pi/runPi.test.ts index 7dff2ac4b..d5e7e6d21 100644 --- a/apps/cli/src/backends/pi/runPi.test.ts +++ b/apps/cli/src/backends/pi/runPi.test.ts @@ -54,13 +54,14 @@ describe('runPi', () => { runStandardAcpProviderMock.mockResolvedValue(undefined); }); - it('disables MCP server resolution for Pi sessions', async () => { + it('disables MCP server resolution and declares spawn-time system prompt delivery for Pi sessions', async () => { await runPi({ credentials }); expect(runStandardAcpProviderMock).toHaveBeenCalledTimes(1); expect(runStandardAcpProviderMock.mock.calls[0]?.[1]).toMatchObject({ flavor: 'pi', supportsMcpServers: false, + deliversSystemPromptAtSpawn: true, }); }); diff --git a/apps/cli/src/backends/pi/runPi.ts b/apps/cli/src/backends/pi/runPi.ts index f05627d8b..5cc8d6123 100644 --- a/apps/cli/src/backends/pi/runPi.ts +++ b/apps/cli/src/backends/pi/runPi.ts @@ -22,6 +22,7 @@ export async function runPi(opts: StandardAcpProviderRunOptions & { agentMessageType: 'pi', supportsMcpServers: false, resolveToolsDeliveryAvailability: resolvePiToolsDeliveryAvailability, + deliversSystemPromptAtSpawn: true, machineMetadata: initialMachineMetadata, terminalDisplay: PiTerminalDisplay, resolvePermissionModeQueueKey: (permissionMode) => buildPiToolsForPermissionMode(permissionMode)?.join(',') ?? 'native', From a60b06ec926508df960596b656f2292d46f6d944 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 16 Aug 2026 23:09:00 +0700 Subject: [PATCH 03/11] fix(pi): resolve catalog-backed model ids before splitting composite references resolveModelSelection split any slash-containing model id on the first slash, which is wrong for pi provider ids that themselves contain slashes (e.g. lmstudio/hadees): a UI composite id like lmstudio/hadees/prism-ml/bonsai-27b resolved to provider "lmstudio" and model "hadees/prism-ml/bonsai-27b", which pi's set_model always rejects with "Model not found". The spawn-time model override then failed and retried silently, leaving sessions on the CLI default. The same naive split also wrote the wrong mapping into the model-provider cache, poisoning later resolutions until the next catalog repopulation. Consult the authoritative catalog map first (exact match on the bare or provider/model composite key, stripping the provider prefix from the returned model id), then split composite references at the longest known provider boundary, and only fall back to the historical first-slash split for unknown composites without caching the guess. Proven against a fake pi RPC catalog: multi-segment composite ids send the correct provider/model pair, catalog composite keys return the bare model id, bare ids keep resolving, and the published session_models_state currentModelId round-trips the bare id. --- .../rpc/PiRpcBackend.modelSelection.test.ts | 183 ++++++++++++++++++ apps/cli/src/backends/pi/rpc/PiRpcBackend.ts | 51 ++++- 2 files changed, 224 insertions(+), 10 deletions(-) create mode 100644 apps/cli/src/backends/pi/rpc/PiRpcBackend.modelSelection.test.ts diff --git a/apps/cli/src/backends/pi/rpc/PiRpcBackend.modelSelection.test.ts b/apps/cli/src/backends/pi/rpc/PiRpcBackend.modelSelection.test.ts new file mode 100644 index 000000000..dd9b599b3 --- /dev/null +++ b/apps/cli/src/backends/pi/rpc/PiRpcBackend.modelSelection.test.ts @@ -0,0 +1,183 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { chmodSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import type { AgentMessage } from '@/agent/core'; +import { PiRpcBackend } from './PiRpcBackend'; + +function makeTempDir(prefix: string): string { + return mkdtempSync(join(tmpdir(), prefix)); +} + +function makeFakePiRpcModelSelectionScript(dir: string): { scriptPath: string; receivedPath: string } { + const scriptPath = join(dir, 'fake-pi-rpc-model-selection.js'); + const receivedPath = join(dir, 'received.jsonl'); + const script = ` +const fs = require('node:fs'); +const readline = require('node:readline'); +const rl = readline.createInterface({ input: process.stdin }); +const out = (obj) => process.stdout.write(JSON.stringify(obj) + '\\n'); +let currentModel = { id: 'glm-5.3', provider: 'zai', name: 'GLM' }; + +rl.on('line', (line) => { + let command; + try { command = JSON.parse(line); } catch { return; } + fs.appendFileSync(${JSON.stringify(receivedPath)}, JSON.stringify(command) + '\\n'); + + switch (command.type) { + case 'get_state': + out({ + id: command.id, + type: 'response', + command: 'get_state', + success: true, + data: { + sessionId: 'pi-session-model-selection', + thinkingLevel: 'off', + model: currentModel + } + }); + break; + case 'get_available_models': + out({ + id: command.id, + type: 'response', + command: 'get_available_models', + success: true, + data: { + models: [ + { id: 'glm-5.3', provider: 'zai', name: 'GLM 5.3' }, + { id: 'prism-ml/bonsai-27b', provider: 'lmstudio/hadees', name: 'Bonsai 27B' }, + { id: 'muse-glimmer-30b@q5_k_m', provider: 'lmstudio/hadees', name: 'Glimmer 30B' } + ] + } + }); + break; + case 'get_commands': + out({ + id: command.id, + type: 'response', + command: 'get_commands', + success: true, + data: { commands: [] } + }); + break; + case 'set_model': + currentModel = { id: command.modelId, provider: command.provider, name: command.modelId }; + out({ id: command.id, type: 'response', command: 'set_model', success: true, data: currentModel }); + break; + default: + out({ id: command.id, type: 'response', command: command.type, success: true, data: {} }); + break; + } +}); +`; + writeFileSync(scriptPath, script, 'utf8'); + chmodSync(scriptPath, 0o755); + return { scriptPath, receivedPath }; +} + +function readReceivedSetModelCommands(receivedPath: string): Array> { + let text: string; + try { + text = readFileSync(receivedPath, 'utf8'); + } catch { + return []; + } + return text + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line) as Record) + .filter((command) => command.type === 'set_model'); +} + +describe('PiRpcBackend (model selection)', () => { + let tempDir: string | null = null; + let backend: PiRpcBackend | null = null; + + afterEach(async () => { + if (backend) { + await backend.dispose(); + backend = null; + } + if (tempDir) { + rmSync(tempDir, { recursive: true, force: true }); + tempDir = null; + } + }); + + async function startBackendWithCatalog(): Promise<{ sessionId: string; receivedPath: string; messages: AgentMessage[] }> { + tempDir = makeTempDir('happier-pi-rpc-model-selection-'); + const { scriptPath, receivedPath } = makeFakePiRpcModelSelectionScript(tempDir); + + backend = new PiRpcBackend({ + cwd: tempDir, + command: process.execPath, + args: [scriptPath], + }); + + const messages: AgentMessage[] = []; + backend.onMessage((message) => messages.push(message)); + + const { sessionId } = await backend.startSession(); + return { sessionId, receivedPath, messages }; + } + + it('resolves a composite model id against the live catalog before naively splitting on the first slash', async () => { + const { sessionId, receivedPath } = await startBackendWithCatalog(); + + await backend!.setSessionModel(sessionId, 'lmstudio/hadees/prism-ml/bonsai-27b'); + + const setModelCommands = readReceivedSetModelCommands(receivedPath); + expect(setModelCommands).toHaveLength(1); + expect(setModelCommands[0]).toMatchObject({ + type: 'set_model', + provider: 'lmstudio/hadees', + modelId: 'prism-ml/bonsai-27b', + }); + }); + + it('strips the provider prefix from a catalog composite key for single-segment providers', async () => { + const { sessionId, receivedPath } = await startBackendWithCatalog(); + + await backend!.setSessionModel(sessionId, 'zai/glm-5.3'); + + const setModelCommands = readReceivedSetModelCommands(receivedPath); + expect(setModelCommands).toHaveLength(1); + expect(setModelCommands[0]).toMatchObject({ + type: 'set_model', + provider: 'zai', + modelId: 'glm-5.3', + }); + }); + + it('resolves bare catalog model ids without a provider prefix', async () => { + const { sessionId, receivedPath } = await startBackendWithCatalog(); + + await backend!.setSessionModel(sessionId, 'muse-glimmer-30b@q5_k_m'); + + const setModelCommands = readReceivedSetModelCommands(receivedPath); + expect(setModelCommands).toHaveLength(1); + expect(setModelCommands[0]).toMatchObject({ + type: 'set_model', + provider: 'lmstudio/hadees', + modelId: 'muse-glimmer-30b@q5_k_m', + }); + }); + + it('publishes the resolved current model with the bare model id', async () => { + const { sessionId, messages } = await startBackendWithCatalog(); + + await backend!.setSessionModel(sessionId, 'lmstudio/hadees/prism-ml/bonsai-27b'); + + const modelsStates = messages.filter( + (message): message is Extract => ( + message.type === 'event' && message.name === 'session_models_state' + ), + ); + const last = modelsStates.at(-1)?.payload as { currentModelId?: unknown } | undefined; + expect(last?.currentModelId).toBe('prism-ml/bonsai-27b'); + }); +}); diff --git a/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts b/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts index 319d83ef5..5fb109c76 100644 --- a/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts +++ b/apps/cli/src/backends/pi/rpc/PiRpcBackend.ts @@ -2733,27 +2733,58 @@ export class PiRpcBackend implements AgentBackend { } } + /** + * Longest known provider id that prefixes the raw model reference + * (`provider/model`). Provider ids may themselves contain slashes (e.g. + * `lmstudio/hadees`), so a naive first-slash split is not sound. + */ + private resolveKnownProviderPrefix(modelIdRaw: string): string | null { + let best: string | null = null; + for (const provider of this.modelProviderById.values()) { + if (!modelIdRaw.startsWith(`${provider}/`)) continue; + if (best === null || provider.length > best.length) best = provider; + } + return best; + } + private async resolveModelSelection(modelIdRaw: string): Promise<{ provider: string; modelId: string }> { + // The catalog map is authoritative: it holds both bare model ids and + // `provider/model` composite keys exactly as pi reports them. + const fromKnownMap = this.modelProviderById.get(modelIdRaw); + if (fromKnownMap) { + const bareModelId = modelIdRaw.startsWith(`${fromKnownMap}/`) + ? modelIdRaw.slice(fromKnownMap.length + 1) + : modelIdRaw; + return { provider: fromKnownMap, modelId: bareModelId }; + } + if (modelIdRaw.includes('/')) { + // Composite reference: split at a known provider boundary when one + // matches, so multi-segment provider ids resolve correctly. + const prefixProvider = this.resolveKnownProviderPrefix(modelIdRaw); + if (prefixProvider) { + const modelId = modelIdRaw.slice(prefixProvider.length + 1).trim(); + if (modelId) { + this.modelProviderById.set(modelId, prefixProvider); + this.modelProviderById.set(`${prefixProvider}/${modelId}`, prefixProvider); + return { provider: prefixProvider, modelId }; + } + } + + // Unknown composite: keep the historical first-slash split for + // single-segment providers, but do not cache the guess so a wrong + // split cannot poison later catalog-backed resolutions. const [provider, ...rest] = modelIdRaw.split('/'); const modelId = rest.join('/').trim(); const normalizedProvider = provider.trim(); if (normalizedProvider && modelId) { - this.modelProviderById.set(modelId, normalizedProvider); - this.modelProviderById.set(`${normalizedProvider}/${modelId}`, normalizedProvider); return { provider: normalizedProvider, modelId }; } - } - - const fromKnownMap = this.modelProviderById.get(modelIdRaw); - if (fromKnownMap) { - return { provider: fromKnownMap, modelId: modelIdRaw }; - } - - if (this.currentModelProvider) { + } else if (this.currentModelProvider) { return { provider: this.currentModelProvider, modelId: modelIdRaw }; } + // Bare id with no cached provider: resolve from the live session state. const state = await this.getState(); const model = asRecord(state.model); const provider = asNonEmptyString(model?.provider); From 019be0bd35c216cdee84a690cc541c524f7f9fa8 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 9 Aug 2026 10:42:54 +0700 Subject: [PATCH 04/11] feat: add pi as a direct-session provider Register `pi` in `AgentProviderIdV1` and add a `piAgentDir` source variant to `DirectSessionsSource`, then implement the full direct-session provider under `apps/cli/src/backends/pi/directSessions/` and wire it through the backend catalog (`getDirectSessionProviderOps`). Pi sessions are tree-structured JSONL keyed by `id`/`parentId`, so the active branch is resolved by porting pi's own `buildContextEntries`/ `buildSessionPath`/`_buildIndex` tree walk into `piEntryContext.ts` (faithful to the installed binary's `firstKeptEntryId` compaction fold). Paging slices the projected active-branch item list (index cursors) rather than raw bytes, since the tree walk needs the whole file; `older` pages return chronological intra-page order so the shared import orchestrator's page-reversal reconstructs full chronological order. Takeover resumes in place via `pi --session ` launched from the session's header `cwd` (authoritative; the `----` directory name is not decoded because its encoding collapses both separators and drive colons), with `PI_CODING_AGENT_DIR` pointing at the scanned agent dir. Layer 4 linking (`ensureDirectSessionLink` pi arms) is not yet included; linking works generically today but pi-specific source-key discrimination and the `piSessionId` metadata field remain to be added. --- apps/cli/src/backends/catalog.test.ts | 7 + .../getPiDirectSessionActivity.ts | 28 ++ .../getPiDirectSessionWorkingDirectory.ts | 26 ++ .../listPiSessionCandidates.test.ts | 83 ++++++ .../directSessions/listPiSessionCandidates.ts | 250 ++++++++++++++++++ .../mapPiSessionToDirectMessages.test.ts | 157 +++++++++++ .../mapPiSessionToDirectMessages.ts | 110 ++++++++ .../directSessions/pagePiTranscript.test.ts | 127 +++++++++ .../pi/directSessions/pagePiTranscript.ts | 153 +++++++++++ .../pi/directSessions/piEntryContext.test.ts | 114 ++++++++ .../pi/directSessions/piEntryContext.ts | 128 +++++++++ .../backends/pi/directSessions/providerOps.ts | 82 ++++++ .../directSessions/readAfterPiTranscript.ts | 58 ++++ .../pi/directSessions/readPiSessionHeader.ts | 55 ++++ .../directSessions/readPiSessionTitle.test.ts | 54 ++++ .../pi/directSessions/readPiSessionTitle.ts | 78 ++++++ .../pi/directSessions/resolvePiAgentDir.ts | 31 +++ .../resolvePiDirectSessionFile.ts | 99 +++++++ apps/cli/src/backends/pi/index.ts | 1 + .../src/directSessions/daemonRpcV1.ts | 9 + packages/protocol/src/index.exports.test.ts | 4 + .../src/providers/agentProviderIdsV1.ts | 2 +- 22 files changed, 1655 insertions(+), 1 deletion(-) create mode 100644 apps/cli/src/backends/pi/directSessions/getPiDirectSessionActivity.ts create mode 100644 apps/cli/src/backends/pi/directSessions/getPiDirectSessionWorkingDirectory.ts create mode 100644 apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.test.ts create mode 100644 apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.ts create mode 100644 apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts create mode 100644 apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts create mode 100644 apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts create mode 100644 apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts create mode 100644 apps/cli/src/backends/pi/directSessions/piEntryContext.test.ts create mode 100644 apps/cli/src/backends/pi/directSessions/piEntryContext.ts create mode 100644 apps/cli/src/backends/pi/directSessions/providerOps.ts create mode 100644 apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts create mode 100644 apps/cli/src/backends/pi/directSessions/readPiSessionHeader.ts create mode 100644 apps/cli/src/backends/pi/directSessions/readPiSessionTitle.test.ts create mode 100644 apps/cli/src/backends/pi/directSessions/readPiSessionTitle.ts create mode 100644 apps/cli/src/backends/pi/directSessions/resolvePiAgentDir.ts create mode 100644 apps/cli/src/backends/pi/directSessions/resolvePiDirectSessionFile.ts diff --git a/apps/cli/src/backends/catalog.test.ts b/apps/cli/src/backends/catalog.test.ts index 11d5fdf86..ef23efbb2 100644 --- a/apps/cli/src/backends/catalog.test.ts +++ b/apps/cli/src/backends/catalog.test.ts @@ -218,6 +218,13 @@ describe('AGENTS', () => { await expect(getDirectSessionProviderOps('opencode')).resolves.toMatchObject({ listCandidates: expect.any(Function), }); + await expect(getDirectSessionProviderOps('pi')).resolves.toMatchObject({ + listCandidates: expect.any(Function), + pageTranscript: expect.any(Function), + readAfterTranscript: expect.any(Function), + getActivity: expect.any(Function), + resolveTakeoverSpawnOptions: expect.any(Function), + }); }); it('loads provider-attach ops through backend catalog hooks only for supporting providers', async () => { diff --git a/apps/cli/src/backends/pi/directSessions/getPiDirectSessionActivity.ts b/apps/cli/src/backends/pi/directSessions/getPiDirectSessionActivity.ts new file mode 100644 index 000000000..bb16de3b3 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/getPiDirectSessionActivity.ts @@ -0,0 +1,28 @@ +import { stat } from 'node:fs/promises'; + +import type { DirectSessionsSource } from '@happier-dev/protocol'; + +import { resolvePiDirectSessionFile } from './resolvePiDirectSessionFile'; + +/** + * Pi direct-session activity is derived from the session file's mtime. There is no live process + * probe in the direct-session model; liveness during background follow is owned by the polling + * follow-lease. `isRunning` is therefore always false, matching Claude's behavior. + */ +export async function getPiDirectSessionActivity(params: Readonly<{ + source: DirectSessionsSource; + env?: NodeJS.ProcessEnv; + remoteSessionId: string; +}>): Promise> { + const resolved = await resolvePiDirectSessionFile({ + source: params.source, + env: params.env, + remoteSessionId: params.remoteSessionId, + }); + if (!resolved) return { lastActivityAtMs: null }; + + const s = await stat(resolved.filePath).catch(() => null); + if (!s) return { lastActivityAtMs: null }; + + return { lastActivityAtMs: Math.trunc(s.mtimeMs) }; +} diff --git a/apps/cli/src/backends/pi/directSessions/getPiDirectSessionWorkingDirectory.ts b/apps/cli/src/backends/pi/directSessions/getPiDirectSessionWorkingDirectory.ts new file mode 100644 index 000000000..e6ae0b6f7 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/getPiDirectSessionWorkingDirectory.ts @@ -0,0 +1,26 @@ +import type { DirectSessionsSource } from '@happier-dev/protocol'; + +import { readPiSessionHeader } from './readPiSessionHeader'; +import { resolvePiDirectSessionFile } from './resolvePiDirectSessionFile'; + +/** + * Resolve a pi direct session's working directory from its authoritative source: the session + * header `cwd` field. The `sessions/----` directory name is not decoded here because the + * encoding collapses both separators and drive colons to `-`, making reverse decoding ambiguous. + */ +export async function getPiDirectSessionWorkingDirectory(params: Readonly<{ + source: DirectSessionsSource; + env?: NodeJS.ProcessEnv; + remoteSessionId: string; +}>): Promise { + const resolved = await resolvePiDirectSessionFile({ + source: params.source, + env: params.env, + remoteSessionId: params.remoteSessionId, + }); + if (!resolved) return null; + + const header = await readPiSessionHeader(resolved.filePath); + const cwd = header?.cwd?.trim(); + return cwd || null; +} diff --git a/apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.test.ts b/apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.test.ts new file mode 100644 index 000000000..a8e09b1f4 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.test.ts @@ -0,0 +1,83 @@ +import { mkdirSync, mkdtempSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import type { DirectSessionsSource } from '@happier-dev/protocol'; + +import { listPiSessionCandidates } from './listPiSessionCandidates'; + +const SESSION_A = '019f4a42-4617-767a-8e7c-189b454a0352'; +const SESSION_B = '019f53a6-c8cf-7a8c-a165-61d0dc6b42e7'; + +function writeSession(agentDir: string, dirName: string, fileName: string, lines: readonly object[], mtimeSeconds: number): void { + const sessionsDir = join(agentDir, 'sessions', dirName); + mkdirSync(sessionsDir, { recursive: true }); + const filePath = join(sessionsDir, fileName); + writeFileSync(filePath, lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + utimesSync(filePath, mtimeSeconds, mtimeSeconds); +} + +function sourceEnv(agentDir: string): { source: DirectSessionsSource; env: NodeJS.ProcessEnv } { + return { source: { kind: 'piAgentDir' }, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir } }; +} + +function header(id: string, cwd: string): object { + return { type: 'session', id, timestamp: '2024-12-03T14:00:00.000Z', cwd, version: 3 }; +} + +function userMsg(id: string, parentId: string | null, text: string): object { + return { type: 'message', id, parentId, timestamp: '2024-12-03T14:00:01.000Z', message: { role: 'user', content: text } }; +} + +describe('listPiSessionCandidates', () => { + it('discovers sessions across cwd-encoded directories, sorted by mtime descending', async () => { + const agentDir = mkdtempSync(join(tmpdir(), 'pi-list-')); + writeSession(agentDir, '--proj-a--', `2024-12-03T14-00-00-000Z_${SESSION_A}.jsonl`, [header(SESSION_A, '/proj-a'), userMsg('m1', null, 'task in proj-a')], 1_700_000_100); + writeSession(agentDir, '--proj-b--', `2024-12-04T09-00-00-000Z_${SESSION_B}.jsonl`, [header(SESSION_B, '/proj-b'), userMsg('n1', null, 'task in proj-b')], 1_700_000_200); + + const { source, env } = sourceEnv(agentDir); + const result = await listPiSessionCandidates({ source, env, limit: 10 }); + + expect(result.candidates.map((c) => c.remoteSessionId)).toEqual([SESSION_B, SESSION_A]); + expect(result.nextCursor).toBeNull(); + // title + cwd enriched from header/title scan + const candidateA = result.candidates.find((c) => c.remoteSessionId === SESSION_A)!; + expect(candidateA.title).toBe('task in proj-a'); + expect((candidateA.details as { cwd: string }).cwd).toBe('/proj-a'); + }); + + it('paginates with an index cursor', async () => { + const agentDir = mkdtempSync(join(tmpdir(), 'pi-list-page-')); + writeSession(agentDir, '--proj-a--', `2024-12-03T14-00-00-000Z_${SESSION_A}.jsonl`, [header(SESSION_A, '/proj-a'), userMsg('m1', null, 'older')], 1_700_000_100); + writeSession(agentDir, '--proj-b--', `2024-12-04T09-00-00-000Z_${SESSION_B}.jsonl`, [header(SESSION_B, '/proj-b'), userMsg('n1', null, 'newer')], 1_700_000_200); + + const { source, env } = sourceEnv(agentDir); + const first = await listPiSessionCandidates({ source, env, limit: 1 }); + expect(first.candidates.map((c) => c.remoteSessionId)).toEqual([SESSION_B]); + expect(first.nextCursor).not.toBeNull(); + + const second = await listPiSessionCandidates({ source, env, limit: 1, cursor: first.nextCursor! }); + expect(second.candidates.map((c) => c.remoteSessionId)).toEqual([SESSION_A]); + expect(second.nextCursor).toBeNull(); + }); + + it('exact-id search resolves directly to the session regardless of scan order', async () => { + const agentDir = mkdtempSync(join(tmpdir(), 'pi-list-search-')); + writeSession(agentDir, '--proj-a--', `2024-12-03T14-00-00-000Z_${SESSION_A}.jsonl`, [header(SESSION_A, '/proj-a'), userMsg('m1', null, 'find me')], 1_700_000_100); + writeSession(agentDir, '--proj-b--', `2024-12-04T09-00-00-000Z_${SESSION_B}.jsonl`, [header(SESSION_B, '/proj-b'), userMsg('n1', null, 'other')], 1_700_000_200); + + const { source, env } = sourceEnv(agentDir); + const result = await listPiSessionCandidates({ source, env, limit: 10, searchTerm: SESSION_A }); + expect(result.candidates.map((c) => c.remoteSessionId)).toEqual([SESSION_A]); + expect(result.candidates[0]!.title).toBe('find me'); + }); + + it('returns an empty candidate list when the agent dir has no sessions', async () => { + const agentDir = mkdtempSync(join(tmpdir(), 'pi-list-empty-')); + const { source, env } = sourceEnv(agentDir); + const result = await listPiSessionCandidates({ source, env, limit: 10 }); + expect(result.candidates).toEqual([]); + expect(result.nextCursor).toBeNull(); + }); +}); diff --git a/apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.ts b/apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.ts new file mode 100644 index 000000000..0c2dc52e2 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/listPiSessionCandidates.ts @@ -0,0 +1,250 @@ +import { type Dirent } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { DirectSessionCandidateV1, DirectSessionsSource } from '@happier-dev/protocol'; + +import { deriveDirectSessionActivityFromTimestamp } from '@/api/directSessions/activity/deriveDirectSessionActivityFromTimestamp'; +import { mapWithConcurrency } from '@/api/directSessions/discovery/mapWithConcurrency'; +import { logger } from '@/utils/logger'; + +import { readPiSessionHeader } from './readPiSessionHeader'; +import { readPiSessionTitle } from './readPiSessionTitle'; +import { extractPiSessionIdFromFilename, resolvePiDirectSessionFile } from './resolvePiDirectSessionFile'; +import { resolvePiAgentDir } from './resolvePiAgentDir'; + +type IndexCursorV1 = Readonly<{ v: 1; kind: 'index'; offset: number }>; + +function encodeIndexCursor(offset: number): string { + const cursor: IndexCursorV1 = { v: 1, kind: 'index', offset: Math.max(0, Math.trunc(offset)) }; + return Buffer.from(JSON.stringify(cursor), 'utf8').toString('base64url'); +} + +function decodeIndexCursor(raw: string | undefined): number { + if (typeof raw !== 'string' || raw.trim().length === 0) return 0; + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object') return 0; + const value = parsed as Record; + if (value.v !== 1 || value.kind !== 'index') return 0; + const offset = typeof value.offset === 'number' && Number.isFinite(value.offset) ? value.offset : 0; + return Math.max(0, Math.trunc(offset)); + } catch { + return 0; + } +} + +function parsePositiveIntEnv(params: Readonly<{ + env: NodeJS.ProcessEnv; + key: string; + defaultValue: number; + min: number; + max: number; +}>): number { + const raw = Number.parseInt(String(params.env[params.key] ?? ''), 10); + const configured = Number.isFinite(raw) && raw > 0 ? Math.trunc(raw) : params.defaultValue; + return Math.max(params.min, Math.min(params.max, configured)); +} + +function resolvePiDiscoveryConcurrency(env: NodeJS.ProcessEnv): number { + return parsePositiveIntEnv({ + env, + key: 'HAPPIER_DIRECT_SESSIONS_PI_DISCOVERY_CONCURRENCY', + defaultValue: 64, + min: 1, + max: 512, + }); +} + +function resolvePiSearchCandidateLimit(env: NodeJS.ProcessEnv): number { + return parsePositiveIntEnv({ + env, + key: 'HAPPIER_DIRECT_SESSIONS_PI_SEARCH_CANDIDATE_LIMIT', + defaultValue: 2000, + min: 1, + max: 50_000, + }); +} + +type DiscoveredPiSession = Readonly<{ + id: string; + dirName: string; + fileName: string; + filePath: string; + mtimeMs: number; +}>; + +async function buildPiCandidate(params: Readonly<{ + session: DiscoveredPiSession; + env: NodeJS.ProcessEnv; +}>): Promise { + const [header, title] = await Promise.all([ + readPiSessionHeader(params.session.filePath).catch(() => null), + readPiSessionTitle(params.session.filePath).catch(() => null), + ]); + + // Prefer the authoritative header id; fall back to the filename UUID when the header is unreadable. + const remoteSessionId = header?.id?.trim() || params.session.id; + const cwd = header?.cwd?.trim() || null; + + return { + remoteSessionId, + ...(title ? { title } : {}), + updatedAtMs: params.session.mtimeMs, + activity: deriveDirectSessionActivityFromTimestamp({ updatedAtMs: params.session.mtimeMs, env: params.env }), + details: { + ...(cwd ? { cwd } : {}), + sessionDirName: params.session.dirName, + }, + }; +} + +export async function listPiSessionCandidates(params: Readonly<{ + source: DirectSessionsSource; + env?: NodeJS.ProcessEnv; + cursor?: string; + limit: number; + searchTerm?: string; + searchMode?: 'fast' | 'full'; +}>): Promise> { + const env = params.env ?? process.env; + const startedAtMs = Date.now(); + const agentDir = resolvePiAgentDir({ source: params.source, env }); + const sessionsDir = join(agentDir, 'sessions'); + const concurrency = resolvePiDiscoveryConcurrency(env); + const limit = Math.max(1, Math.trunc(params.limit)); + const offset = decodeIndexCursor(params.cursor); + + const rawSearchTerm = typeof params.searchTerm === 'string' ? params.searchTerm.trim() : ''; + const searchTerm = rawSearchTerm.toLowerCase(); + + let dirEntries: Dirent[]; + try { + dirEntries = await readdir(sessionsDir, { withFileTypes: true }); + } catch { + dirEntries = []; + } + + // Phase 1: stat every session file. The session id is the filename UUID, so no header read is + // needed for discovery — headers/titles are read only for the page slice (Phase 2). + const discoveredSessions = ( + await mapWithConcurrency(dirEntries, concurrency, async (dirEntry): Promise => { + if (!dirEntry.isDirectory()) return []; + if (dirEntry.isSymbolicLink()) return []; + const dirName = typeof dirEntry.name === 'string' ? dirEntry.name : String(dirEntry.name); + if (!dirName || dirName.includes('/') || dirName.includes('\\')) return []; + + let fileEntries: Dirent[]; + try { + fileEntries = await readdir(join(sessionsDir, dirName), { withFileTypes: true }); + } catch { + return []; + } + + const sessions = await mapWithConcurrency(fileEntries, concurrency, async (fe): Promise => { + if (!fe.isFile()) return null; + if (fe.isSymbolicLink()) return null; + const fileName = typeof fe.name === 'string' ? fe.name : String(fe.name); + const id = extractPiSessionIdFromFilename(fileName); + if (!id) return null; + const filePath = join(sessionsDir, dirName, fileName); + try { + const s = await stat(filePath); + if (!s.isFile()) return null; + return { id, dirName, fileName, filePath, mtimeMs: Math.trunc(s.mtimeMs) }; + } catch { + return null; + } + }); + + return sessions.filter((session): session is DiscoveredPiSession => session !== null); + }) + ).flat(); + + const sortedSessions = discoveredSessions.sort( + (a, b) => b.mtimeMs - a.mtimeMs || String(a.id).localeCompare(String(b.id)), + ); + + // Exact-id fast path: when the search term is a bare session id, resolve straight to that file + // (authoritative, no scan-order dependence). + if (searchTerm && !rawSearchTerm.includes('/')) { + const resolved = await resolvePiDirectSessionFile({ source: params.source, env, remoteSessionId: rawSearchTerm }).catch(() => null); + if (resolved) { + let exactStat: Awaited> | null = null; + try { + exactStat = await stat(resolved.filePath); + } catch { + exactStat = null; + } + if (exactStat?.isFile()) { + const pageOffset = Math.min(offset, 1); + if (pageOffset > 0) { + return { candidates: [], nextCursor: null }; + } + const candidate = await buildPiCandidate({ + session: { + id: rawSearchTerm, + dirName: '', + fileName: '', + filePath: resolved.filePath, + mtimeMs: Math.trunc(exactStat.mtimeMs), + }, + env, + }); + return { candidates: [candidate], nextCursor: null }; + } + } + } + + let searchIncomplete = false; + let searchedPage: DirectSessionCandidateV1[] | null = null; + + if (searchTerm) { + if (params.searchMode === 'fast') { + searchIncomplete = true; + const metadataMatches = sortedSessions.filter((session) => { + const haystack = `${session.id} ${session.dirName}`.toLowerCase(); + return haystack.includes(searchTerm); + }); + const page = metadataMatches.slice(offset, offset + limit); + searchedPage = await mapWithConcurrency(page, concurrency, (session) => buildPiCandidate({ session, env })); + } else { + const searchCandidateLimit = resolvePiSearchCandidateLimit(env); + const sessionsToSearch = sortedSessions.slice(0, searchCandidateLimit); + searchIncomplete = sessionsToSearch.length < sortedSessions.length; + const withTitles = await mapWithConcurrency(sessionsToSearch, concurrency, async (session): Promise => { + const candidate = await buildPiCandidate({ session, env }); + const haystack = `${candidate.remoteSessionId} ${session.dirName}${candidate.title ? ` ${candidate.title}` : ''}`.toLowerCase(); + return haystack.includes(searchTerm) ? candidate : null; + }); + const filtered = withTitles.filter((candidate): candidate is DirectSessionCandidateV1 => candidate !== null); + searchedPage = filtered.slice(offset, offset + limit); + } + } + + const page = + searchedPage + ?? await mapWithConcurrency(sortedSessions.slice(offset, offset + limit), concurrency, (session) => buildPiCandidate({ session, env })); + + const filteredCount = searchTerm + ? (searchedPage ? Math.max(sortedSessions.length, offset + page.length) : sortedSessions.length) + : sortedSessions.length; + const nextOffset = offset + page.length; + const nextCursor = nextOffset < filteredCount ? encodeIndexCursor(nextOffset) : null; + + logger.debug('[directSessions.pi.candidates] list finished', { + elapsedMs: Date.now() - startedAtMs, + searchTermLength: rawSearchTerm.length, + searchMode: params.searchMode ?? 'default', + discoveredSessions: sortedSessions.length, + returnedCandidates: page.length, + hasNextCursor: Boolean(nextCursor), + searchIncomplete, + }); + + return { + candidates: page, + nextCursor, + ...(searchIncomplete ? { searchIncomplete: true } : {}), + }; +} diff --git a/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts new file mode 100644 index 000000000..96c97ceaa --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from 'vitest'; + +import type { DirectTranscriptRawMessageV1 } from '@happier-dev/protocol'; + +import type { PiSessionEntry } from './piEntryContext'; +import { mapPiSessionToDirectMessages } from './mapPiSessionToDirectMessages'; + +function entry(partial: Partial & Pick): PiSessionEntry { + return { + parentId: null, + timestamp: '2024-12-03T14:00:00.000Z', + ...partial, + } as PiSessionEntry; +} + +function user(id: string, parentId: string | null, text: string, ts = '2024-12-03T14:00:01.000Z'): PiSessionEntry { + return entry({ type: 'message', id, parentId, timestamp: ts, message: { role: 'user', content: text, timestamp: Date.parse(ts) } }); +} + +function assistant(id: string, parentId: string | null, text: string, ts = '2024-12-03T14:00:02.000Z'): PiSessionEntry { + return entry({ + type: 'message', + id, + parentId, + timestamp: ts, + message: { role: 'assistant', content: [{ type: 'text', text }], provider: 'anthropic', model: 'claude', usage: {}, stopReason: 'stop', timestamp: Date.parse(ts) }, + }); +} + +function toolResult(id: string, parentId: string | null, ts = '2024-12-03T14:00:03.000Z'): PiSessionEntry { + return entry({ + type: 'message', + id, + parentId, + timestamp: ts, + message: { role: 'toolResult', toolCallId: 'call_1', toolName: 'bash', content: [{ type: 'text', text: 'ok' }], isError: false, timestamp: Date.parse(ts) }, + }); +} + +const FILE_REL = 'projects/sample.jsonl'; + +function ids(items: readonly DirectTranscriptRawMessageV1[]): string[] { + return items.map((item) => item.id); +} + +function roles(items: readonly DirectTranscriptRawMessageV1[]): string[] { + return items.map((item) => item.messageRole ?? ''); +} + +describe('mapPiSessionToDirectMessages', () => { + it('maps a linear user -> assistant -> toolResult branch into three ordered transcript items', () => { + const entries = [ + user('a1b2c3d4', null, 'hello'), + assistant('b2c3d4e5', 'a1b2c3d4', 'hi!'), + toolResult('c3d4e5f6', 'b2c3d4e5'), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + expect(items).toHaveLength(3); + expect(ids(items)).toEqual([ + `pi:${FILE_REL}:a1b2c3d4`, + `pi:${FILE_REL}:b2c3d4e5`, + `pi:${FILE_REL}:c3d4e5f6`, + ]); + expect(roles(items)).toEqual(['user', 'agent', 'event']); + expect(items[0]!.createdAtMs).toBe(Date.parse('2024-12-03T14:00:01.000Z')); + // user text is preserved on raw + expect((items[0]!.raw as any).role).toBe('user'); + expect((items[0]!.raw as any).content).toBe('hello'); + }); + + it('imports only the active (last-in-file) branch and excludes the abandoned sibling', () => { + // a -> b (abandoned), a -> b' (active). Only [a, b'] must surface. + const entries = [ + user('aaaa0001', null, 'prompt'), + assistant('bbbb0001', 'aaaa0001', 'abandoned branch'), + assistant('bbbb0002', 'aaaa0001', 'active branch'), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + expect(ids(items)).toEqual([`pi:${FILE_REL}:aaaa0001`, `pi:${FILE_REL}:bbbb0002`]); + expect((items[1]!.raw as any).content[0].text).toBe('active branch'); + }); + + it('honors an explicit leafId to select a non-default branch', () => { + const entries = [ + user('aaaa0001', null, 'prompt'), + assistant('bbbb0001', 'aaaa0001', 'abandoned branch'), + assistant('bbbb0002', 'aaaa0001', 'active branch'), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL, leafId: 'bbbb0001' }); + expect(ids(items)).toEqual([`pi:${FILE_REL}:aaaa0001`, `pi:${FILE_REL}:bbbb0001`]); + }); + + it('drops entries summarized before the latest compaction and emits the compaction as an event', () => { + const entries = [ + user('aaaa0001', null, 'old prompt'), + assistant('summariz', 'aaaa0001', 'summarized away'), + user('kept00001', 'summariz', 'kept prompt'), + entry({ type: 'compaction', id: 'comp00001', parentId: 'kept00001', firstKeptEntryId: 'kept00001', summary: 'earlier work', tokensBefore: 5000 }), + assistant('aftercmp', 'comp00001', 'after compaction'), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + // [compaction, kept00001, aftercmp] — aaaa0001 and summariz are dropped + expect(ids(items)).toEqual([ + `pi:${FILE_REL}:comp00001`, + `pi:${FILE_REL}:kept00001`, + `pi:${FILE_REL}:aftercmp`, + ]); + expect(items[0]!.messageRole).toBe('event'); + expect((items[0]!.raw as any).role).toBe('compactionSummary'); + }); + + it('skips non-context entries (model_change, thinking_level_change, label, custom) entirely', () => { + const entries = [ + user('aaaa0001', null, 'prompt'), + entry({ type: 'model_change', id: 'mchn0001', parentId: 'aaaa0001', provider: 'openai', modelId: 'gpt-4o' }), + entry({ type: 'thinking_level_change', id: 'tlnk0001', parentId: 'mchn0001', thinkingLevel: 'high' }), + entry({ type: 'label', id: 'lbl00001', parentId: 'tlnk0001', targetId: 'aaaa0001', label: 'checkpoint' }), + entry({ type: 'custom', id: 'cst00001', parentId: 'lbl00001', customType: 'ext', data: { x: 1 } }), + assistant('bbbb0001', 'cst00001', 'reply'), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + // only the two message entries surface; the path-walk includes the metadata entries but + // projection drops them + expect(ids(items)).toEqual([`pi:${FILE_REL}:aaaa0001`, `pi:${FILE_REL}:bbbb0001`]); + }); + + it('classifies bashExecution tool output as an event', () => { + const entries = [ + user('aaaa0001', null, 'run ls'), + entry({ + type: 'message', + id: 'bbbb0001', + parentId: 'aaaa0001', + message: { role: 'bashExecution', command: 'ls', output: 'a\nb', exitCode: 0, cancelled: false, truncated: false, timestamp: 1 }, + }), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + expect(roles(items)).toEqual(['user', 'event']); + }); + + it('returns [] for an empty session', () => { + expect(mapPiSessionToDirectMessages({ entries: [], fileRelPath: FILE_REL })).toEqual([]); + }); + + it('treats a message with null content as an empty-content message rather than dropping it', () => { + const entries = [ + entry({ type: 'message', id: 'aaaa0001', parentId: null, message: { role: 'assistant', content: null } }), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + expect(items).toHaveLength(1); + expect((items[0]!.raw as any).content).toEqual([]); + }); +}); diff --git a/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts new file mode 100644 index 000000000..c7e524a5e --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts @@ -0,0 +1,110 @@ +import type { DirectTranscriptRawMessageV1, SessionMessageRole } from '@happier-dev/protocol'; + +import { buildContextEntries, type PiSessionEntry } from './piEntryContext'; + +/** + * Map a parsed pi session (full entry list) into direct-transcript items, resolving the active + * branch via `buildContextEntries` and projecting each context entry the same way pi's own + * `sessionEntryToContextMessages` does. Unlike the Claude line-by-line mapper, pi needs the whole + * file because the active branch is a tree walk, not a linear scan. + */ +export function mapPiSessionToDirectMessages(params: Readonly<{ + entries: readonly PiSessionEntry[]; + fileRelPath: string; + leafId?: string | null; +}>): DirectTranscriptRawMessageV1[] { + const contextEntries = buildContextEntries(params.entries, params.leafId); + const items: DirectTranscriptRawMessageV1[] = []; + + for (const entry of contextEntries) { + const message = projectPiEntryToMessage(entry); + if (!message) continue; + + const role = typeof (message as { role?: unknown }).role === 'string' + ? ((message as { role: string }).role) + : undefined; + const id = `pi:${params.fileRelPath}:${entry.id}`; + + items.push({ + id, + localId: id, + createdAtMs: resolvePiEntryTimestampMs(entry, message), + messageRole: resolvePiMessageRole(role), + raw: message, + }); + } + + return items; +} + +/** + * Port of pi's `sessionEntryToContextMessages`: project one selected entry into its pi AgentMessage + * form, or `null` when the entry does not participate in LLM context (model_change, + * thinking_level_change, label, plain custom). Message entries with null/missing content are + * normalized to an empty content array, matching pi's defensive parsing. + */ +function projectPiEntryToMessage(entry: PiSessionEntry): Record | null { + if (entry.type === 'message') { + const message = (entry as { message?: unknown }).message; + if (!message || typeof message !== 'object' || Array.isArray(message)) return null; + const msg = message as Record & { content?: unknown }; + if (msg.content == null) { + return { ...msg, content: [] }; + } + return { ...msg }; + } + if (entry.type === 'custom_message') { + return { + role: 'custom', + customType: (entry as { customType?: unknown }).customType, + content: (entry as { content?: unknown }).content ?? [], + display: (entry as { display?: unknown }).display, + details: (entry as { details?: unknown }).details, + timestamp: entry.timestamp, + }; + } + if (entry.type === 'branch_summary') { + const summary = (entry as { summary?: unknown }).summary; + if (!summary) return null; + return { + role: 'branchSummary', + summary, + fromId: (entry as { fromId?: unknown }).fromId, + timestamp: entry.timestamp, + }; + } + if (entry.type === 'compaction') { + return { + role: 'compactionSummary', + summary: (entry as { summary?: unknown }).summary, + tokensBefore: (entry as { tokensBefore?: unknown }).tokensBefore, + timestamp: entry.timestamp, + }; + } + return null; +} + +function resolvePiMessageRole(role: string | undefined): SessionMessageRole { + if (role === 'user') return 'user'; + if (role === 'assistant') return 'agent'; + // toolResult, bashExecution, custom, custom_message-inferred, branchSummary, compactionSummary + return 'event'; +} + +function resolvePiEntryTimestampMs(entry: PiSessionEntry, message: Record): number { + const fromEntry = timestampToMs(entry.timestamp); + if (fromEntry > 0) return fromEntry; + return timestampToMs(message.timestamp); +} + +function timestampToMs(value: unknown): number { + if (typeof value === 'string' && value.trim()) { + const ms = Date.parse(value); + if (Number.isFinite(ms) && ms >= 0) return Math.trunc(ms); + } + if (typeof value === 'number' && Number.isFinite(value) && value >= 0) { + // Heuristic: seconds vs milliseconds (same rule as the Claude mapper). + return value < 1_000_000_000_000 ? Math.trunc(value * 1000) : Math.trunc(value); + } + return 0; +} diff --git a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts new file mode 100644 index 000000000..43ebd838f --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts @@ -0,0 +1,127 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import type { DirectSessionsSource, DirectTranscriptRawMessageV1 } from '@happier-dev/protocol'; + +import { pagePiTranscript } from './pagePiTranscript'; + +const SESSION_ID = '019f4a42-4617-767a-8e7c-189b454a0352'; + +function writeSession(agentDir: string, lines: readonly object[]): { source: DirectSessionsSource; env: NodeJS.ProcessEnv } { + const sessionsDir = join(agentDir, 'sessions', '--proj--'); + mkdirSync(sessionsDir, { recursive: true }); + const filePath = join(sessionsDir, `2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl`); + writeFileSync(filePath, lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + return { source: { kind: 'piAgentDir' }, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir } }; +} + +function freshAgentDir(): string { + return mkdtempSync(join(tmpdir(), 'pi-page-')); +} + +const header = { type: 'session', id: SESSION_ID, timestamp: '2024-12-03T14:00:00.000Z', cwd: '/proj', version: 3 }; + +function msg(id: string, parentId: string | null, role: string, text: string, ts: string): object { + return { type: 'message', id, parentId, timestamp: ts, message: { role, content: [{ type: 'text', text }], timestamp: Date.parse(ts) } }; +} + +/** Mirror importDirectSessionTranscript's accumulation: collect older pages, reverse, flatten. */ +async function importAll(source: DirectSessionsSource, env: NodeJS.ProcessEnv, opts: { maxBytes: number; maxItems: number }): Promise { + const pages: DirectTranscriptRawMessageV1[][] = []; + let cursor: string | undefined; + // eslint-disable-next-line no-constant-condition + while (true) { + const page = await pagePiTranscript({ source, env, remoteSessionId: SESSION_ID, direction: 'older', cursor, ...opts }); + if (page.items.length > 0) pages.push(page.items.slice()); + if (!page.hasMore || !page.nextCursor) break; + cursor = page.nextCursor; + } + const ordered: DirectTranscriptRawMessageV1[] = []; + for (let i = pages.length - 1; i >= 0; i -= 1) ordered.push(...pages[i]!); + return ordered; +} + +describe('pagePiTranscript', () => { + it('walks a linear session to completion in chronological order', async () => { + const agentDir = freshAgentDir(); + const { source, env } = writeSession(agentDir, [ + header, + msg('m1', null, 'user', 'one', '2024-12-03T14:00:01.000Z'), + msg('m2', 'm1', 'assistant', 'two', '2024-12-03T14:00:02.000Z'), + msg('m3', 'm2', 'user', 'three', '2024-12-03T14:00:03.000Z'), + msg('m4', 'm3', 'assistant', 'four', '2024-12-03T14:00:04.000Z'), + ]); + + const ordered = await importAll(source, env, { maxBytes: 1024 * 1024, maxItems: 2 }); + expect(ordered.map((i) => i.id)).toEqual([ + `pi:sessions/--proj--/2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl:m1`, + `pi:sessions/--proj--/2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl:m2`, + `pi:sessions/--proj--/2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl:m3`, + `pi:sessions/--proj--/2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl:m4`, + ]); + expect(ordered.map((i) => i.createdAtMs)).toEqual([ + Date.parse('2024-12-03T14:00:01.000Z'), + Date.parse('2024-12-03T14:00:02.000Z'), + Date.parse('2024-12-03T14:00:03.000Z'), + Date.parse('2024-12-03T14:00:04.000Z'), + ]); + }); + + it('pages only the active branch, excluding the abandoned sibling', async () => { + const agentDir = freshAgentDir(); + const { source, env } = writeSession(agentDir, [ + header, + msg('m1', null, 'user', 'prompt', '2024-12-03T14:00:01.000Z'), + msg('m2', 'm1', 'assistant', 'abandoned branch', '2024-12-03T14:00:02.000Z'), + msg('m3', 'm1', 'assistant', 'active branch', '2024-12-03T14:00:03.000Z'), + ]); + + const ordered = await importAll(source, env, { maxBytes: 1024 * 1024, maxItems: 10 }); + // only m1 + m3 (active leaf = m3, the last in file) + expect(ordered.map((i) => i.id)).toHaveLength(2); + expect((ordered[1]!.raw as { content: Array<{ text: string }> }).content[0]!.text).toBe('active branch'); + }); + + it('returns empty and no cursor for a missing session', async () => { + const agentDir = freshAgentDir(); + // empty agent dir (no sessions) + const source: DirectSessionsSource = { kind: 'piAgentDir' }; + const env = { ...process.env, PI_CODING_AGENT_DIR: agentDir }; + const page = await pagePiTranscript({ source, env, remoteSessionId: SESSION_ID, direction: 'older', maxBytes: 1024, maxItems: 10 }); + expect(page.items).toEqual([]); + expect(page.nextCursor).toBeNull(); + expect(page.hasMore).toBe(false); + }); + + it('returns empty for the newer direction (v1 uses readAfter for tail)', async () => { + const agentDir = freshAgentDir(); + const { source, env } = writeSession(agentDir, [header, msg('m1', null, 'user', 'one', '2024-12-03T14:00:01.000Z')]); + const page = await pagePiTranscript({ source, env, remoteSessionId: SESSION_ID, direction: 'newer', maxBytes: 1024, maxItems: 10 }); + expect(page.items).toEqual([]); + expect(page.hasMore).toBe(false); + }); + + it('honors maxItems by splitting across pages without losing items', async () => { + const agentDir = freshAgentDir(); + const { source, env } = writeSession(agentDir, [ + header, + msg('m1', null, 'user', '1', '2024-12-03T14:00:01.000Z'), + msg('m2', 'm1', 'assistant', '2', '2024-12-03T14:00:02.000Z'), + msg('m3', 'm2', 'user', '3', '2024-12-03T14:00:03.000Z'), + msg('m4', 'm3', 'assistant', '4', '2024-12-03T14:00:04.000Z'), + msg('m5', 'm4', 'user', '5', '2024-12-03T14:00:05.000Z'), + ]); + + const ordered = await importAll(source, env, { maxBytes: 1024 * 1024, maxItems: 2 }); + expect(ordered).toHaveLength(5); + expect(ordered.map((i) => i.createdAtMs)).toEqual([ + Date.parse('2024-12-03T14:00:01.000Z'), + Date.parse('2024-12-03T14:00:02.000Z'), + Date.parse('2024-12-03T14:00:03.000Z'), + Date.parse('2024-12-03T14:00:04.000Z'), + Date.parse('2024-12-03T14:00:05.000Z'), + ]); + }); +}); diff --git a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts new file mode 100644 index 000000000..a7be93040 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts @@ -0,0 +1,153 @@ +import { readFile } from 'node:fs/promises'; + +import type { DirectSessionsSource, DirectTranscriptRawMessageV1 } from '@happier-dev/protocol'; + +import type { PiSessionEntry } from './piEntryContext'; +import { mapPiSessionToDirectMessages } from './mapPiSessionToDirectMessages'; +import { resolvePiDirectSessionFile } from './resolvePiDirectSessionFile'; + +type PiBackwardCursorV1 = Readonly<{ v: 1; kind: 'piBackward'; consumed: number }>; +type PiForwardCursorV1 = Readonly<{ v: 1; kind: 'piForward'; delivered: number }>; + +function encodeBackwardCursor(value: PiBackwardCursorV1): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); +} + +function decodeBackwardCursor(raw: string | undefined): number { + if (typeof raw !== 'string' || raw.trim().length === 0) return 0; + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object') return 0; + const value = parsed as Record; + if (value.v !== 1 || value.kind !== 'piBackward') return 0; + const consumed = typeof value.consumed === 'number' && Number.isFinite(value.consumed) ? value.consumed : 0; + return Math.max(0, Math.trunc(consumed)); + } catch { + return 0; + } +} + +export function encodePiForwardCursor(value: PiForwardCursorV1): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); +} + +export function decodePiForwardCursor(raw: string | undefined): number { + if (typeof raw !== 'string' || raw.trim().length === 0) return 0; + try { + const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as unknown; + if (!parsed || typeof parsed !== 'object') return 0; + const value = parsed as Record; + if (value.v !== 1 || value.kind !== 'piForward') return 0; + const delivered = typeof value.delivered === 'number' && Number.isFinite(value.delivered) ? value.delivered : 0; + return Math.max(0, Math.trunc(delivered)); + } catch { + return 0; + } +} + +/** + * Parse a whole pi session JSONL file into its raw entries. Pi sessions are trees, so the active + * branch cannot be resolved incrementally; the full entry list is required for the tree walk. + */ +export async function loadPiSessionEntries(filePath: string): Promise { + const content = await readFile(filePath, 'utf8').catch(() => ''); + const entries: PiSessionEntry[] = []; + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + entries.push(parsed as PiSessionEntry); + } + } catch { + // Skip malformed lines (matches pi's own parseSessionEntryLine). + } + } + return entries; +} + +async function loadMappedItems( + filePath: string, + fileRelPath: string, +): Promise { + const entries = await loadPiSessionEntries(filePath); + return mapPiSessionToDirectMessages({ entries, fileRelPath }); +} + +function itemByteSize(item: DirectTranscriptRawMessageV1): number { + try { + return Buffer.byteLength(JSON.stringify(item.raw), 'utf8'); + } catch { + return 0; + } +} + +/** + * Page a pi direct-session transcript. Pi pages the projected active-branch item list rather than + * raw file bytes: the `older` direction walks backward from the newest item (the import flow), + * returning each page in chronological order so the caller's page-reversal reconstructs full + * chronological order. `consumed` counts items already delivered from the end. + */ +export async function pagePiTranscript(params: Readonly<{ + source: DirectSessionsSource; + env?: NodeJS.ProcessEnv; + remoteSessionId: string; + direction: 'older' | 'newer'; + cursor?: string; + maxBytes: number; + maxItems: number; +}>): Promise> { + const resolved = await resolvePiDirectSessionFile({ + source: params.source, + env: params.env, + remoteSessionId: params.remoteSessionId, + }); + if (!resolved) { + return { items: [], nextCursor: null, tailCursor: null, hasMore: false }; + } + + // Forward paging is not required for v1 UI flows (tail uses readAfter). + if (params.direction !== 'older') { + return { items: [], nextCursor: null, tailCursor: null, hasMore: false }; + } + + const items = await loadMappedItems(resolved.filePath, resolved.fileRelPath); + const total = items.length; + const consumed = decodeBackwardCursor(params.cursor); + const tailCursor = encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: total }); + + const remaining = total - consumed; + if (remaining <= 0) { + return { items: [], nextCursor: null, tailCursor, hasMore: false }; + } + + const maxItems = Math.max(1, Math.trunc(params.maxItems)); + const maxBytes = Math.max(1, Math.trunc(params.maxBytes)); + + const pageStart = Math.max(0, total - consumed - maxItems); + const pageEndExclusive = total - consumed; + + const pageItems: DirectTranscriptRawMessageV1[] = []; + let bytesUsed = 0; + for (let i = pageStart; i < pageEndExclusive; i += 1) { + const item = items[i]!; + if (pageItems.length >= maxItems) break; + const size = itemByteSize(item); + if (pageItems.length > 0 && bytesUsed + size > maxBytes) break; + pageItems.push(item); + bytesUsed += size; + } + + const newConsumed = consumed + pageItems.length; + const hasMore = newConsumed < total; + const nextCursor = hasMore ? encodeBackwardCursor({ v: 1, kind: 'piBackward', consumed: newConsumed }) : null; + + return { items: pageItems, nextCursor, tailCursor, hasMore }; +} diff --git a/apps/cli/src/backends/pi/directSessions/piEntryContext.test.ts b/apps/cli/src/backends/pi/directSessions/piEntryContext.test.ts new file mode 100644 index 000000000..2b0797126 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/piEntryContext.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, it } from 'vitest'; + +import { + buildContextEntries, + buildSessionPath, + resolveActiveLeafId, + type PiSessionEntry, +} from './piEntryContext'; + +// Minimal entry factory. Timestamps are ISO strings on real pi entries. +function entry(partial: Partial & Pick): PiSessionEntry { + return { + parentId: null, + timestamp: '2024-12-03T14:00:00.000Z', + ...partial, + } as PiSessionEntry; +} + +const header = { type: 'session', id: 'root-uuid', timestamp: '2024-12-03T14:00:00.000Z', version: 3, cwd: '/proj' }; + +describe('piEntryContext', () => { + describe('resolveActiveLeafId', () => { + it('returns the last non-header entry id (mirrors pi _buildIndex)', () => { + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'b2c3d4e5', parentId: 'a1b2c3d4' }), + ]; + expect(resolveActiveLeafId(entries)).toBe('b2c3d4e5'); + }); + + it('skips the session header when present', () => { + const entries = [ + header, + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'c3d4e5f6', parentId: 'a1b2c3d4' }), + ]; + expect(resolveActiveLeafId(entries)).toBe('c3d4e5f6'); + }); + + it('returns null when there are no non-header entries', () => { + expect(resolveActiveLeafId([header as any])).toBeNull(); + expect(resolveActiveLeafId([])).toBeNull(); + }); + }); + + describe('buildSessionPath', () => { + it('walks a linear branch root -> leaf', () => { + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'b2c3d4e5', parentId: 'a1b2c3d4' }), + entry({ type: 'message', id: 'c3d4e5f6', parentId: 'b2c3d4e5' }), + ]; + expect(buildSessionPath(entries).map((e) => e.id)).toEqual(['a1b2c3d4', 'b2c3d4e5', 'c3d4e5f6']); + }); + + it('defaults the leaf to the last-in-file entry, so the active branch excludes abandoned siblings', () => { + // root a -> b, and a -> b' where b' was appended later (b' is the active leaf) + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'bbbbbbbb', parentId: 'a1b2c3d4' }), + entry({ type: 'message', id: 'b2c3d4e5', parentId: 'a1b2c3d4' }), + ]; + expect(buildSessionPath(entries).map((e) => e.id)).toEqual(['a1b2c3d4', 'b2c3d4e5']); + }); + + it('honors an explicit leafId to select a non-default branch', () => { + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'bbbbbbbb', parentId: 'a1b2c3d4' }), + entry({ type: 'message', id: 'b2c3d4e5', parentId: 'a1b2c3d4' }), + ]; + expect(buildSessionPath(entries, 'bbbbbbbb').map((e) => e.id)).toEqual(['a1b2c3d4', 'bbbbbbbb']); + }); + + it('returns [] when leafId is null', () => { + const entries: PiSessionEntry[] = [entry({ type: 'message', id: 'a1b2c3d4', parentId: null })]; + expect(buildSessionPath(entries, null)).toEqual([]); + }); + }); + + describe('buildContextEntries', () => { + it('returns the full path when there is no compaction', () => { + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'b2c3d4e5', parentId: 'a1b2c3d4' }), + ]; + expect(buildContextEntries(entries).map((e) => e.id)).toEqual(['a1b2c3d4', 'b2c3d4e5']); + }); + + it('drops entries before the latest compaction firstKeptEntryId, keeps compaction + kept tail + post-compaction', () => { + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'message', id: 'summarized1', parentId: 'a1b2c3d4' }), + entry({ type: 'message', id: 'keptstart', parentId: 'summarized1' }), + entry({ type: 'message', id: 'keptnext', parentId: 'keptstart' }), + entry({ type: 'compaction', id: 'comp12345', parentId: 'keptnext', firstKeptEntryId: 'keptstart', summary: '...' }), + entry({ type: 'message', id: 'aftercmp', parentId: 'comp12345' }), + ]; + expect(buildContextEntries(entries).map((e) => e.id)).toEqual(['comp12345', 'keptstart', 'keptnext', 'aftercmp']); + }); + + it('uses the latest compaction when multiple are on the path', () => { + const entries: PiSessionEntry[] = [ + entry({ type: 'message', id: 'a1b2c3d4', parentId: null }), + entry({ type: 'compaction', id: 'oldcomp12', parentId: 'a1b2c3d4', firstKeptEntryId: 'a1b2c3d4', summary: 'old' }), + entry({ type: 'message', id: 'midmsg12', parentId: 'oldcomp12' }), + entry({ type: 'compaction', id: 'newcomp12', parentId: 'midmsg12', firstKeptEntryId: 'midmsg12', summary: 'new' }), + entry({ type: 'message', id: 'afternew', parentId: 'newcomp12' }), + ]; + // latest compaction wins: [newcomp, midmsg, afternew] + expect(buildContextEntries(entries).map((e) => e.id)).toEqual(['newcomp12', 'midmsg12', 'afternew']); + }); + }); +}); diff --git a/apps/cli/src/backends/pi/directSessions/piEntryContext.ts b/apps/cli/src/backends/pi/directSessions/piEntryContext.ts new file mode 100644 index 000000000..5cdf1650c --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/piEntryContext.ts @@ -0,0 +1,128 @@ +/** + * Pi session tree-walk helpers, ported from pi's own SessionManager + * (dist/core/session-manager.js: buildSessionPath, buildContextEntries, _buildIndex). + * + * Pi session files are JSONL trees keyed by `id`/`parentId`. The "active branch" is the path + * from the current leaf to the root, folded at the latest compaction entry. These helpers + * reproduce pi's own active-context resolution so direct-session import matches what a resumed + * pi session actually sees — not a second divergent tree resolver. + * + * The session header (`type: 'session'`) is not part of the tree and is excluded everywhere. + */ + +export interface PiSessionEntry { + readonly type: string; + readonly id: string; + // Optional because the `session` header entry carries no parentId; headers are filtered out of + // all tree walks before parentId is read. + readonly parentId?: string | null; + /** ISO timestamp string on real pi entries. */ + readonly timestamp?: string; + /** Present on `compaction` entries; the first entry id retained after summarization. */ + readonly firstKeptEntryId?: string; + readonly [key: string]: unknown; +} + +/** + * Index non-header entries by id (mirrors pi's `buildEntryIndex`, header-excluded). + */ +function indexEntries(entries: readonly PiSessionEntry[]): Map { + const index = new Map(); + for (const entry of entries) { + if (entry.type === 'session') continue; + index.set(entry.id, entry); + } + return index; +} + +function nonHeaderEntries(entries: readonly PiSessionEntry[]): PiSessionEntry[] { + return entries.filter((entry) => entry.type !== 'session'); +} + +/** + * Resolve the active leaf id on load: the last non-header entry in file order. + * Mirrors pi's `_buildIndex`, which assigns `leafId` at each iteration so the final entry wins. + * There is no persisted leaf pointer in the file; this is fully re-derivable from contents. + */ +export function resolveActiveLeafId(entries: readonly PiSessionEntry[]): string | null { + let leafId: string | null = null; + for (const entry of entries) { + if (entry.type === 'session') continue; + leafId = entry.id; + } + return leafId; +} + +/** + * Walk from the leaf to the root via `parentId`, returning the path in root -> leaf order. + * When `leafId` is omitted, defaults to the last non-header entry (pi's load default). + * When `leafId` is explicitly `null`, returns `[]` (pi's reset-leaf semantics). + */ +export function buildSessionPath( + entries: readonly PiSessionEntry[], + leafId?: string | null, +): PiSessionEntry[] { + if (leafId === null) return []; + const index = indexEntries(entries); + let leaf: PiSessionEntry | undefined; + if (leafId) { + leaf = index.get(leafId); + } + leaf ??= nonHeaderEntries(entries).at(-1); + if (!leaf) return []; + + const path: PiSessionEntry[] = []; + let current: PiSessionEntry | undefined = leaf; + while (current) { + path.push(current); + current = current.parentId ? index.get(current.parentId) : undefined; + } + path.reverse(); + return path; +} + +/** + * Build the compaction-aware active entry list. Mirrors pi's `buildContextEntries`: + * 1. take the leaf -> root path; + * 2. find the latest compaction entry on it; + * 3. if none, return the whole path; + * 4. otherwise return [compaction, …entries from firstKeptEntryId up to (not incl.) compaction, + * …entries after compaction], dropping older summarized entries. + * + * Note: pi's installed SessionManager honors `firstKeptEntryId` only; it does not expand + * `retainedTail`. This port matches that behavior. + */ +export function buildContextEntries( + entries: readonly PiSessionEntry[], + leafId?: string | null, +): PiSessionEntry[] { + const path = buildSessionPath(entries, leafId); + let compaction: PiSessionEntry | null = null; + for (const entry of path) { + if (entry.type === 'compaction') { + compaction = entry; + } + } + if (!compaction) { + return path; + } + const compactionEntry = compaction; + const compactionIdx = path.findIndex((entry) => entry.id === compactionEntry.id); + if (compactionIdx < 0) { + return path; + } + + const contextEntries: PiSessionEntry[] = [compactionEntry]; + let foundFirstKept = false; + for (let i = 0; i < compactionIdx; i += 1) { + const entry = path[i]!; + if (entry.id === compactionEntry.firstKeptEntryId) { + foundFirstKept = true; + } + if (foundFirstKept) { + contextEntries.push(entry); + } + } + contextEntries.push(...path.slice(compactionIdx + 1)); + return contextEntries; +} diff --git a/apps/cli/src/backends/pi/directSessions/providerOps.ts b/apps/cli/src/backends/pi/directSessions/providerOps.ts new file mode 100644 index 000000000..c349beb47 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/providerOps.ts @@ -0,0 +1,82 @@ +import { createPollingDirectSessionFollowLease } from '@/api/directSessions/backgroundFollow/createPollingDirectSessionFollowLease'; +import { + mergeDirectSessionEnvironmentVariables, + type DirectSessionProviderOps, +} from '@/backends/directSessions/providerOps'; + +import { getPiDirectSessionActivity } from './getPiDirectSessionActivity'; +import { getPiDirectSessionWorkingDirectory } from './getPiDirectSessionWorkingDirectory'; +import { listPiSessionCandidates } from './listPiSessionCandidates'; +import { pagePiTranscript } from './pagePiTranscript'; +import { readAfterPiTranscript } from './readAfterPiTranscript'; +import { resolvePiAgentDir } from './resolvePiAgentDir'; + +export const piDirectSessionProviderOps: DirectSessionProviderOps = { + listCandidates: async ({ source, cursor, limit, searchTerm, searchMode }) => { + const res = await listPiSessionCandidates({ source, cursor, limit, searchTerm, searchMode }); + return { + candidates: res.candidates, + nextCursor: res.nextCursor ?? null, + ...(res.searchIncomplete ? { searchIncomplete: true } : {}), + }; + }, + + getActivity: async ({ source, remoteSessionId }) => { + const res = await getPiDirectSessionActivity({ source, remoteSessionId, env: process.env }); + return { + lastActivityAtMs: + typeof res.lastActivityAtMs === 'number' && Number.isFinite(res.lastActivityAtMs) + ? res.lastActivityAtMs + : null, + // No live process probe in the direct-session model; liveness is owned by the follow-lease. + isRunning: false, + }; + }, + + pageTranscript: async ({ source, remoteSessionId, direction, cursor, maxBytes, maxItems }) => { + const res = await pagePiTranscript({ source, remoteSessionId, direction, cursor, maxBytes, maxItems, env: process.env }); + return { + items: res.items, + nextCursor: res.nextCursor ?? null, + tailCursor: res.tailCursor ?? null, + hasMore: res.hasMore, + truncated: res.truncated === true, + }; + }, + + readAfterTranscript: async ({ source, remoteSessionId, cursor, maxBytes, maxItems }) => { + const res = await readAfterPiTranscript({ source, remoteSessionId, cursor, maxBytes, maxItems, env: process.env }); + return { items: res.items, nextCursor: res.nextCursor ?? null, truncated: res.truncated === true }; + }, + + acquireFollowLease: async ({ source, remoteSessionId }) => + createPollingDirectSessionFollowLease({ + readAfterTranscript: ({ cursor, maxBytes, maxItems }) => + readAfterPiTranscript({ source, remoteSessionId, cursor, maxBytes, maxItems, env: process.env }), + }), + + resolveTakeoverSpawnOptions: async ({ linked, sessionId }) => { + // Resume the pi session in place (pi --session ), launched from the session's own working + // directory (read from the authoritative header cwd, not the ambiguously-encoded dir name). + // PI_CODING_AGENT_DIR points pi at the same ~/.pi/agent the discovery scanner read from. + const agentDir = resolvePiAgentDir({ source: linked.source, env: process.env }); + const directory = + linked.sessionPath ?? + (await getPiDirectSessionWorkingDirectory({ + source: linked.source, + remoteSessionId: linked.remoteSessionId, + env: process.env, + })); + if (!directory) return null; + + return { + directory, + backendTarget: { kind: 'builtInAgent', agentId: 'pi' }, + existingSessionId: sessionId, + resume: linked.remoteSessionId, + approvedNewDirectoryCreation: true, + transcriptStorage: 'direct', + environmentVariables: mergeDirectSessionEnvironmentVariables([{ PI_CODING_AGENT_DIR: agentDir }]), + }; + }, +}; diff --git a/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts b/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts new file mode 100644 index 000000000..86d819447 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts @@ -0,0 +1,58 @@ +import type { DirectSessionsSource, DirectTranscriptRawMessageV1 } from '@happier-dev/protocol'; + +import { mapPiSessionToDirectMessages } from './mapPiSessionToDirectMessages'; +import { decodePiForwardCursor, encodePiForwardCursor, loadPiSessionEntries } from './pagePiTranscript'; +import { resolvePiDirectSessionFile } from './resolvePiDirectSessionFile'; + +/** + * Read pi transcript items appended after a forward cursor (item count already delivered from the + * start of the active branch). Used by the polling follow-lease to tail a live session. Because the + * active branch is recomputed from the whole file each call, branch switches mid-follow are handled + * approximately; the common steady-growth case (new entries appended to the same leaf) is exact. + */ +export async function readAfterPiTranscript(params: Readonly<{ + source: DirectSessionsSource; + env?: NodeJS.ProcessEnv; + remoteSessionId: string; + cursor: string; + maxBytes: number; + maxItems: number; +}>): Promise> { + const resolved = await resolvePiDirectSessionFile({ + source: params.source, + env: params.env, + remoteSessionId: params.remoteSessionId, + }); + if (!resolved) { + return { items: [], nextCursor: null, truncated: false }; + } + + const entries = await loadPiSessionEntries(resolved.filePath); + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: resolved.fileRelPath }); + const total = items.length; + + const delivered = Math.min(Math.max(0, decodePiForwardCursor(params.cursor)), total); + const maxItems = Math.max(1, Math.trunc(params.maxItems)); + const maxBytes = Math.max(1, Math.trunc(params.maxBytes)); + + const pageItems: DirectTranscriptRawMessageV1[] = []; + let bytesUsed = 0; + for (let i = delivered; i < total; i += 1) { + const item = items[i]!; + if (pageItems.length >= maxItems) break; + const size = Buffer.byteLength(JSON.stringify(item.raw), 'utf8'); + if (pageItems.length > 0 && bytesUsed + size > maxBytes) break; + pageItems.push(item); + bytesUsed += size; + } + + const newDelivered = delivered + pageItems.length; + const truncated = newDelivered < total; + const nextCursor = truncated ? encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: newDelivered }) : null; + + return { items: pageItems, nextCursor, truncated }; +} diff --git a/apps/cli/src/backends/pi/directSessions/readPiSessionHeader.ts b/apps/cli/src/backends/pi/directSessions/readPiSessionHeader.ts new file mode 100644 index 000000000..96be6f9a7 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/readPiSessionHeader.ts @@ -0,0 +1,55 @@ +import { open } from 'node:fs/promises'; + +/** + * The parsed pi session header (the first JSONL line, `type: 'session'`). The header is metadata + * only and is not part of the entry tree. + */ +export type PiSessionHeader = Readonly<{ + id: string; + cwd: string; + timestamp: string; + version?: number; + parentSession?: string; +}>; + +const HEADER_READ_BUFFER_BYTES = 64 * 1024; + +/** + * Read and parse the first JSONL line of a pi session file. Returns null on any read/parse failure + * or when the first line is not a `session` header. + */ +export async function readPiSessionHeader(filePath: string): Promise { + let fh: Awaited> | undefined; + try { + fh = await open(filePath, 'r'); + const buffer = Buffer.alloc(HEADER_READ_BUFFER_BYTES); + const { bytesRead } = await fh.read(buffer, 0, buffer.length, 0); + const chunk = buffer.subarray(0, bytesRead).toString('utf8'); + const newlineIdx = chunk.indexOf('\n'); + const firstLine = newlineIdx >= 0 ? chunk.slice(0, newlineIdx) : chunk; + const trimmed = firstLine.trim(); + if (!trimmed) return null; + + const parsed = JSON.parse(trimmed) as Record; + if (!parsed || parsed.type !== 'session') return null; + + const id = typeof parsed.id === 'string' ? parsed.id : ''; + const cwd = typeof parsed.cwd === 'string' ? parsed.cwd : ''; + const timestamp = typeof parsed.timestamp === 'string' ? parsed.timestamp : ''; + if (!id) return null; + + return { + id, + cwd, + timestamp, + ...(typeof parsed.version === 'number' ? { version: parsed.version } : {}), + ...(typeof parsed.parentSession === 'string' && parsed.parentSession.trim() + ? { parentSession: parsed.parentSession } + : {}), + }; + } catch { + return null; + } finally { + await fh?.close().catch(() => undefined); + } +} diff --git a/apps/cli/src/backends/pi/directSessions/readPiSessionTitle.test.ts b/apps/cli/src/backends/pi/directSessions/readPiSessionTitle.test.ts new file mode 100644 index 000000000..f50c23799 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/readPiSessionTitle.test.ts @@ -0,0 +1,54 @@ +import { mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import { readPiSessionTitle } from './readPiSessionTitle'; + +function sessionFile(lines: readonly object[]): string { + const dir = mkdtempSync(join(tmpdir(), 'pi-title-')); + const filePath = join(dir, '2024-12-03T14-00-00-000Z_019f4a42-4617-767a-8e7c-189b454a0352.jsonl'); + writeFileSync(filePath, lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + return filePath; +} + +const header = { type: 'session', id: '019f4a42-4617-767a-8e7c-189b454a0352', timestamp: '2024-12-03T14:00:00.000Z', cwd: '/proj', version: 3 }; + +describe('readPiSessionTitle', () => { + it('prefers the latest session_info name over the first user message', async () => { + const filePath = sessionFile([ + header, + { type: 'message', id: 'a1b2c3d4', parentId: null, timestamp: '2024-12-03T14:00:01.000Z', message: { role: 'user', content: 'first user prompt' } }, + { type: 'session_info', id: 'sinf0001', parentId: 'a1b2c3d4', timestamp: '2024-12-03T14:00:02.000Z', name: 'Named by user' }, + ]); + await expect(readPiSessionTitle(filePath)).resolves.toBe('Named by user'); + }); + + it('falls back to the first user message text when no session_info name is set', async () => { + const filePath = sessionFile([ + header, + { type: 'message', id: 'a1b2c3d4', parentId: null, timestamp: '2024-12-03T14:00:01.000Z', message: { role: 'user', content: [{ type: 'text', text: 'array-form user prompt' }] } }, + { type: 'message', id: 'b2c3d4e5', parentId: 'a1b2c3d4', timestamp: '2024-12-03T14:00:02.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'reply' }] } }, + ]); + await expect(readPiSessionTitle(filePath)).resolves.toBe('array-form user prompt'); + }); + + it('returns null when there is no session_info name and no user message text', async () => { + const filePath = sessionFile([ + header, + { type: 'message', id: 'a1b2c3d4', parentId: null, timestamp: '2024-12-03T14:00:01.000Z', message: { role: 'assistant', content: [{ type: 'text', text: 'no user yet' }] } }, + ]); + await expect(readPiSessionTitle(filePath)).resolves.toBeNull(); + }); + + it('uses the latest session_info entry when multiple are present', async () => { + const filePath = sessionFile([ + header, + { type: 'message', id: 'a1b2c3d4', parentId: null, timestamp: '2024-12-03T14:00:01.000Z', message: { role: 'user', content: 'x' } }, + { type: 'session_info', id: 'sinf0001', parentId: 'a1b2c3d4', timestamp: '2024-12-03T14:00:02.000Z', name: 'old name' }, + { type: 'message', id: 'cccc0001', parentId: 'sinf0001', timestamp: '2024-12-03T14:00:03.000Z', message: { role: 'user', content: 'y' } }, + { type: 'session_info', id: 'sinf0002', parentId: 'cccc0001', timestamp: '2024-12-03T14:00:04.000Z', name: 'newest name' }, + ]); + await expect(readPiSessionTitle(filePath)).resolves.toBe('newest name'); + }); +}); diff --git a/apps/cli/src/backends/pi/directSessions/readPiSessionTitle.ts b/apps/cli/src/backends/pi/directSessions/readPiSessionTitle.ts new file mode 100644 index 000000000..09e2ae229 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/readPiSessionTitle.ts @@ -0,0 +1,78 @@ +import { readJsonlFileForward } from '@/api/directSessions/filePaging/jsonlForwardReader'; +import { readDirectSessionTitleCandidate } from '@/api/directSessions/title/readDirectSessionTitleCandidate'; + +const TITLE_SCAN_CHUNK_MAX_BYTES = 128 * 1024; +const TITLE_SCAN_CHUNK_MAX_ITEMS = 64; +const TITLE_SCAN_TOTAL_MAX_BYTES = 1024 * 1024; +const TITLE_SCAN_TOTAL_MAX_ITEMS = 512; + +function coerceTextContent(content: unknown): string | null { + if (typeof content === 'string') { + return readDirectSessionTitleCandidate(content); + } + if (!Array.isArray(content)) return null; + + const parts = content + .map((item) => { + if (!item || typeof item !== 'object' || Array.isArray(item)) return ''; + const text = (item as Record).text; + return typeof text === 'string' ? text : ''; + }) + .filter((part) => part.trim().length > 0); + + return parts.length > 0 ? readDirectSessionTitleCandidate(parts.join(' ')) : null; +} + +/** + * Read a pi session display title. Pi stores the user-defined name on the latest `session_info` + * entry; when none is set, fall back to the first user message text. Both are cleaned through the + * shared `readDirectSessionTitleCandidate` boilerplate filter. + */ +export async function readPiSessionTitle(filePath: string): Promise { + let sessionInfoName: string | null = null; + let userFallback: string | null = null; + let offsetBytes = 0; + let scannedBytes = 0; + let scannedItems = 0; + + while (scannedBytes < TITLE_SCAN_TOTAL_MAX_BYTES && scannedItems < TITLE_SCAN_TOTAL_MAX_ITEMS) { + const page = await readJsonlFileForward({ + filePath, + offsetBytes, + maxBytes: Math.min(TITLE_SCAN_CHUNK_MAX_BYTES, TITLE_SCAN_TOTAL_MAX_BYTES - scannedBytes), + maxItems: Math.min(TITLE_SCAN_CHUNK_MAX_ITEMS, TITLE_SCAN_TOTAL_MAX_ITEMS - scannedItems), + }); + + for (const line of page.items) { + const value = line.value; + if (!value || typeof value !== 'object' || Array.isArray(value)) continue; + const record = value as Record; + const type = typeof record.type === 'string' ? record.type : ''; + + if (type === 'session_info') { + const name = coerceTextContent(record.name); + // Latest session_info wins, matching pi's own `getSessionName` semantics. + if (name) sessionInfoName = name; + continue; + } + + if (type === 'message' && userFallback === null) { + const message = record.message; + if (message && typeof message === 'object' && !Array.isArray(message)) { + const msg = message as Record; + if (msg.role === 'user') { + const title = coerceTextContent(msg.content); + if (title) userFallback = title; + } + } + } + } + + if (page.reachedEnd || page.nextOffsetBytes <= offsetBytes) break; + scannedBytes += Math.max(0, page.nextOffsetBytes - offsetBytes); + scannedItems += page.items.length; + offsetBytes = page.nextOffsetBytes; + } + + return sessionInfoName ?? userFallback; +} diff --git a/apps/cli/src/backends/pi/directSessions/resolvePiAgentDir.ts b/apps/cli/src/backends/pi/directSessions/resolvePiAgentDir.ts new file mode 100644 index 000000000..3c249665c --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/resolvePiAgentDir.ts @@ -0,0 +1,31 @@ +import { join } from 'node:path'; + +import type { DirectSessionsSource } from '@happier-dev/protocol'; +import { expandHomeDirPath, resolveHomeDirFromEnvironment } from '@happier-dev/cli-common/providers'; + +/** + * Resolve the pi agent directory (`~/.pi/agent`) for direct-session operations. Precedence mirrors + * pi's own `getDefaultAgentDir`: explicit `source.agentDir`, then `PI_CODING_AGENT_DIR`, then + * `/.pi/agent`. This is the pi equivalent of Claude's `resolveClaudeConfigDir`. + */ +export function resolvePiAgentDir(params: Readonly<{ + source: DirectSessionsSource; + env: NodeJS.ProcessEnv; +}>): string { + const env = params.env; + if (params.source.kind === 'piAgentDir') { + const fromSource = typeof params.source.agentDir === 'string' ? params.source.agentDir.trim() : ''; + if (fromSource) { + const expanded = expandHomeDirPath(fromSource, env); + if (expanded) return expanded; + } + } + + const fromEnv = typeof env.PI_CODING_AGENT_DIR === 'string' ? env.PI_CODING_AGENT_DIR.trim() : ''; + if (fromEnv) { + const expanded = expandHomeDirPath(fromEnv, env); + if (expanded) return expanded; + } + + return join(resolveHomeDirFromEnvironment(env), '.pi', 'agent'); +} diff --git a/apps/cli/src/backends/pi/directSessions/resolvePiDirectSessionFile.ts b/apps/cli/src/backends/pi/directSessions/resolvePiDirectSessionFile.ts new file mode 100644 index 000000000..02cd67962 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/resolvePiDirectSessionFile.ts @@ -0,0 +1,99 @@ +import { type Dirent } from 'node:fs'; +import { readdir, stat } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { DirectSessionsSource } from '@happier-dev/protocol'; + +import { resolvePiAgentDir } from './resolvePiAgentDir'; + +export type ResolvedPiDirectSessionFile = Readonly<{ + filePath: string; + fileRelPath: string; +}>; + +function isSafeSegment(value: string): boolean { + if (!value) return false; + if (value.includes('/') || value.includes('\\')) return false; + if (value === '.' || value === '..') return false; + return true; +} + +/** + * Extract the session UUID from a pi session filename (`_.jsonl` or + * `.jsonl`). The UUID is the segment after the final underscore. + */ +export function extractPiSessionIdFromFilename(fileName: string): string | null { + if (!fileName.endsWith('.jsonl')) return null; + const base = fileName.slice(0, -'.jsonl'.length); + if (!base) return null; + const lastUnderscore = base.lastIndexOf('_'); + const id = lastUnderscore >= 0 ? base.slice(lastUnderscore + 1) : base; + return id || null; +} + +/** + * Resolve a pi session file by remote session id (UUID). Scans every `sessions/----/` + * directory under the agent dir because a session's working directory is not known ahead of time + * and the directory name encodes cwd ambiguously. When the same id appears in multiple directories, + * the most recently modified file wins (mirrors Claude's project-spanning resolution). + */ +export async function resolvePiDirectSessionFile(params: Readonly<{ + source: DirectSessionsSource; + env?: NodeJS.ProcessEnv; + remoteSessionId: string; +}>): Promise { + const env = params.env ?? process.env; + const remoteSessionId = String(params.remoteSessionId ?? '').trim(); + if (!isSafeSegment(remoteSessionId)) return null; + + const agentDir = resolvePiAgentDir({ source: params.source, env }); + const sessionsDir = join(agentDir, 'sessions'); + + let dirEntries: Dirent[]; + try { + dirEntries = await readdir(sessionsDir, { withFileTypes: true }); + } catch { + return null; + } + + let best: { filePath: string; fileRelPath: string; mtimeMs: number } | null = null; + + for (const dirEntry of dirEntries) { + if (!dirEntry.isDirectory()) continue; + if (dirEntry.isSymbolicLink()) continue; + const dirName = typeof dirEntry.name === 'string' ? dirEntry.name : String(dirEntry.name); + if (!isSafeSegment(dirName)) continue; + + let fileEntries: Dirent[]; + try { + fileEntries = await readdir(join(sessionsDir, dirName), { withFileTypes: true }); + } catch { + continue; + } + + for (const fileEntry of fileEntries) { + if (!fileEntry.isFile()) continue; + if (fileEntry.isSymbolicLink()) continue; + const name = typeof fileEntry.name === 'string' ? fileEntry.name : String(fileEntry.name); + const idFromFile = extractPiSessionIdFromFilename(name); + if (idFromFile !== remoteSessionId) continue; + + const filePath = join(sessionsDir, dirName, name); + try { + const s = await stat(filePath); + if (!s.isFile()) continue; + if (!best || s.mtimeMs > best.mtimeMs) { + best = { + filePath, + fileRelPath: `sessions/${dirName}/${name}`.replace(/\\/g, '/'), + mtimeMs: Math.trunc(s.mtimeMs), + }; + } + } catch { + // ignore unreadable candidate + } + } + } + + return best ? { filePath: best.filePath, fileRelPath: best.fileRelPath } : null; +} diff --git a/apps/cli/src/backends/pi/index.ts b/apps/cli/src/backends/pi/index.ts index e7e02acfb..7b1442bb6 100644 --- a/apps/cli/src/backends/pi/index.ts +++ b/apps/cli/src/backends/pi/index.ts @@ -64,6 +64,7 @@ export const agent = { resolveConnectedServiceCandidatePersistedSessionFile: resolvePiConnectedServiceCandidatePersistedSessionFile, verifyResumeReachable: async (input) => await (await import('@/backends/pi/connectedServices/verifyResumeReachablePi')).verifyResumeReachablePi(input), + getDirectSessionProviderOps: async () => (await import('./directSessions/providerOps')).piDirectSessionProviderOps, getSessionUsageLimitRecoveryControlAdapter: async () => piUsageLimitRecoveryControlAdapter, getDaemonSpawnHooks: async () => piDaemonSpawnHooks, vendorResumeSupport: AGENTS_CORE.pi.resume.vendorResume, diff --git a/packages/protocol/src/directSessions/daemonRpcV1.ts b/packages/protocol/src/directSessions/daemonRpcV1.ts index 45e304db2..f8cfd6e0f 100644 --- a/packages/protocol/src/directSessions/daemonRpcV1.ts +++ b/packages/protocol/src/directSessions/daemonRpcV1.ts @@ -52,10 +52,19 @@ const DirectSessionsOpenCodeServerSourceSchema = z }) .passthrough(); +const DirectSessionsPiAgentDirSourceSchema = z + .object({ + kind: z.literal('piAgentDir'), + // Resolved ~/.pi/agent directory; falls back to env PI_CODING_AGENT_DIR / ~/.pi/agent when nullish. + agentDir: z.string().min(1).max(10_000).nullish(), + }) + .passthrough(); + export const DirectSessionsSourceSchema = z.discriminatedUnion('kind', [ DirectSessionsCodexHomeSourceSchema, DirectSessionsClaudeConfigSourceSchema, DirectSessionsOpenCodeServerSourceSchema, + DirectSessionsPiAgentDirSourceSchema, ]); export type DirectSessionsSource = z.infer; diff --git a/packages/protocol/src/index.exports.test.ts b/packages/protocol/src/index.exports.test.ts index cae0ee4e4..750d83d8a 100644 --- a/packages/protocol/src/index.exports.test.ts +++ b/packages/protocol/src/index.exports.test.ts @@ -125,6 +125,10 @@ describe('protocol package root exports', () => { expect((protocol as any).DirectSessionsProviderIdSchema.parse('codex')).toBe('codex'); expect((protocol as any).DirectSessionsProviderIdSchema.parse('claude')).toBe('claude'); expect((protocol as any).DirectSessionsProviderIdSchema.parse('opencode')).toBe('opencode'); + expect((protocol as any).DirectSessionsProviderIdSchema.parse('pi')).toBe('pi'); + expect((protocol as any).DirectSessionsSourceSchema.safeParse({ kind: 'piAgentDir' }).success).toBe(true); + expect((protocol as any).DirectSessionsSourceSchema.safeParse({ kind: 'piAgentDir', agentDir: '/custom/.pi/agent' }).success).toBe(true); + expect((protocol as any).DirectSessionsSourceSchema.safeParse({ kind: 'piAgentDir', agentDir: '' }).success).toBe(false); expect(typeof (protocol as any).DirectSessionsCandidatesListRequestSchema?.safeParse).toBe('function'); expect(typeof (protocol as any).DirectTranscriptPageRequestSchema?.safeParse).toBe('function'); expect(typeof (protocol as any).DirectTranscriptReadAfterRequestSchema?.safeParse).toBe('function'); diff --git a/packages/protocol/src/providers/agentProviderIdsV1.ts b/packages/protocol/src/providers/agentProviderIdsV1.ts index 4b0a2b734..b87695e07 100644 --- a/packages/protocol/src/providers/agentProviderIdsV1.ts +++ b/packages/protocol/src/providers/agentProviderIdsV1.ts @@ -2,7 +2,7 @@ import { z } from 'zod'; // Intentionally scoped: this is the subset of providers that participate in v1 daemon-facing // provider ids (direct sessions, handoff resume plans, MCP detection). -export const AGENT_PROVIDER_IDS_V1 = ['claude', 'codex', 'opencode'] as const; +export const AGENT_PROVIDER_IDS_V1 = ['claude', 'codex', 'opencode', 'pi'] as const; export type AgentProviderIdV1 = (typeof AGENT_PROVIDER_IDS_V1)[number]; From c9598f3244662c241f62c7c0c84bf0f604ba2661 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 9 Aug 2026 13:18:28 +0700 Subject: [PATCH 05/11] feat(direct-sessions): link pi direct sessions with source-keyed identity Add pi arms to the four provider-discrimination sites in ensureDirectSessionLink so pi direct sessions link with correct identity: resolveSourceKey produces `piAgentDir:` (was falling to 'unknown', which collided across different PI_CODING_AGENT_DIR scopes), buildDirectSessionMetadata writes `piSessionId`, resolveMetadataRemoteSessionId reads it back, and resolveMarkerProviderId recognizes pi-flavored daemon markers (flavor + backendTarget agentId). The identity-merge path intentionally mirrors Claude (no provider-specific re-sync block); the generic directSessionV1 update handles refresh. With this, pi reaches full direct-session parity with Claude/Codex/OpenCode across discovery, import, follow, takeover spawn, and linking. --- .../linking/ensureDirectSessionLink.test.ts | 111 ++++++++++++++++++ .../linking/ensureDirectSessionLink.ts | 19 ++- 2 files changed, 127 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.test.ts b/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.test.ts index b8e3845b7..7157987fb 100644 --- a/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.test.ts +++ b/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.test.ts @@ -608,4 +608,115 @@ describe('ensureDirectSessionLink', () => { expect(JSON.stringify(updatedMetadata)).not.toContain('OPENCODE_AUTH_CONTENT'); expect(JSON.stringify(updatedMetadata)).not.toContain('must-not-be-copied'); }); + + it('creates a pi direct link with piSessionId metadata and a piAgentDir source key', async () => { + getOrCreateSessionByTagMock.mockResolvedValueOnce({ + session: { id: 'sess_direct_pi_1', metadata: {} }, + }); + + const result = await ensureDirectSessionLink({ + credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } }, + machineId: 'machine_1', + providerId: 'pi', + remoteSessionId: 'pi_sess_1', + source: { kind: 'piAgentDir', agentDir: '/home/user/.pi/agent' }, + titleHint: 'Pi linked session', + directoryHint: '/repo', + nowMs: () => 123, + }); + + const expectedTag = `direct:v1:${sha256Hex('machine_1|pi|pi_sess_1|piAgentDir:/home/user/.pi/agent')}`; + expect(result.tag).toBe(expectedTag); + expect(getOrCreateSessionByTagMock.mock.calls[0]?.[0]?.tag).toBe(expectedTag); + const createdMetadata = getOrCreateSessionByTagMock.mock.calls[0]?.[0]?.metadata; + expect(createdMetadata).toMatchObject({ + flavor: 'pi', + piSessionId: 'pi_sess_1', + directSessionV1: { + v: 1, + providerId: 'pi', + machineId: 'machine_1', + remoteSessionId: 'pi_sess_1', + source: { kind: 'piAgentDir', agentDir: '/home/user/.pi/agent' }, + }, + }); + }); + + it('discriminates pi direct sessions by agentDir so identical remote ids do not collide', async () => { + getOrCreateSessionByTagMock.mockResolvedValueOnce({ session: { id: 'sess_direct_pi_a', metadata: {} } }); + getOrCreateSessionByTagMock.mockResolvedValueOnce({ session: { id: 'sess_direct_pi_b', metadata: {} } }); + + const resultAlice = await ensureDirectSessionLink({ + credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } }, + machineId: 'machine_1', + providerId: 'pi', + remoteSessionId: 'pi_sess_shared', + source: { kind: 'piAgentDir', agentDir: '/home/alice/.pi/agent' }, + nowMs: () => 123, + }); + const resultBob = await ensureDirectSessionLink({ + credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } }, + machineId: 'machine_1', + providerId: 'pi', + remoteSessionId: 'pi_sess_shared', + source: { kind: 'piAgentDir', agentDir: '/home/bob/.pi/agent' }, + nowMs: () => 123, + }); + + expect(resultAlice.tag).not.toBe(resultBob.tag); + expect(resultAlice.tag).toBe(`direct:v1:${sha256Hex('machine_1|pi|pi_sess_shared|piAgentDir:/home/alice/.pi/agent')}`); + expect(resultBob.tag).toBe(`direct:v1:${sha256Hex('machine_1|pi|pi_sess_shared|piAgentDir:/home/bob/.pi/agent')}`); + }); + + it('recognizes a pi daemon marker by flavor and resolves its remoteSessionId from piSessionId metadata', async () => { + const connectedServices = { + v: 1, + bindingsByServiceId: { + 'openai-codex': { source: 'connected', selection: 'group', groupId: 'happier', profileId: 'work' }, + }, + } satisfies ConnectedServiceBindingsV1; + const materializationIdentity = { + v: 1, + id: 'csm_pi_link', + createdAtMs: 1_718_719_900_000, + } satisfies ConnectedServiceMaterializationIdentityV1; + listSessionMarkersMock.mockResolvedValueOnce([ + { + pid: 12345, + updatedAt: 200, + flavor: 'pi', + cwd: '/repo', + metadata: { flavor: 'pi', path: '/repo', piSessionId: 'pi_connected' }, + respawn: { + version: 1, + directory: '/repo', + backendTarget: { kind: 'builtInAgent', agentId: 'pi' }, + connectedServices, + connectedServicesUpdatedAt: 1_718_719_899_000, + connectedServiceMaterializationIdentityV1: materializationIdentity, + }, + }, + ]); + getOrCreateSessionByTagMock.mockResolvedValueOnce({ + session: { id: 'sess_direct_pi_connected', metadata: {} }, + }); + + await ensureDirectSessionLink({ + credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } }, + machineId: 'machine_1', + providerId: 'pi', + remoteSessionId: 'pi_connected', + source: { kind: 'piAgentDir', agentDir: '/home/user/.pi/agent' }, + directoryHint: '/repo', + nowMs: () => 123, + }); + + const createdMetadata = getOrCreateSessionByTagMock.mock.calls[0]?.[0]?.metadata; + expect(createdMetadata).toMatchObject({ + connectedServices, + connectedServicesUpdatedAt: 1_718_719_899_000, + connectedServiceMaterializationIdentityV1: materializationIdentity, + directSessionV1: { remoteSessionId: 'pi_connected' }, + }); + }); }); diff --git a/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.ts b/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.ts index 501705a86..06b4acbb4 100644 --- a/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.ts +++ b/apps/cli/src/api/directSessions/linking/ensureDirectSessionLink.ts @@ -80,6 +80,11 @@ function resolveMetadataRemoteSessionId( if (openCodeSessionId) return openCodeSessionId; break; } + case 'pi': { + const piSessionId = normalizeNullableString(metadata.piSessionId); + if (piSessionId) return piSessionId; + break; + } } const runtimeDescriptor = asMetadataRecord(metadata.agentRuntimeDescriptorV1); @@ -91,16 +96,16 @@ function resolveMetadataRemoteSessionId( function resolveMarkerProviderId(marker: DaemonSessionMarker): DirectSessionsProviderId | null { const metadata = asMetadataRecord(marker.metadata); const metadataFlavor = normalizeNullableString(metadata?.flavor); - if (metadataFlavor === 'claude' || metadataFlavor === 'codex' || metadataFlavor === 'opencode') { + if (metadataFlavor === 'claude' || metadataFlavor === 'codex' || metadataFlavor === 'opencode' || metadataFlavor === 'pi') { return metadataFlavor; } - if (marker.flavor === 'claude' || marker.flavor === 'codex' || marker.flavor === 'opencode') { + if (marker.flavor === 'claude' || marker.flavor === 'codex' || marker.flavor === 'opencode' || marker.flavor === 'pi') { return marker.flavor; } const respawn = asMetadataRecord(marker.respawn); const backendTarget = asMetadataRecord(respawn?.backendTarget); const agentId = normalizeNullableString(backendTarget?.agentId); - return agentId === 'claude' || agentId === 'codex' || agentId === 'opencode' ? agentId : null; + return agentId === 'claude' || agentId === 'codex' || agentId === 'opencode' || agentId === 'pi' ? agentId : null; } function resolveMarkerRemoteSessionId(marker: DaemonSessionMarker, providerId: DirectSessionsProviderId): string | null { @@ -378,6 +383,11 @@ function resolveSourceKey(providerId: DirectSessionsProviderId, source: DirectSe const directory = normalizeNullableString(source.directory) ?? ''; return `opencodeServer:${baseUrl}:${directory}`; } + case 'pi': { + if (source.kind !== 'piAgentDir') return 'piAgentDir:invalid'; + const agentDir = normalizeNullableString(source.agentDir) ?? ''; + return `piAgentDir:${agentDir}`; + } default: return 'unknown'; } @@ -679,6 +689,9 @@ function buildDirectSessionMetadata(params: Readonly<{ case 'claude': base.claudeSessionId = params.remoteSessionId; break; + case 'pi': + base.piSessionId = params.remoteSessionId; + break; case 'opencode': base.opencodeSessionId = params.remoteSessionId; if (params.runtimeDescriptor?.providerId === 'opencode') { From 4d028c3a4e113c75937d6b761fa83774a8fbc121 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 9 Aug 2026 14:47:32 +0700 Subject: [PATCH 06/11] fix(direct-sessions): page pi transcripts without overlap when maxBytes truncates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backward pager's cursor tracked a `consumed` item count and derived the next page window as `total - consumed - maxItems`. When maxBytes truncated a page below maxItems, the next window overlapped the just-delivered region, producing duplicated/gapped items and out-of-order reconstruction — corrupting imports of any substantial pi session. Fixtures used a large maxBytes so never triggered it. Switch to an `endExclusive` cursor: each page collects newest-first within [endExclusive-maxItems, endExclusive), byte-limit truncates the older end, and the next page window begins exactly where this one stopped. Pages stay gap-free, overlap-free, and reconstruct into full chronological order regardless of truncation. Adds a regression test (maxBytes: 512) that failed before the fix. --- .../directSessions/pagePiTranscript.test.ts | 26 +++++++++ .../pi/directSessions/pagePiTranscript.ts | 54 ++++++++++--------- 2 files changed, 54 insertions(+), 26 deletions(-) diff --git a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts index 43ebd838f..f1ea4b01c 100644 --- a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts +++ b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts @@ -124,4 +124,30 @@ describe('pagePiTranscript', () => { Date.parse('2024-12-03T14:00:05.000Z'), ]); }); + + it('reconstructs chronologically with no duplicates or gaps when maxBytes truncates pages below maxItems', async () => { + // Regression: byte-truncation must not overlap the next page's window. With small maxBytes each + // page delivers fewer items than maxItems; the reconstruction must still be chronological, + // gap-free, and duplicate-free across the whole active branch. + const agentDir = freshAgentDir(); + const { source, env } = writeSession(agentDir, [ + header, + msg('m1', null, 'user', 'message one', '2024-12-03T14:00:01.000Z'), + msg('m2', 'm1', 'assistant', 'message two', '2024-12-03T14:00:02.000Z'), + msg('m3', 'm2', 'user', 'message three', '2024-12-03T14:00:03.000Z'), + msg('m4', 'm3', 'assistant', 'message four', '2024-12-03T14:00:04.000Z'), + msg('m5', 'm4', 'user', 'message five', '2024-12-03T14:00:05.000Z'), + msg('m6', 'm5', 'assistant', 'message six', '2024-12-03T14:00:06.000Z'), + ]); + + const ordered = await importAll(source, env, { maxBytes: 512, maxItems: 10 }); + // all six, no duplicates + expect(ordered).toHaveLength(6); + expect(new Set(ordered.map((i) => i.id)).size).toBe(6); + // strictly chronological + for (let i = 1; i < ordered.length; i += 1) { + expect(ordered[i]!.createdAtMs).toBeGreaterThanOrEqual(ordered[i - 1]!.createdAtMs); + } + expect(ordered.map((i) => i.id).map((id) => id.slice(-2))).toEqual(['m1', 'm2', 'm3', 'm4', 'm5', 'm6']); + }); }); diff --git a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts index a7be93040..a87be9614 100644 --- a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts +++ b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.ts @@ -6,24 +6,25 @@ import type { PiSessionEntry } from './piEntryContext'; import { mapPiSessionToDirectMessages } from './mapPiSessionToDirectMessages'; import { resolvePiDirectSessionFile } from './resolvePiDirectSessionFile'; -type PiBackwardCursorV1 = Readonly<{ v: 1; kind: 'piBackward'; consumed: number }>; +type PiBackwardCursorV1 = Readonly<{ v: 1; kind: 'piBackward'; endExclusive: number }>; type PiForwardCursorV1 = Readonly<{ v: 1; kind: 'piForward'; delivered: number }>; function encodeBackwardCursor(value: PiBackwardCursorV1): string { return Buffer.from(JSON.stringify(value), 'utf8').toString('base64url'); } -function decodeBackwardCursor(raw: string | undefined): number { - if (typeof raw !== 'string' || raw.trim().length === 0) return 0; +function decodeBackwardCursor(raw: string | undefined): number | null { + if (typeof raw !== 'string' || raw.trim().length === 0) return null; try { const parsed = JSON.parse(Buffer.from(raw, 'base64url').toString('utf8')) as unknown; - if (!parsed || typeof parsed !== 'object') return 0; + if (!parsed || typeof parsed !== 'object') return null; const value = parsed as Record; - if (value.v !== 1 || value.kind !== 'piBackward') return 0; - const consumed = typeof value.consumed === 'number' && Number.isFinite(value.consumed) ? value.consumed : 0; - return Math.max(0, Math.trunc(consumed)); + if (value.v !== 1 || value.kind !== 'piBackward') return null; + const endExclusive = typeof value.endExclusive === 'number' && Number.isFinite(value.endExclusive) ? value.endExclusive : NaN; + if (!Number.isFinite(endExclusive) || endExclusive < 0) return null; + return Math.trunc(endExclusive); } catch { - return 0; + return null; } } @@ -120,34 +121,35 @@ export async function pagePiTranscript(params: Readonly<{ const items = await loadMappedItems(resolved.filePath, resolved.fileRelPath); const total = items.length; - const consumed = decodeBackwardCursor(params.cursor); + const maxItems = Math.max(1, Math.trunc(params.maxItems)); + const maxBytes = Math.max(1, Math.trunc(params.maxBytes)); const tailCursor = encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: total }); - const remaining = total - consumed; - if (remaining <= 0) { + // Backward paging uses an `endExclusive` cursor: each page delivers a contiguous block ending at + // endExclusive, collected newest-first so byte-limit truncation cuts the OLDER end and the next + // page's window begins exactly where this one stopped. This keeps pages gap-free, overlap-free, + // and reconstructable into full chronological order even when maxBytes truncates below maxItems. + const decoded = decodeBackwardCursor(params.cursor); + const endExclusive = decoded === null ? total : Math.min(Math.max(0, decoded), total); + if (endExclusive <= 0) { return { items: [], nextCursor: null, tailCursor, hasMore: false }; } - const maxItems = Math.max(1, Math.trunc(params.maxItems)); - const maxBytes = Math.max(1, Math.trunc(params.maxBytes)); - - const pageStart = Math.max(0, total - consumed - maxItems); - const pageEndExclusive = total - consumed; - - const pageItems: DirectTranscriptRawMessageV1[] = []; + const windowStart = Math.max(0, endExclusive - maxItems); + const collected: DirectTranscriptRawMessageV1[] = []; let bytesUsed = 0; - for (let i = pageStart; i < pageEndExclusive; i += 1) { + for (let i = endExclusive - 1; i >= windowStart && collected.length < maxItems; i -= 1) { const item = items[i]!; - if (pageItems.length >= maxItems) break; const size = itemByteSize(item); - if (pageItems.length > 0 && bytesUsed + size > maxBytes) break; - pageItems.push(item); + if (collected.length > 0 && bytesUsed + size > maxBytes) break; + collected.push(item); bytesUsed += size; } + collected.reverse(); // newest-first collection → chronological intra-page order - const newConsumed = consumed + pageItems.length; - const hasMore = newConsumed < total; - const nextCursor = hasMore ? encodeBackwardCursor({ v: 1, kind: 'piBackward', consumed: newConsumed }) : null; + const newEndExclusive = endExclusive - collected.length; + const hasMore = newEndExclusive > 0; + const nextCursor = hasMore ? encodeBackwardCursor({ v: 1, kind: 'piBackward', endExclusive: newEndExclusive }) : null; - return { items: pageItems, nextCursor, tailCursor, hasMore }; + return { items: collected, nextCursor, tailCursor, hasMore }; } From 126480f1dd9b417c434e10b0ee5d5e6818e00c04 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 9 Aug 2026 16:16:19 +0700 Subject: [PATCH 07/11] fix(direct-sessions): validate pi machine source at the daemon RPC gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit validateDirectMachineSource had a closed switch over the provider enum with only codex/claude/opencode arms and a default rejection. Once 'pi' joined AgentProviderIdV1, TypeScript compiled but every pi direct-session RPC request failed at runtime with 'unsupported direct session provider' — a silent daemon->pi wiring break the integration test was written to surface (and did). Add the pi arm mirroring the claude security model: the configured agent dir is daemon-controlled (env PI_CODING_AGENT_DIR or default ~/.pi/agent, resolved via resolvePiAgentDir), a client may omit agentDir, and a supplied agentDir must match the configured dir as a path-traversal guard. Adds owner-level unit coverage for the arm and a new RPC-handler integration test exercising list/page/readAfter through the real catalog + real pi providerOps against a fixture pi session (with an abandoned sibling branch so active-branch selection comes through the RPC stack too). --- .../validateDirectMachineSource.test.ts | 41 +++++ .../security/validateDirectMachineSource.ts | 22 +++ ...lers.directSessions.pi.integration.test.ts | 166 ++++++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 apps/cli/src/api/machine/rpcHandlers.directSessions.pi.integration.test.ts diff --git a/apps/cli/src/api/directSessions/security/validateDirectMachineSource.test.ts b/apps/cli/src/api/directSessions/security/validateDirectMachineSource.test.ts index e6ddb7eab..80109b137 100644 --- a/apps/cli/src/api/directSessions/security/validateDirectMachineSource.test.ts +++ b/apps/cli/src/api/directSessions/security/validateDirectMachineSource.test.ts @@ -59,4 +59,45 @@ describe('validateDirectMachineSource', () => { }, }); }); + + it('accepts a pi piAgentDir source and resolves the agentDir from PI_CODING_AGENT_DIR', () => { + expect( + validateDirectMachineSource({ + providerId: 'pi', + source: { kind: 'piAgentDir' }, + env: { + HOME: '/Users/tester', + PI_CODING_AGENT_DIR: '~/.pi/agent', + }, + }), + ).toEqual({ + ok: true, + source: { + kind: 'piAgentDir', + agentDir: '/Users/tester/.pi/agent', + }, + }); + }); + + it('rejects a pi agentDir override that does not match the daemon-configured dir', () => { + const result = validateDirectMachineSource({ + providerId: 'pi', + source: { kind: 'piAgentDir', agentDir: '/etc/passwd' }, + env: { PI_CODING_AGENT_DIR: '/tmp/pi-agent-configured' }, + }); + expect(result.ok).toBe(false); + if (!result.ok) { + expect(result.error).toBe('source agentDir override is not allowed'); + } + }); + + it('rejects a pi provider with a mismatched source kind', () => { + expect( + validateDirectMachineSource({ + providerId: 'pi', + source: { kind: 'claudeConfig', configDir: '/tmp/.claude' }, + env: {}, + }), + ).toEqual({ ok: false, error: 'provider/source mismatch' }); + }); }); diff --git a/apps/cli/src/api/directSessions/security/validateDirectMachineSource.ts b/apps/cli/src/api/directSessions/security/validateDirectMachineSource.ts index dabb2083e..a8631753e 100644 --- a/apps/cli/src/api/directSessions/security/validateDirectMachineSource.ts +++ b/apps/cli/src/api/directSessions/security/validateDirectMachineSource.ts @@ -7,6 +7,7 @@ import { expandHomeDirPath } from '@happier-dev/cli-common/providers'; import { resolveConfiguredClaudeConfigDir, } from '@/backends/claude/directSessions/resolveClaudeConfigDir'; +import { resolvePiAgentDir } from '@/backends/pi/directSessions/resolvePiAgentDir'; type DirectSourceValidationResult = | Readonly<{ ok: true; source: DirectSessionsSource }> @@ -72,6 +73,27 @@ export function validateDirectMachineSource(params: Readonly<{ }, }; } + case 'pi': { + if (source.kind !== 'piAgentDir') return err('provider/source mismatch'); + const requestedAgentDir = + typeof source.agentDir === 'string' && source.agentDir.trim().length > 0 + ? canonicalizePath(source.agentDir, env) + : null; + // The configured dir is daemon-controlled (env PI_CODING_AGENT_DIR or default ~/.pi/agent). + // A client may omit agentDir; if supplied it must match the configured dir (path-traversal guard, + // mirroring the claude configDir policy). + const configuredAgentDir = canonicalizePath(resolvePiAgentDir({ source: { kind: 'piAgentDir' }, env }), env); + if (requestedAgentDir && requestedAgentDir !== configuredAgentDir) { + return err('source agentDir override is not allowed'); + } + return { + ok: true, + source: { + ...source, + agentDir: configuredAgentDir, + }, + }; + } case 'opencode': { if (source.kind !== 'opencodeServer') return err('provider/source mismatch'); const requestedBaseUrl = typeof source.baseUrl === 'string' && source.baseUrl.trim().length > 0 ? normalizeUrl(source.baseUrl) : null; diff --git a/apps/cli/src/api/machine/rpcHandlers.directSessions.pi.integration.test.ts b/apps/cli/src/api/machine/rpcHandlers.directSessions.pi.integration.test.ts new file mode 100644 index 000000000..35e317ce7 --- /dev/null +++ b/apps/cli/src/api/machine/rpcHandlers.directSessions.pi.integration.test.ts @@ -0,0 +1,166 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { RPC_METHODS } from '@happier-dev/protocol/rpc'; + +vi.mock('@/configuration', () => ({ + configuration: { + activeServerDir: '/tmp/happier-test-active-server', + happyHomeDir: '/tmp/happier-test-home', + logsDir: '/tmp', + isDaemonProcess: false, + }, +})); + +vi.mock('@/persistence', () => ({ + readCredentials: vi.fn().mockResolvedValue(null), +})); + +vi.mock('@/session/transport/http/sessionsHttp', async () => { + const actual = await vi.importActual('@/session/transport/http/sessionsHttp'); + return { + ...actual, + fetchSessionById: vi.fn().mockResolvedValue(null), + commitSessionStoredMessage: vi.fn().mockResolvedValue(undefined), + }; +}); + +vi.mock('@/session/metadata/updateSessionMetadataWithRetry', () => ({ + updateSessionMetadataWithRetry: vi.fn().mockResolvedValue(undefined), +})); + +import { registerMachineDirectSessionsRpcHandlers } from './rpcHandlers.directSessions'; + +const SESSION_ID = '019f4a42-4617-767a-8e7c-189b454a0352'; + +function freshAgentDir(): string { + return mkdtempSync(join(tmpdir(), 'pi-rpc-int-')); +} + +function writeSession(agentDir: string, lines: readonly object[]): void { + const sessionsDir = join(agentDir, 'sessions', '--proj--'); + mkdirSync(sessionsDir, { recursive: true }); + const filePath = join(sessionsDir, `2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl`); + writeFileSync(filePath, lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); +} + +const header = { type: 'session', id: SESSION_ID, timestamp: '2024-12-03T14:00:00.000Z', cwd: '/proj', version: 3 }; + +function msg(id: string, parentId: string | null, role: string, text: string, ts: string): object { + return { type: 'message', id, parentId, timestamp: ts, message: { role, content: [{ type: 'text', text }], timestamp: Date.parse(ts) } }; +} + +/** + * Exercises the daemon→pi direct-session RPC wiring with the REAL catalog + REAL pi providerOps + * against a fixture pi session on disk. This proves the full local stack — request schema + * validation, validateDirectMachineSource, getDirectSessionProviderOps('pi'), provider discovery / + * paging / readAfter — without a server or auth. Discriminating: the fixture has an abandoned + * sibling branch so active-branch selection must come through the RPC layer too. + */ +describe('registerMachineDirectSessionsRpcHandlers: pi integration', () => { + let agentDir: string; + let registered: Map Promise>; + + beforeEach(() => { + vi.clearAllMocks(); + vi.unstubAllEnvs(); + agentDir = freshAgentDir(); + vi.stubEnv('PI_CODING_AGENT_DIR', agentDir); + + // Active branch is m1→m2→m3→m4 (leaf). m_alt is a sibling of m2 off m1; appended before the + // leaf so the last-in-file leaf rule still resolves to m4, excluding the abandoned branch. + writeSession(agentDir, [ + header, + msg('m1', null, 'user', 'one', '2024-12-03T14:00:01.000Z'), + msg('m2', 'm1', 'assistant', 'two', '2024-12-03T14:00:02.000Z'), + msg('m_alt', 'm1', 'assistant', 'alt branch', '2024-12-03T14:00:02.500Z'), + msg('m3', 'm2', 'user', 'three', '2024-12-03T14:00:03.000Z'), + msg('m4', 'm3', 'assistant', 'four', '2024-12-03T14:00:04.000Z'), + ]); + + registered = new Map(); + const rpcHandlerManager = { + registerHandler: (method: string, handler: (params: unknown) => Promise) => { + registered.set(method, handler); + }, + } as unknown as Parameters[0]['rpcHandlerManager']; + registerMachineDirectSessionsRpcHandlers({ rpcHandlerManager }); + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('lists pi sessions through the daemon RPC wiring', async () => { + const handler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSIONS_CANDIDATES_LIST)!; + const res = (await handler({ + machineId: 'm1', + providerId: 'pi', + source: { kind: 'piAgentDir' }, + })) as { ok: boolean; candidates?: { remoteSessionId: string }[]; errorCode?: string; error?: string }; + + expect(res.ok).toBe(true); + expect(res.candidates?.some((c) => c.remoteSessionId === SESSION_ID)).toBe(true); + }); + + it('pages the pi active branch through the daemon RPC wiring, excluding the abandoned sibling', async () => { + const handler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSION_TRANSCRIPT_PAGE)!; + const res = (await handler({ + machineId: 'm1', + providerId: 'pi', + source: { kind: 'piAgentDir' }, + remoteSessionId: SESSION_ID, + direction: 'older', + maxItems: 10, + })) as { ok: boolean; items?: { id: string; createdAtMs: number }[]; errorCode?: string }; + + expect(res.ok).toBe(true); + expect(res.items).toHaveLength(4); + // active branch only — m_alt excluded + expect(res.items!.map((i) => i.id.slice(-2))).toEqual(['m1', 'm2', 'm3', 'm4']); + // chronological + for (let i = 1; i < res.items!.length; i += 1) { + expect(res.items![i]!.createdAtMs).toBeGreaterThanOrEqual(res.items![i - 1]!.createdAtMs); + } + }); + + it('readAfter returns the tail after a forward cursor through the daemon RPC wiring', async () => { + const pageHandler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSION_TRANSCRIPT_PAGE)!; + const firstPage = (await pageHandler({ + machineId: 'm1', + providerId: 'pi', + source: { kind: 'piAgentDir' }, + remoteSessionId: SESSION_ID, + direction: 'older', + maxItems: 2, + })) as { ok: boolean; items?: unknown[]; tailCursor?: string | null }; + + // tailCursor points at the end of the file; a static session has nothing after it. + const readAfterHandler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSION_TRANSCRIPT_READ_AFTER)!; + const res = (await readAfterHandler({ + machineId: 'm1', + providerId: 'pi', + source: { kind: 'piAgentDir' }, + remoteSessionId: SESSION_ID, + cursor: firstPage.tailCursor, + })) as { ok: boolean; items?: unknown[]; errorCode?: string }; + + expect(res.ok).toBe(true); + expect(res.items).toEqual([]); + }); + + it('rejects a client-supplied agentDir that does not match the daemon-configured dir', async () => { + const handler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSIONS_CANDIDATES_LIST)!; + const res = (await handler({ + machineId: 'm1', + providerId: 'pi', + source: { kind: 'piAgentDir', agentDir: '/etc/passwd' }, + })) as { ok: boolean; errorCode?: string }; + + expect(res.ok).toBe(false); + expect(res.errorCode).toBe('invalid_request'); + }); +}); From 372c849893ca07c0e1630169a554ebf1663c4820 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 9 Aug 2026 17:22:20 +0700 Subject: [PATCH 08/11] test(direct-sessions): cover pi link.ensure and takeover through the daemon RPC Extend the existing auth-gated RPC integration coverage to the pi provider: - link.ensure integration: add a pi case to the mock-server harness, asserting created=true and that the persisted (encrypted) metadata carries providerId='pi', piSessionId, and a piAgentDir source. Adds PI_CODING_AGENT_DIR to the test env scope. - takeover: add a pi case (real catalog + fixture pi session + spawn capture) asserting the spawn options carry the header cwd as directory, resume=, builtInAgent pi, transcriptStorage='direct', and PI_CODING_AGENT_DIR env. No production code changed; closes the last unverified RPC wiring paths (link.ensure, takeover) for pi with mocked auth. --- ...ectSessions.linkEnsure.integration.test.ts | 58 +++++++++++++ .../rpcHandlers.directSessions.test.ts | 86 +++++++++++++++++++ 2 files changed, 144 insertions(+) diff --git a/apps/cli/src/api/machine/rpcHandlers.directSessions.linkEnsure.integration.test.ts b/apps/cli/src/api/machine/rpcHandlers.directSessions.linkEnsure.integration.test.ts index c93c4791a..9d2972ba0 100644 --- a/apps/cli/src/api/machine/rpcHandlers.directSessions.linkEnsure.integration.test.ts +++ b/apps/cli/src/api/machine/rpcHandlers.directSessions.linkEnsure.integration.test.ts @@ -35,6 +35,7 @@ describe('daemon.directSessions.link.ensure (integration)', () => { 'HAPPIER_WEBAPP_URL', 'HAPPIER_HOME_DIR', 'HAPPIER_CLAUDE_CONFIG_DIR', + 'PI_CODING_AGENT_DIR', ] as const; let envScope = createEnvKeyScope(envKeys); let server: Server | null = null; @@ -147,6 +148,7 @@ describe('daemon.directSessions.link.ensure (integration)', () => { process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3000'; process.env.HAPPIER_HOME_DIR = happyHomeDir; process.env.HAPPIER_CLAUDE_CONFIG_DIR = '/tmp'; + process.env.PI_CODING_AGENT_DIR = happyHomeDir; const { reloadConfiguration } = await import('@/configuration'); reloadConfiguration(); @@ -294,6 +296,62 @@ describe('daemon.directSessions.link.ensure (integration)', () => { expect(parsedMeta.data.directSessionV1.codexBackendMode).toBe('appServer'); }); + it('creates a linked pi direct session with piSessionId metadata and an active-branch source', async () => { + const { registerMachineDirectSessionsRpcHandlers } = await import('./rpcHandlers.directSessions'); + + const registered = new Map Promise>(); + const rpcHandlerManager = { + registerHandler: (method: string, handler: (params: any) => Promise) => { + registered.set(method, handler); + }, + } as any; + + registerMachineDirectSessionsRpcHandlers({ rpcHandlerManager }); + + const handler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSION_LINK_ENSURE); + expect(handler).toBeDefined(); + + const res = await handler!({ + machineId: 'machine_1', + providerId: 'pi', + remoteSessionId: 'remote_pi_123', + titleHint: 'Linked Pi Session', + directoryHint: '/tmp/project-pi', + source: { kind: 'piAgentDir' }, + }); + + expect(res.ok).toBe(true); + expect(res.created).toBe(true); + expect(typeof res.sessionId).toBe('string'); + + const createdSession = sessionsById.get(res.sessionId); + const creds = await readCredentialsMock(); + const meta = tryDecryptSessionMetadata({ credentials: creds!, rawSession: createdSession }); + const parsedMeta = z.object({ + tag: z.string().min(1), + name: z.string(), + path: z.string(), + piSessionId: z.string(), + directSessionV1: z.object({ + providerId: z.literal('pi'), + remoteSessionId: z.string().min(1), + machineId: z.string().min(1), + source: z.object({ kind: z.literal('piAgentDir') }).passthrough(), + }).passthrough(), + }).passthrough().safeParse(meta); + if (!parsedMeta.success) { + throw new Error('Expected pi direct session metadata payload'); + } + + expect(parsedMeta.data.tag).toMatch(/^direct:v1:/); + expect(parsedMeta.data.name).toBe('Linked Pi Session'); + expect(parsedMeta.data.path).toBe('/tmp/project-pi'); + expect(parsedMeta.data.piSessionId).toBe('remote_pi_123'); + expect(parsedMeta.data.directSessionV1.providerId).toBe('pi'); + expect(parsedMeta.data.directSessionV1.remoteSessionId).toBe('remote_pi_123'); + expect(parsedMeta.data.directSessionV1.source.kind).toBe('piAgentDir'); + }); + it('returns created=false and the same sessionId on repeat calls', async () => { const { registerMachineDirectSessionsRpcHandlers } = await import('./rpcHandlers.directSessions'); diff --git a/apps/cli/src/api/machine/rpcHandlers.directSessions.test.ts b/apps/cli/src/api/machine/rpcHandlers.directSessions.test.ts index f42de9a72..1ecbb3acb 100644 --- a/apps/cli/src/api/machine/rpcHandlers.directSessions.test.ts +++ b/apps/cli/src/api/machine/rpcHandlers.directSessions.test.ts @@ -143,6 +143,92 @@ describe('registerMachineDirectSessionsRpcHandlers', () => { ); }); + it('takes over a direct pi session using header cwd and the configured agent dir', async () => { + const root = await mkdtemp(join(tmpdir(), 'happier-directSessions-rpc-takeover-pi-')); + const agentDir = join(root, '.pi', 'agent'); + const cwd = '/tmp/direct-pi-worktree'; + const sessionsDir = join(agentDir, 'sessions', '--tmp-direct-pi-worktree--'); + await mkdir(sessionsDir, { recursive: true }); + const piSessionId = '019f4a42-4617-767a-8e7c-189b454a0352'; + const sessionFile = join(sessionsDir, `2024-12-03T14-00-00-000Z_${piSessionId}.jsonl`); + await writeFile( + sessionFile, + [ + jsonlLine({ type: 'session', id: piSessionId, timestamp: '2024-12-03T14:00:00.000Z', cwd, version: 3 }), + jsonlLine({ + type: 'message', + id: 'm1', + parentId: null, + timestamp: '2024-12-03T14:00:01.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'hello' }], timestamp: Date.parse('2024-12-03T14:00:01.000Z') }, + }), + ].join(''), + 'utf8', + ); + const resolvedAgentDir = await realpath(agentDir).catch(() => agentDir); + vi.stubEnv('PI_CODING_AGENT_DIR', agentDir); + + readCredentialsMock.mockResolvedValueOnce({ + token: 'token-direct', + encryption: { type: 'legacy', secret: new Uint8Array([1, 2, 3]) }, + }); + fetchSessionByIdMock.mockResolvedValueOnce({ + id: 'sess_happy_direct_pi', + metadataVersion: 1, + encryptionMode: 'plain', + metadata: JSON.stringify({ + path: '', + machineId: 'm1', + flavor: 'pi', + piSessionId, + directSessionV1: { + v: 1, + providerId: 'pi', + machineId: 'm1', + remoteSessionId: piSessionId, + source: { kind: 'piAgentDir' }, + linkedAtMs: Date.now(), + }, + }), + }); + + const spawnSession = vi.fn(async (_options: SpawnSessionOptions): Promise => ({ + type: 'success', + sessionId: 'sess_happy_direct_pi', + })); + const stopSession = vi.fn(async () => true); + const registered = new Map Promise>(); + const rpcHandlerManager = { + registerHandler: (method: string, handler: (params: any) => Promise) => { + registered.set(method, handler); + }, + } as any; + + registerMachineDirectSessionsRpcHandlers({ rpcHandlerManager, spawnSession, stopSession }); + + const handler = registered.get(RPC_METHODS.DAEMON_DIRECT_SESSION_TAKEOVER); + expect(handler).toBeDefined(); + + const res = await handler!({ + machineId: 'm1', + sessionId: 'sess_happy_direct_pi', + }); + + expect(res).toEqual({ ok: true }); + expect(stopSession).not.toHaveBeenCalled(); + expect(spawnSession).toHaveBeenCalledWith( + expect.objectContaining({ + directory: cwd, + backendTarget: { kind: 'builtInAgent', agentId: 'pi' }, + existingSessionId: 'sess_happy_direct_pi', + resume: piSessionId, + approvedNewDirectoryCreation: true, + transcriptStorage: 'direct', + environmentVariables: expect.objectContaining({ PI_CODING_AGENT_DIR: resolvedAgentDir }), + }), + ); + }); + it('requires forceStop before taking over when a trusted local runner still owns the provider session', async () => { vi.stubEnv('HAPPIER_CLAUDE_CONFIG_DIR', '/tmp/claude-direct'); readCredentialsMock.mockResolvedValueOnce({ From c91b0902c828fb071ab93fd59b7d02ab9dbbf0f0 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 16 Aug 2026 17:49:11 +0700 Subject: [PATCH 09/11] fix(pi-direct): project pi transcripts with user prompts, tool calls, and stable cursors Imported pi direct sessions lost user prompts (block-array message content was dropped), showed no tool calls (toolCall/toolResult entries were not mapped to the protocol tool records the UI renders), and could full-replay on every poll. Project pi message blocks to user text, normalize tool entries alongside the claude convention, answer the 'tail' sentinel with an end-of-branch cursor, and always return a resumable cursor (claude parity) so polling never falls back to replay. --- .../mapPiSessionToDirectMessages.test.ts | 123 ++++++++++++- .../mapPiSessionToDirectMessages.ts | 170 +++++++++++++++--- .../directSessions/pagePiTranscript.test.ts | 2 +- .../readAfterPiTranscript.test.ts | 104 +++++++++++ .../directSessions/readAfterPiTranscript.ts | 18 +- 5 files changed, 384 insertions(+), 33 deletions(-) create mode 100644 apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.test.ts diff --git a/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts index 96c97ceaa..e8e21105a 100644 --- a/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts +++ b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest'; -import type { DirectTranscriptRawMessageV1 } from '@happier-dev/protocol'; +import { TranscriptRawRecordV1Schema, type DirectTranscriptRawMessageV1 } from '@happier-dev/protocol'; import type { PiSessionEntry } from './piEntryContext'; import { mapPiSessionToDirectMessages } from './mapPiSessionToDirectMessages'; @@ -64,9 +64,9 @@ describe('mapPiSessionToDirectMessages', () => { ]); expect(roles(items)).toEqual(['user', 'agent', 'event']); expect(items[0]!.createdAtMs).toBe(Date.parse('2024-12-03T14:00:01.000Z')); - // user text is preserved on raw + // user text is preserved on the protocol user record expect((items[0]!.raw as any).role).toBe('user'); - expect((items[0]!.raw as any).content).toBe('hello'); + expect((items[0]!.raw as any).content.text).toBe('hello'); }); it('imports only the active (last-in-file) branch and excludes the abandoned sibling', () => { @@ -79,7 +79,7 @@ describe('mapPiSessionToDirectMessages', () => { const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); expect(ids(items)).toEqual([`pi:${FILE_REL}:aaaa0001`, `pi:${FILE_REL}:bbbb0002`]); - expect((items[1]!.raw as any).content[0].text).toBe('active branch'); + expect((items[1]!.raw as any).content.data.message.content[0].text).toBe('active branch'); }); it('honors an explicit leafId to select a non-default branch', () => { @@ -109,7 +109,9 @@ describe('mapPiSessionToDirectMessages', () => { `pi:${FILE_REL}:aftercmp`, ]); expect(items[0]!.messageRole).toBe('event'); - expect((items[0]!.raw as any).role).toBe('compactionSummary'); + expect((items[0]!.raw as any).role).toBe('agent'); + expect((items[0]!.raw as any).content.data.type).toBe('summary'); + expect((items[0]!.raw as any).content.data.summary).toBe('earlier work'); }); it('skips non-context entries (model_change, thinking_level_change, label, custom) entirely', () => { @@ -146,12 +148,121 @@ describe('mapPiSessionToDirectMessages', () => { expect(mapPiSessionToDirectMessages({ entries: [], fileRelPath: FILE_REL })).toEqual([]); }); + it('emits protocol transcript records the UI schema accepts for every projected entry kind', () => { + const entries = [ + user('aaaa0001', null, 'string user prompt'), + entry({ + type: 'message', id: 'aaaa0002', parentId: 'aaaa0001', timestamp: '2024-12-03T14:00:01.500Z', + message: { role: 'user', content: [{ type: 'text', text: 'blocks user prompt' }], timestamp: 1 }, + }), + assistant('bbbb0001', 'aaaa0002', 'assistant reply'), + toolResult('c3d4e5f6', 'bbbb0001'), + entry({ + type: 'message', id: 'b5s000001', parentId: 'c3d4e5f6', timestamp: '2024-12-03T14:00:03.500Z', + message: { role: 'bashExecution', command: 'ls', output: 'a\nb', exitCode: 0, cancelled: false, truncated: false, timestamp: 1 }, + }), + entry({ type: 'custom_message', id: 'c5t000001', parentId: 'b5s000001', timestamp: '2024-12-03T14:00:04.000Z', customType: 'websearch', content: [{ type: 'text', text: 'search result' }] }), + entry({ type: 'branch_summary', id: 's5m000001', parentId: 'c5t000001', timestamp: '2024-12-03T14:00:04.500Z', summary: 'branched from earlier work', fromId: 'aaaa0001' }), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + expect(items.length).toBeGreaterThan(0); + const failures: string[] = []; + for (const item of items) { + const parsed = TranscriptRawRecordV1Schema.safeParse(item.raw); + if (!parsed.success) failures.push(`${item.id}: ${JSON.stringify(parsed.error.issues[0])}`); + } + expect(failures).toEqual([]); + + // Spot-check the projections render as the right transcript kinds. + const stringUser = items[0]!.raw as Record; + expect(stringUser.role).toBe('user'); + expect(stringUser.content.text).toBe('string user prompt'); + const assistantRow = items.find((item) => item.id.endsWith('bbbb0001'))!.raw as Record; + expect(assistantRow.role).toBe('agent'); + expect(assistantRow.content.data.type).toBe('assistant'); + expect(assistantRow.content.data.message.content[0].text).toBe('assistant reply'); + const summaryRow = items.find((item) => item.id.endsWith('s5m000001'))!.raw as Record; + expect(summaryRow.content.data.type).toBe('summary'); + expect(summaryRow.content.data.summary).toBe('branched from earlier work'); + }); + + it('projects block-array user prompts onto the protocol user record so transcript views render them as user messages', () => { + const entries = [ + entry({ + type: 'message', id: 'aaaa0001', parentId: null, timestamp: '2024-12-03T14:00:01.000Z', + message: { role: 'user', content: [{ type: 'text', text: 'first part' }, { type: 'text', text: 'second part' }], timestamp: 1 }, + }), + entry({ + type: 'message', id: 'aaaa0002', parentId: 'aaaa0001', timestamp: '2024-12-03T14:00:01.500Z', + message: { role: 'user', content: [{ type: 'image', source: '…' }], timestamp: 1 }, + }), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + // Text blocks join into one protocol user text record (the semantic transcript + // classifier only recognizes user prompts as role:'user' + content.type:'text'). + expect(items[0]!.messageRole).toBe('user'); + expect((items[0]!.raw as any).role).toBe('user'); + expect((items[0]!.raw as any).content.type).toBe('text'); + expect((items[0]!.raw as any).content.text).toBe('first part\nsecond part'); + // A user message with no text blocks cannot become a text record; it stays on the + // agent-output 'user' row (tool/attachment convention) instead of being dropped. + expect(items[1]!.messageRole).toBe('user'); + expect((items[1]!.raw as any).content.data.type).toBe('user'); + }); + + it('normalizes pi toolCall blocks and toolResult messages to the Claude transcript convention', () => { + const entries = [ + user('aaaa0001', null, 'run a command'), + entry({ + type: 'message', id: 'bbbb0001', parentId: 'aaaa0001', timestamp: '2024-12-03T14:00:02.000Z', + message: { + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'pondering', thinkingSignature: 'sig' }, + { type: 'toolCall', id: 'call_1', name: 'bash', arguments: { command: 'ls' } }, + ], + timestamp: 1, + }, + }), + entry({ + type: 'message', id: 'c3d4e5f6', parentId: 'bbbb0001', timestamp: '2024-12-03T14:00:03.000Z', + message: { role: 'toolResult', toolCallId: 'call_1', toolName: 'bash', content: [{ type: 'text', text: 'ok' }], isError: false, timestamp: 1 }, + }), + ]; + const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); + + // The UI transcript normalizer renders Claude-convention blocks; pi writes camelCase + // toolCall blocks and standalone toolResult messages, so the mapper must convert. + const assistantRow = items[1]!.raw as Record; + const blocks = assistantRow.content.data.message.content; + expect(blocks.find((b: any) => b.type === 'tool_use')).toEqual({ + type: 'tool_use', + id: 'call_1', + name: 'bash', + input: { command: 'ls' }, + }); + expect(blocks.find((b: any) => b.type === 'thinking')?.thinking).toBe('pondering'); + + const toolResultRow = items[2]!.raw as Record; + expect(toolResultRow.content.data.type).toBe('user'); + expect(toolResultRow.content.data.message.content).toEqual([ + { + type: 'tool_result', + tool_use_id: 'call_1', + content: [{ type: 'text', text: 'ok' }], + is_error: false, + }, + ]); + }); + it('treats a message with null content as an empty-content message rather than dropping it', () => { const entries = [ entry({ type: 'message', id: 'aaaa0001', parentId: null, message: { role: 'assistant', content: null } }), ]; const items = mapPiSessionToDirectMessages({ entries, fileRelPath: FILE_REL }); expect(items).toHaveLength(1); - expect((items[0]!.raw as any).content).toEqual([]); + expect((items[0]!.raw as any).content.data.message.content).toEqual([]); }); }); diff --git a/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts index c7e524a5e..03ab30c33 100644 --- a/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts +++ b/apps/cli/src/backends/pi/directSessions/mapPiSessionToDirectMessages.ts @@ -17,19 +17,17 @@ export function mapPiSessionToDirectMessages(params: Readonly<{ const items: DirectTranscriptRawMessageV1[] = []; for (const entry of contextEntries) { + const piRole = readPiEntryRole(entry); const message = projectPiEntryToMessage(entry); if (!message) continue; - const role = typeof (message as { role?: unknown }).role === 'string' - ? ((message as { role: string }).role) - : undefined; const id = `pi:${params.fileRelPath}:${entry.id}`; items.push({ id, localId: id, createdAtMs: resolvePiEntryTimestampMs(entry, message), - messageRole: resolvePiMessageRole(role), + messageRole: resolvePiMessageRole(piRole), raw: message, }); } @@ -38,52 +36,174 @@ export function mapPiSessionToDirectMessages(params: Readonly<{ } /** - * Port of pi's `sessionEntryToContextMessages`: project one selected entry into its pi AgentMessage - * form, or `null` when the entry does not participate in LLM context (model_change, - * thinking_level_change, label, plain custom). Message entries with null/missing content are - * normalized to an empty content array, matching pi's defensive parsing. + * Port of pi's `sessionEntryToContextMessages`, projected into the protocol transcript envelope + * (`role: 'agent' | 'user'`, mirroring the Claude direct-session mapper) so the UI's + * `TranscriptRawRecordV1` schema accepts every emitted record. Message entries with + * null/missing content are normalized to an empty content array, matching pi's defensive + * parsing. Non-assistant pi roles (user-with-blocks, toolResult, bashExecution) ride `user` + * rows, the claude convention for non-assistant content. */ function projectPiEntryToMessage(entry: PiSessionEntry): Record | null { if (entry.type === 'message') { const message = (entry as { message?: unknown }).message; if (!message || typeof message !== 'object' || Array.isArray(message)) return null; const msg = message as Record & { content?: unknown }; - if (msg.content == null) { - return { ...msg, content: [] }; + const content = msg.content == null ? [] : msg.content; + if (msg.role === 'user') { + const text = typeof content === 'string' ? content : joinPiTextBlocks(content); + // The semantic transcript classifier only recognizes user prompts as protocol + // user text records (role:'user' + content.type:'text'); real pi sessions store + // user prompts as content block arrays, so join their text blocks. User messages + // without text blocks fall through to the agent-output 'user' row (attachment / + // tool convention) instead of being dropped. + if (text !== null) { + return { role: 'user', content: { type: 'text', text } }; + } } - return { ...msg }; + if (msg.role === 'assistant') { + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + role: 'assistant', + ...(typeof msg.model === 'string' ? { model: msg.model } : {}), + ...(msg.usage && typeof msg.usage === 'object' ? { usage: msg.usage } : {}), + content: normalizePiAssistantContentBlocks(content), + }, + }, + }, + }; + } + if (msg.role === 'toolResult') { + // The UI transcript normalizer renders Claude-convention tool_result blocks; pi stores + // standalone toolResult messages, so project the whole message as one tool_result block. + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + content: [{ + type: 'tool_result', + tool_use_id: typeof (msg as { toolCallId?: unknown }).toolCallId === 'string' + ? (msg as { toolCallId: string }).toolCallId + : '', + content, + is_error: (msg as { isError?: unknown }).isError === true, + }], + }, + }, + }, + }; + } + return { + role: 'agent', + content: { + type: 'output', + data: { + type: 'user', + message: { + role: 'user', + ...(typeof (msg as { toolCallId?: unknown }).toolCallId === 'string' + ? { toolCallId: (msg as { toolCallId: string }).toolCallId } + : {}), + content, + }, + }, + }, + }; } if (entry.type === 'custom_message') { return { - role: 'custom', - customType: (entry as { customType?: unknown }).customType, - content: (entry as { content?: unknown }).content ?? [], - display: (entry as { display?: unknown }).display, - details: (entry as { details?: unknown }).details, - timestamp: entry.timestamp, + role: 'agent', + content: { + type: 'output', + data: { + type: 'piCustomMessage', + customType: (entry as { customType?: unknown }).customType, + content: (entry as { content?: unknown }).content ?? [], + display: (entry as { display?: unknown }).display, + details: (entry as { details?: unknown }).details, + }, + }, }; } if (entry.type === 'branch_summary') { const summary = (entry as { summary?: unknown }).summary; if (!summary) return null; return { - role: 'branchSummary', - summary, - fromId: (entry as { fromId?: unknown }).fromId, - timestamp: entry.timestamp, + role: 'agent', + content: { type: 'output', data: { type: 'summary', summary: String(summary) } }, }; } if (entry.type === 'compaction') { return { - role: 'compactionSummary', - summary: (entry as { summary?: unknown }).summary, - tokensBefore: (entry as { tokensBefore?: unknown }).tokensBefore, - timestamp: entry.timestamp, + role: 'agent', + content: { + type: 'output', + data: { + type: 'summary', + summary: String((entry as { summary?: unknown }).summary ?? ''), + tokensBefore: (entry as { tokensBefore?: unknown }).tokensBefore, + }, + }, }; } return null; } +/** + * Normalize pi assistant content blocks to the Claude transcript convention the UI renders: + * `{ type: 'toolCall', id, name, arguments }` -> `{ type: 'tool_use', id, name, input }`. + * All other block shapes (text, thinking, …) pass through unchanged. + */ +function normalizePiAssistantContentBlocks(content: unknown): unknown { + if (!Array.isArray(content)) return content; + return content.map((block) => { + if (!block || typeof block !== 'object' || Array.isArray(block)) return block; + const record = block as Record; + if (record.type !== 'toolCall') return block; + return { + type: 'tool_use', + id: record.id, + name: record.name, + input: record.arguments, + }; + }); +} + +/** + * Join the `{ type: 'text' }` blocks of a pi content block array into one string. + * Returns null for non-arrays and for arrays without any non-empty text blocks. + */ +function joinPiTextBlocks(content: unknown): string | null { + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== 'object' || Array.isArray(block)) continue; + const record = block as Record; + if (record.type !== 'text' || typeof record.text !== 'string') continue; + if (record.text.length > 0) parts.push(record.text); + } + return parts.length > 0 ? parts.join('\n') : null; +} + +function readPiEntryRole(entry: PiSessionEntry): string | undefined { + if (entry.type === 'message') { + const role = (entry as { message?: { role?: unknown } }).message?.role; + return typeof role === 'string' ? role : undefined; + } + if (entry.type === 'custom_message') return 'custom'; + if (entry.type === 'branch_summary') return 'branchSummary'; + if (entry.type === 'compaction') return 'compactionSummary'; + return undefined; +} + function resolvePiMessageRole(role: string | undefined): SessionMessageRole { if (role === 'user') return 'user'; if (role === 'assistant') return 'agent'; diff --git a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts index f1ea4b01c..c3176fdaa 100644 --- a/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts +++ b/apps/cli/src/backends/pi/directSessions/pagePiTranscript.test.ts @@ -81,7 +81,7 @@ describe('pagePiTranscript', () => { const ordered = await importAll(source, env, { maxBytes: 1024 * 1024, maxItems: 10 }); // only m1 + m3 (active leaf = m3, the last in file) expect(ordered.map((i) => i.id)).toHaveLength(2); - expect((ordered[1]!.raw as { content: Array<{ text: string }> }).content[0]!.text).toBe('active branch'); + expect((ordered[1]!.raw as { content: { data: { message: { content: Array<{ text: string }> } } } }).content.data.message.content[0]!.text).toBe('active branch'); }); it('returns empty and no cursor for a missing session', async () => { diff --git a/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.test.ts b/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.test.ts new file mode 100644 index 000000000..a9d72f980 --- /dev/null +++ b/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.test.ts @@ -0,0 +1,104 @@ +import { mkdirSync, mkdtempSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +import type { DirectSessionsSource } from '@happier-dev/protocol'; + +import { encodePiForwardCursor } from './pagePiTranscript'; +import { readAfterPiTranscript } from './readAfterPiTranscript'; + +const SESSION_ID = '019f4a42-4617-767a-8e7c-189b454a0352'; +const FILE_REL = `projects/2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl`; + +function writeSession(agentDir: string, lines: readonly object[]): { source: DirectSessionsSource; env: NodeJS.ProcessEnv } { + const sessionsDir = join(agentDir, 'sessions', '--proj--'); + mkdirSync(sessionsDir, { recursive: true }); + writeFileSync(join(sessionsDir, `2024-12-03T14-00-00-000Z_${SESSION_ID}.jsonl`), lines.map((line) => JSON.stringify(line)).join('\n') + '\n'); + return { source: { kind: 'piAgentDir' }, env: { ...process.env, PI_CODING_AGENT_DIR: agentDir } }; +} + +function freshAgentDir(): string { + return mkdtempSync(join(tmpdir(), 'pi-readafter-')); +} + +const header = { type: 'session', id: SESSION_ID, timestamp: '2024-12-03T14:00:00.000Z', cwd: '/proj', version: 3 }; + +function msg(id: string, parentId: string | null, role: string, text: string, ts: string): object { + return { type: 'message', id, parentId, timestamp: ts, message: { role, content: [{ type: 'text', text }], timestamp: Date.parse(ts) } }; +} + +const THREE_MESSAGES = [ + header, + msg('aaaa0001', null, 'user', 'one', '2024-12-03T14:00:01.000Z'), + msg('bbbb0001', 'aaaa0001', 'assistant', 'two', '2024-12-03T14:00:02.000Z'), + msg('cccc0001', 'bbbb0001', 'user', 'three', '2024-12-03T14:00:03.000Z'), +]; + +const LIMITS = { maxBytes: 1024 * 1024, maxItems: 100 }; + +async function withThreeMessages(run: (params: { source: DirectSessionsSource; env: NodeJS.ProcessEnv }) => Promise): Promise { + const agentDir = freshAgentDir(); + const { source, env } = writeSession(agentDir, THREE_MESSAGES); + return await run({ source, env }); +} + +describe('readAfterPiTranscript cursor contract', () => { + it("answers the 'tail' sentinel with no items and an end-positioned cursor (claude parity)", async () => { + await withThreeMessages(async ({ source, env }) => { + const res = await readAfterPiTranscript({ + source, env, remoteSessionId: SESSION_ID, cursor: 'tail', ...LIMITS, + }); + expect(res.items).toEqual([]); + expect(res.truncated).toBe(false); + // A resumable cursor at the end of the active branch: polling with it must replay nothing. + expect(res.nextCursor).toBe(encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 3 })); + }); + }); + + it('returns a non-null cursor when fully caught up so the poller never falls back to tail', async () => { + await withThreeMessages(async ({ source, env }) => { + const res = await readAfterPiTranscript({ + source, env, remoteSessionId: SESSION_ID, + cursor: encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 3 }), + ...LIMITS, + }); + expect(res.items).toEqual([]); + expect(res.truncated).toBe(false); + expect(res.nextCursor).toBe(encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 3 })); + }); + }); + + it('returns the remainder for a mid-branch cursor and a non-null cursor when truncated', async () => { + await withThreeMessages(async ({ source, env }) => { + const res = await readAfterPiTranscript({ + source, env, remoteSessionId: SESSION_ID, + cursor: encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 1 }), + ...LIMITS, + }); + expect(res.items).toHaveLength(2); + expect(res.truncated).toBe(false); + expect(res.nextCursor).toBe(encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 3 })); + + const capped = await readAfterPiTranscript({ + source, env, remoteSessionId: SESSION_ID, + cursor: encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 1 }), + maxBytes: 1, maxItems: 1, + }); + expect(capped.items).toHaveLength(1); + expect(capped.truncated).toBe(true); + expect(capped.nextCursor).toBe(encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 2 })); + }); + }); + + it('returns an end-positioned cursor for tail when byte limits would truncate a replay', async () => { + await withThreeMessages(async ({ source, env }) => { + const res = await readAfterPiTranscript({ + source, env, remoteSessionId: SESSION_ID, cursor: 'tail', maxBytes: 1, maxItems: 1, + }); + expect(res.items).toEqual([]); + expect(res.truncated).toBe(false); + expect(res.nextCursor).toBe(encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: 3 })); + }); + }); +}); diff --git a/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts b/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts index 86d819447..bedd8664d 100644 --- a/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts +++ b/apps/cli/src/backends/pi/directSessions/readAfterPiTranscript.ts @@ -35,6 +35,19 @@ export async function readAfterPiTranscript(params: Readonly<{ const items = mapPiSessionToDirectMessages({ entries, fileRelPath: resolved.fileRelPath }); const total = items.length; + // The polling follow-lease treats a missing cursor as "start from the newest" and sends the + // 'tail' sentinel. Answer it the way the claude provider does: no items, and a cursor + // positioned at the end of the active branch. Decoding 'tail' as 0 would replay the whole + // session every poll (each replay re-applies every item and truncates, forcing the client + // into a full-refetch loop). + if (params.cursor === 'tail') { + return { + items: [], + nextCursor: encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: total }), + truncated: false, + }; + } + const delivered = Math.min(Math.max(0, decodePiForwardCursor(params.cursor)), total); const maxItems = Math.max(1, Math.trunc(params.maxItems)); const maxBytes = Math.max(1, Math.trunc(params.maxBytes)); @@ -52,7 +65,10 @@ export async function readAfterPiTranscript(params: Readonly<{ const newDelivered = delivered + pageItems.length; const truncated = newDelivered < total; - const nextCursor = truncated ? encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: newDelivered }) : null; + // Always hand back a resumable cursor, including when fully caught up: clients store this + // cursor verbatim for the next poll, and a null here makes them fall back to the 'tail' + // sentinel (claude parity: end-of-file cursors are returned, not null). + const nextCursor = encodePiForwardCursor({ v: 1, kind: 'piForward', delivered: newDelivered }); return { items: pageItems, nextCursor, truncated }; } From f180fc5889ed365d1e50e4210c49b1120facc536 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 16 Aug 2026 17:49:24 +0700 Subject: [PATCH 10/11] fix(direct-sessions): re-link converted sessions for idempotent take-over & import Take-over & import converts a direct session by deleting directSessionV1 and recording externalHistoryImportV1. The converted session kept its direct tag, so importing the same vendor session a second time hit session_is_not_direct. Rebuild the direct identity from the import record (machine-scoped to the requesting machine, validated through the same DirectSessionMetadataSchema) when directSessionV1 is absent, making re-import idempotent. --- .../takeover/loadLinkedDirectSession.test.ts | 56 +++++++++++++++++++ .../takeover/loadLinkedDirectSession.ts | 48 +++++++++++++++- 2 files changed, 103 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.test.ts b/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.test.ts index 9244b1e8f..9761c76bc 100644 --- a/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.test.ts +++ b/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.test.ts @@ -19,6 +19,62 @@ describe('loadLinkedDirectSession', () => { vi.clearAllMocks(); }); + it('re-links a converted session from its external history import record', async () => { + fetchSessionByIdMock.mockResolvedValueOnce({ id: 'sess_converted' }); + tryDecryptSessionMetadataMock.mockReturnValueOnce({ + path: '/home/kunde21', + tag: 'direct:v1:a94278a6cd532c1f472c99c66d7c6ade3ef4a38565ebded7bcfc1d76b6948841', + // takeover.persist deletes directSessionV1 after import and records the provenance instead. + externalHistoryImportV1: { + v: 1, + providerId: 'pi', + remoteSessionId: '01a00481-8cdc-78ff-a4aa-9b243badd9fb', + importedAtMs: 123, + source: { kind: 'piAgentDir', agentDir: '/home/kunde21/.pi/agent' }, + }, + }); + + const result = await loadLinkedDirectSession({ + credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } }, + sessionId: 'sess_converted', + machineId: 'machine_1', + }); + + expect(result).toEqual({ + ok: true, + session: expect.objectContaining({ + providerId: 'pi', + machineId: 'machine_1', + remoteSessionId: '01a00481-8cdc-78ff-a4aa-9b243badd9fb', + source: { kind: 'piAgentDir', agentDir: '/home/kunde21/.pi/agent' }, + }), + }); + }); + + it('still rejects a converted session when no machine scope can back the relink', async () => { + fetchSessionByIdMock.mockResolvedValueOnce({ id: 'sess_converted_no_machine' }); + tryDecryptSessionMetadataMock.mockReturnValueOnce({ + externalHistoryImportV1: { + v: 1, + providerId: 'pi', + remoteSessionId: '01a00481-8cdc-78ff-a4aa-9b243badd9fb', + importedAtMs: 123, + source: { kind: 'piAgentDir', agentDir: '/home/kunde21/.pi/agent' }, + }, + }); + + const result = await loadLinkedDirectSession({ + credentials: { token: 'token', encryption: { type: 'legacy', secret: new Uint8Array([1]) } }, + sessionId: 'sess_converted_no_machine', + }); + + expect(result).toEqual({ + ok: false, + errorCode: 'invalid_request', + error: 'session_is_not_direct', + }); + }); + it('prefers the nested OpenCode runtime descriptor over stale legacy metadata', async () => { fetchSessionByIdMock.mockResolvedValueOnce({ id: 'sess_1' }); tryDecryptSessionMetadataMock.mockReturnValueOnce({ diff --git a/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.ts b/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.ts index b42cf5fb3..42cfba54e 100644 --- a/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.ts +++ b/apps/cli/src/api/directSessions/takeover/loadLinkedDirectSession.ts @@ -104,6 +104,42 @@ function resolveCanonicalDirectSource(params: Readonly<{ return params.source; } +/** + * Rebuild a direct-session identity for a session that takeover.persist converted + * (directSessionV1 deleted, externalHistoryImportV1 recorded). The identity is + * machine-scoped to the requesting machine, and the result still has to pass + * DirectSessionMetadataSchema. Returns null when the record cannot back a relink. + */ +function rebuildDirectSessionMetadataFromImportRecord( + metadata: Record, + requestMachineId: string | undefined, +): Record | null { + const machineId = typeof requestMachineId === 'string' ? requestMachineId.trim() : ''; + if (!machineId) return null; + + const importRecord = metadata.externalHistoryImportV1; + if (!importRecord || typeof importRecord !== 'object' || Array.isArray(importRecord)) return null; + const record = importRecord as Record; + if (record.v !== 1) return null; + if (!DirectSessionsProviderIdSchema.safeParse(record.providerId).success) return null; + if (typeof record.remoteSessionId !== 'string' || !record.remoteSessionId.trim()) return null; + if (!DirectSessionsSourceSchema.safeParse(record.source).success) return null; + const importedAtMs = record.importedAtMs; + if (typeof importedAtMs !== 'number' || !Number.isFinite(importedAtMs) || importedAtMs < 0) return null; + + return { + ...metadata, + directSessionV1: { + v: 1, + providerId: record.providerId, + machineId, + remoteSessionId: record.remoteSessionId, + source: record.source, + linkedAtMs: Math.trunc(importedAtMs), + }, + }; +} + export async function loadLinkedDirectSession(params: Readonly<{ credentials: Credentials; sessionId: string; @@ -122,7 +158,17 @@ export async function loadLinkedDirectSession(params: Readonly<{ return { ok: false, errorCode: 'provider_unavailable', error: 'session_metadata_unavailable' }; } - const parsed = DirectSessionMetadataSchema.safeParse(metadata); + let parsed = DirectSessionMetadataSchema.safeParse(metadata); + if (!parsed.success) { + // takeover.persist converts imported sessions by deleting directSessionV1 and recording + // externalHistoryImportV1. That conversion must not dead-end re-imports of the same remote + // session: rebuild the direct identity from the import record so a second take-over/import + // of the same vendor session stays idempotent. + const rebuilt = rebuildDirectSessionMetadataFromImportRecord(metadata, params.machineId); + if (rebuilt) { + parsed = DirectSessionMetadataSchema.safeParse(rebuilt); + } + } if (!parsed.success) { return { ok: false, errorCode: 'invalid_request', error: 'session_is_not_direct' }; } From ef11fb0cc2b92edd66c9fcb5cc8fee0a3ef1a027 Mon Sep 17 00:00:00 2001 From: kunde21 Date: Sun, 9 Aug 2026 21:22:02 +0700 Subject: [PATCH 11/11] feat(ui): surface pi in the direct-session browse flow Register pi so the UI's direct-session browse picker offers it as a discoverable provider, completing the UI side of the pi direct-session support. - packages/agents manifest: flip pi sessionStorage.direct to true. This is the gate that listDirectBrowseProviderIds (and the new-session / handoff direct-storage flows) check. It is an accurate capability declaration: the daemon already supports pi direct (in-place) session storage via the provider added in the CLI work. - apps/ui: add a pi directSessions.browse capability (order 40) with a resolvePiBrowseSourceOptions resolver returning the piAgentDir source, mirroring the claude/codex/opencode providers. Add the browseSourcePiDefault translation across all locales. - Tests: update resolveDirectBrowseSourceOptions to expect pi in the provider list, and vendorHandoffPolicy to reflect pi's now-true direct storage (swapping the "unsupported direct storage" example to gemini). Broader (intended) effect: pi is now also selectable for direct transcript storage when starting a new session and for direct-storage handoff, matching claude/codex/opencode. --- .../pi/directSessions/resolvePiBrowseSourceOptions.ts | 10 ++++++++++ apps/ui/sources/agents/providers/pi/uiBehavior.ts | 8 ++++++++ .../browse/resolveDirectBrowseSourceOptions.test.ts | 2 +- apps/ui/sources/text/translations/ca.ts | 1 + apps/ui/sources/text/translations/en.ts | 1 + apps/ui/sources/text/translations/es.ts | 1 + apps/ui/sources/text/translations/it.ts | 1 + apps/ui/sources/text/translations/ja.ts | 1 + apps/ui/sources/text/translations/pl.ts | 1 + apps/ui/sources/text/translations/pt.ts | 1 + apps/ui/sources/text/translations/ru.ts | 1 + apps/ui/sources/text/translations/zh-Hans.ts | 1 + apps/ui/sources/text/translations/zh-Hant.ts | 1 + packages/agents/src/manifest.ts | 2 +- .../src/sessionControls/vendorHandoffPolicy.test.ts | 6 +++--- 15 files changed, 33 insertions(+), 5 deletions(-) create mode 100644 apps/ui/sources/agents/providers/pi/directSessions/resolvePiBrowseSourceOptions.ts diff --git a/apps/ui/sources/agents/providers/pi/directSessions/resolvePiBrowseSourceOptions.ts b/apps/ui/sources/agents/providers/pi/directSessions/resolvePiBrowseSourceOptions.ts new file mode 100644 index 000000000..015009f9e --- /dev/null +++ b/apps/ui/sources/agents/providers/pi/directSessions/resolvePiBrowseSourceOptions.ts @@ -0,0 +1,10 @@ +import type { DirectBrowseSourceOption } from '@/agents/registry/registryUiBehavior'; +import { t } from '@/text'; + +export function resolvePiBrowseSourceOptions(): readonly DirectBrowseSourceOption[] { + return [{ + key: 'pi:default', + label: t('directSessions.browseSourcePiDefault'), + source: { kind: 'piAgentDir' }, + }]; +} diff --git a/apps/ui/sources/agents/providers/pi/uiBehavior.ts b/apps/ui/sources/agents/providers/pi/uiBehavior.ts index f2832a7ce..47f552d48 100644 --- a/apps/ui/sources/agents/providers/pi/uiBehavior.ts +++ b/apps/ui/sources/agents/providers/pi/uiBehavior.ts @@ -1,6 +1,14 @@ import type { AgentUiBehavior } from '@/agents/registry/registryUiBehavior'; +import { resolvePiBrowseSourceOptions } from './directSessions/resolvePiBrowseSourceOptions'; + export const PI_UI_BEHAVIOR_OVERRIDE: AgentUiBehavior = { // Pi thinking level is now modeled as a model-scoped option (reasoning_effort) returned // by model probing + session metadata, so no Pi-specific chip or env-var bridge is needed here. + directSessions: { + browse: { + order: 40, + getSourceOptions: () => resolvePiBrowseSourceOptions(), + }, + }, }; diff --git a/apps/ui/sources/components/sessions/directSessions/browse/resolveDirectBrowseSourceOptions.test.ts b/apps/ui/sources/components/sessions/directSessions/browse/resolveDirectBrowseSourceOptions.test.ts index 4ff26ae82..3889c3233 100644 --- a/apps/ui/sources/components/sessions/directSessions/browse/resolveDirectBrowseSourceOptions.test.ts +++ b/apps/ui/sources/components/sessions/directSessions/browse/resolveDirectBrowseSourceOptions.test.ts @@ -11,7 +11,7 @@ const directBrowseModulePromise = import('./resolveDirectBrowseSourceOptions'); describe('resolveDirectBrowseSourceOptions', () => { it('lists browse providers from registered provider behavior order', async () => { const { listDirectBrowseProviderIds } = await directBrowseModulePromise; - expect(listDirectBrowseProviderIds()).toEqual(['codex', 'claude', 'opencode']); + expect(listDirectBrowseProviderIds()).toEqual(['codex', 'claude', 'opencode', 'pi']); }); it('returns the codex user home and per-profile connected-service sources when codex profiles exist', async () => { diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index 136bf9460..7d0a801c6 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -5811,6 +5811,7 @@ deps: { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "Configuració predeterminada de Claude", browseSourceOpenCodeDefault: "Servidor predeterminat d'OpenCode", + browseSourcePiDefault: "Directori per defecte de l'agent pi", browseCandidates: "Sessions disponibles", browseNoMachines: "Encara no hi ha màquines disponibles per a sessions directes.", browseNoCandidates: "No s'han trobat sessions del proveïdor per a aquesta màquina i aquest proveïdor.", diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index 9a777b63a..e863f3509 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -5795,6 +5795,7 @@ export const en = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: 'Default Claude config', browseSourceOpenCodeDefault: 'Default OpenCode server', + browseSourcePiDefault: 'Default pi agent directory', browseCandidates: 'Available sessions', browseNoMachines: 'No machines are available for direct sessions yet.', browseNoCandidates: 'No provider sessions were found for this machine and provider.', diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index 71680307d..d69dc52d6 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -6171,6 +6171,7 @@ export const es: TranslationStructure = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "Configuración predeterminada de Claude", browseSourceOpenCodeDefault: "Servidor predeterminado de OpenCode", + browseSourcePiDefault: "Directorio predeterminado del agente pi", browseCandidates: "Sesiones disponibles", browseNoMachines: "Aún no hay máquinas disponibles para sesiones directas.", browseNoCandidates: "No se encontraron sesiones del proveedor para esta máquina y este proveedor.", diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index a9993daf5..6f00761d0 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -6510,6 +6510,7 @@ export const it: TranslationStructure = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} servizi collegati`, browseSourceClaudeDefault: "Configurazione predefinita di Claude", browseSourceOpenCodeDefault: "Server OpenCode predefinito", + browseSourcePiDefault: "Directory predefinita dell'agente pi", browseCandidates: "Sessioni disponibili", browseNoMachines: "Non ci sono ancora macchine disponibili per le sessioni dirette.", browseNoCandidates: "Nessuna sessione del provider trovata per questa macchina e questo provider.", diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 683099560..90d86d557 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -6430,6 +6430,7 @@ localTailscale: { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "デフォルトの Claude 設定", browseSourceOpenCodeDefault: "デフォルトの OpenCode サーバー", + browseSourcePiDefault: "デフォルトの pi エージェントディレクトリ", browseCandidates: "利用可能なセッション", browseNoMachines: "直接セッションに利用できるマシンはまだありません。", browseNoCandidates: "このマシンとプロバイダーに対するセッションは見つかりませんでした。", diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index ac9e24d09..8f34f0cb0 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -6188,6 +6188,7 @@ export const pl: TranslationStructure = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "Domyślna konfiguracja Claude", browseSourceOpenCodeDefault: "Domyślny serwer OpenCode", + browseSourcePiDefault: "Domyślny katalog agenta pi", browseCandidates: "Dostępne sesje", browseNoMachines: "Na razie nie ma dostępnych maszyn dla sesji bezpośrednich.", browseNoCandidates: "Nie znaleziono sesji dostawcy dla tej maszyny i dostawcy.", diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index e5d2dda61..e2878bc3e 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -6289,6 +6289,7 @@ export const pt: TranslationStructure = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "Configuração padrão do Claude", browseSourceOpenCodeDefault: "Servidor padrão do OpenCode", + browseSourcePiDefault: "Diretório padrão do agente pi", browseCandidates: "Sessões disponíveis", browseNoMachines: "Ainda não há máquinas disponíveis para sessões diretas.", browseNoCandidates: "Nenhuma sessão do provedor foi encontrada para esta máquina e este provedor.", diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index 598ac1537..29c4175e0 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -5197,6 +5197,7 @@ export const ru: TranslationStructure = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "Стандартная конфигурация Claude", browseSourceOpenCodeDefault: "Стандартный сервер OpenCode", + browseSourcePiDefault: "Каталог агента pi по умолчанию", browseCandidates: "Доступные сессии", browseNoMachines: "Для прямых сессий пока нет доступных машин.", browseNoCandidates: "Для этой машины и провайдера сессии не найдены.", diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index abf0b80f1..b5132491e 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -5968,6 +5968,7 @@ export const zhHans: TranslationStructure = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "默认 Claude 配置", browseSourceOpenCodeDefault: "默认 OpenCode 服务器", + browseSourcePiDefault: "默认 pi 代理目录", browseCandidates: "可用会话", browseNoMachines: "尚无可用于直连会话的机器。", browseNoCandidates: "未找到此机器和提供方对应的会话。", diff --git a/apps/ui/sources/text/translations/zh-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index 24236865c..41860e07c 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -5220,6 +5220,7 @@ const zhHantOverrides: DeepPartial = { browseSourceCodexConnectedServices: ({ service }: { service: string }) => `${service} connected services`, browseSourceClaudeDefault: "預設 Claude 設定", browseSourceOpenCodeDefault: "預設 OpenCode 伺服器", + browseSourcePiDefault: "預設 pi 代理目錄", browseCandidates: "可用工作階段", browseNoMachines: "目前尚無可用於直接工作階段的機器。", browseNoCandidates: "找不到這台機器與提供者對應的工作階段。", diff --git a/packages/agents/src/manifest.ts b/packages/agents/src/manifest.ts index 83230abd7..b02114ed6 100644 --- a/packages/agents/src/manifest.ts +++ b/packages/agents/src/manifest.ts @@ -401,7 +401,7 @@ export const AGENTS_CORE = { }, }, resume: { vendorResume: 'supported', vendorResumeIdField: 'piSessionId' }, - sessionStorage: { direct: false, persisted: true }, + sessionStorage: { direct: true, persisted: true }, sessionCapabilities: { sessionListing: 'unsupported', sessionFork: { conversation: 'unsupported', fromMessage: 'unsupported' }, diff --git a/packages/agents/src/sessionControls/vendorHandoffPolicy.test.ts b/packages/agents/src/sessionControls/vendorHandoffPolicy.test.ts index aa54b1142..5d5737de3 100644 --- a/packages/agents/src/sessionControls/vendorHandoffPolicy.test.ts +++ b/packages/agents/src/sessionControls/vendorHandoffPolicy.test.ts @@ -13,7 +13,7 @@ describe('vendorHandoffPolicy', () => { expect(AGENTS_CORE.claude.sessionStorage).toEqual({ direct: true, persisted: true }); expect(AGENTS_CORE.opencode.sessionStorage).toEqual({ direct: true, persisted: true }); expect(AGENTS_CORE.codex.sessionStorage).toEqual({ direct: true, persisted: true }); - expect(AGENTS_CORE.pi.sessionStorage).toEqual({ direct: false, persisted: true }); + expect(AGENTS_CORE.pi.sessionStorage).toEqual({ direct: true, persisted: true }); }); it('resolves vendor handoff ids from metadata using the vendor resume field', () => { @@ -35,9 +35,9 @@ describe('vendorHandoffPolicy', () => { it('rejects unsupported direct handoff when the provider does not support direct session storage', () => { expect( evaluateVendorHandoffEligibility({ - agentId: 'pi', + agentId: 'gemini', storageMode: 'direct', - metadata: { piSessionId: 'p1' }, + metadata: {}, }), ).toEqual({ eligible: false, reasonCode: 'storage_mode_unsupported' }); });