From 46015ad1b714c565eef16d75c14c2ae4fe1eb20a Mon Sep 17 00:00:00 2001 From: pi-android Date: Thu, 10 Sep 2026 17:44:33 +0000 Subject: [PATCH 1/3] wip: checkpoint five agents' work in progress MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This is a WIP checkpoint, NOT a verified build. The tree does not typecheck: app/src/main/kotlin/app/pi/ui/chat/ChatSheets.kt and ChatScreen.kt reference a 'QueueMode' that the GUI agent is still writing. Committed only so the work survives a session interruption — main is deliberately left at e8bffc0 and this branch is not for merging until every agent has landed and the frozen tree passes tools/typecheck.sh and :rpc:test. What is in here, by producer: - syntax highlighting: a pi extension serving the real highlight.js 10.7.3 over loopback plus the Kotlin client (verified 56/56 against pi's own renderer), and no new dependencies - adversarial fidelity review (docs/fidelity-review.md) and extension compatibility audit (docs/extension-compatibility.md) - syntax-highlight backend evaluation (docs/syntax-highlight-eval.md, marked superseded at the top) - package/trust layer, markdown gaps, RPC command surface, GUI surfaces: all mid-write - docs/known-gaps.md and docs/feature-gaps.md-adjacent notes - fix: the a11y description string resource, the two runtime-assembly bugs, and an unclosed KDoc comment I introduced myself --- app/build.gradle.kts | 8 +- .../pi-extensions/pi-highlight/service.ts | 25 +- .../app/pi/bridge/DeviceBridgeController.kt | 21 +- .../app/pi/highlight/PiNodeCodeHighlighter.kt | 8 +- .../kotlin/app/pi/packages/AgentLayout.kt | 139 +++ .../app/pi/packages/ExtensionLifecycle.kt | 348 +++++++ .../kotlin/app/pi/packages/GuestCommand.kt | 208 ++++ .../kotlin/app/pi/packages/PackageStrings.kt | 120 +++ .../kotlin/app/pi/packages/PiListOutput.kt | 103 ++ .../kotlin/app/pi/packages/PiPackageModel.kt | 36 + .../app/pi/packages/PiPackageService.kt | 363 +++++++ .../kotlin/app/pi/packages/PiPackageSource.kt | 315 ++++++ .../app/pi/packages/PiPackagesScreen.kt | 618 ++++++++++++ .../app/pi/packages/PiProjectTrustPrompt.kt | 131 +++ .../kotlin/app/pi/packages/ProjectTrust.kt | 217 +++++ .../main/kotlin/app/pi/packages/TrustFile.kt | 209 ++++ .../kotlin/app/pi/packages/TrustRepository.kt | 367 +++++++ .../kotlin/app/pi/terminal/TerminalInput.kt | 105 +- app/src/main/kotlin/app/pi/ui/PiRoot.kt | 55 ++ .../kotlin/app/pi/ui/PiSessionViewModel.kt | 840 +++++++++++++++- .../app/pi/ui/blocks/BranchSummaryBlock.kt | 34 +- .../app/pi/ui/blocks/CompactionBlock.kt | 41 +- .../main/kotlin/app/pi/ui/blocks/DiffBlock.kt | 5 +- .../app/pi/ui/blocks/HookMessageBlock.kt | 37 +- .../app/pi/ui/blocks/SkillInvocationBlock.kt | 5 +- .../app/pi/ui/blocks/SystemPromptBlock.kt | 5 +- .../app/pi/ui/blocks/ThinkingBlockBlock.kt | 5 +- .../kotlin/app/pi/ui/blocks/ToolCallBlock.kt | 5 +- .../main/kotlin/app/pi/ui/chat/BashPanel.kt | 137 +++ .../main/kotlin/app/pi/ui/chat/ChatSheets.kt | 677 +++++++++++++ .../kotlin/app/pi/ui/chat/PiSlashCommands.kt | 233 +++++ .../app/pi/ui/chat/SessionTreeScreen.kt | 326 +++++++ .../kotlin/app/pi/ui/chat/SlashPalette.kt | 232 +++++ .../main/kotlin/app/pi/ui/chat/TuiOnlyScan.kt | 56 ++ .../app/pi/ui/extension/ExtensionUiHost.kt | 1 + .../main/kotlin/app/pi/ui/render/PiLatex.kt | 912 ++++++++++++++++++ .../kotlin/app/pi/ui/render/PiMarkdown.kt | 79 +- .../app/pi/ui/render/PiMarkdownComponents.kt | 179 +++- .../app/pi/ui/render/PiMarkdownTheme.kt | 7 + .../kotlin/app/pi/ui/screens/ChatScreen.kt | 473 ++++++++- .../app/pi/ui/screens/SessionsScreen.kt | 66 +- .../app/pi/packages/PackagesPureLogicCheck.kt | 314 ++++++ docs/fidelity-review.md | 270 ++++++ docs/known-gaps.md | 70 +- gradle/libs.versions.toml | 25 +- rpc/src/main/kotlin/app/pi/rpc/Events.kt | 38 +- rpc/src/main/kotlin/app/pi/rpc/Jsonl.kt | 12 +- rpc/src/main/kotlin/app/pi/rpc/Responses.kt | 14 +- rpc/src/main/kotlin/app/pi/rpc/SkillBlock.kt | 66 ++ rpc/src/main/kotlin/app/pi/rpc/Transcript.kt | 178 +++- .../kotlin/app/pi/rpc/FidelityFixesTest.kt | 279 ++++++ .../app/pi/rpc/RpcResponsesTypedTest.kt | 9 +- tools/pi-highlight-check.mjs | 113 +++ 53 files changed, 8963 insertions(+), 176 deletions(-) create mode 100644 app/src/main/kotlin/app/pi/packages/AgentLayout.kt create mode 100644 app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt create mode 100644 app/src/main/kotlin/app/pi/packages/GuestCommand.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PackageStrings.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PiListOutput.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PiPackageModel.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PiPackageService.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PiPackageSource.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt create mode 100644 app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt create mode 100644 app/src/main/kotlin/app/pi/packages/ProjectTrust.kt create mode 100644 app/src/main/kotlin/app/pi/packages/TrustFile.kt create mode 100644 app/src/main/kotlin/app/pi/packages/TrustRepository.kt create mode 100644 app/src/main/kotlin/app/pi/ui/chat/BashPanel.kt create mode 100644 app/src/main/kotlin/app/pi/ui/chat/ChatSheets.kt create mode 100644 app/src/main/kotlin/app/pi/ui/chat/PiSlashCommands.kt create mode 100644 app/src/main/kotlin/app/pi/ui/chat/SessionTreeScreen.kt create mode 100644 app/src/main/kotlin/app/pi/ui/chat/SlashPalette.kt create mode 100644 app/src/main/kotlin/app/pi/ui/chat/TuiOnlyScan.kt create mode 100644 app/src/main/kotlin/app/pi/ui/render/PiLatex.kt create mode 100644 app/src/test/kotlin/app/pi/packages/PackagesPureLogicCheck.kt create mode 100644 docs/fidelity-review.md create mode 100644 rpc/src/main/kotlin/app/pi/rpc/SkillBlock.kt create mode 100644 rpc/src/test/kotlin/app/pi/rpc/FidelityFixesTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 32df3c3..f6b45c2 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -9,7 +9,13 @@ plugins { android { namespace = "app.pi" - compileSdk = 36 + // SDK 37 ships as a *minor-version* platform: there is no `platforms;android-37` + // package, only 37.0/37.1/37.2. AGP 8.13.2 supports that scheme through + // `compileSdkMinor`; `compileSdk = 37` alone resolves to the nonexistent + // `platforms;android-37`. The minor setter must come *after* `compileSdk` — + // it reads the API level already set on the extension. + compileSdk = 37 + compileSdkMinor = 0 // --------------------------------------------------------------------- // targetSdk is a *runtime capability switch*, not a style choice. diff --git a/app/src/main/assets/pi-extensions/pi-highlight/service.ts b/app/src/main/assets/pi-extensions/pi-highlight/service.ts index 2a19f89..54c0d77 100644 --- a/app/src/main/assets/pi-extensions/pi-highlight/service.ts +++ b/app/src/main/assets/pi-extensions/pi-highlight/service.ts @@ -217,9 +217,32 @@ function handleHighlight(response: ServerResponse, request: IncomingMessage): Pr } const language = parsed.language.trim(); + // An empty listing means highlight.js itself could not be found next to pi. + // That is a configuration failure worth naming, not "unknown language": + // otherwise every block would silently render plain. + let available: Set; + try { + available = knownLanguages(); + } catch (error) { + return fail( + response, + 503, + "ENGINE_UNAVAILABLE", + `找不到 highlight.js:${error instanceof Error ? error.message : String(error)}。`, + ); + } + if (available.size === 0) { + return fail( + response, + 503, + "ENGINE_UNAVAILABLE", + `highlight.js 没有列出任何语言(${loadState().error ?? "未知原因"})。`, + ); + } + // Unknown language is not an error: it is pi's normal "render plain" path // (hcl/graphql/toml/fish are not in highlight.js 10.7.3 at all). - if (!knownLanguages().has(language)) { + if (!available.has(language)) { return send(response, 200, { ok: true, data: { language, known: false, spans: [], codeUnits: parsed.code.length, hljs: loadState().hljsVersion }, diff --git a/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt b/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt index 9bddddf..57afc08 100644 --- a/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt +++ b/app/src/main/kotlin/app/pi/bridge/DeviceBridgeController.kt @@ -46,8 +46,25 @@ object DeviceBridgeController { /** Assets shipped in the APK that make up the extension. */ private const val ASSET_ROOT = "pi-extensions" - /** Bumped whenever the shipped extension changes, to force re-installation. */ - const val ASSET_VERSION = "1" + /** + * Bumped whenever anything under `assets/pi-extensions/` changes, to force + * re-installation into the guest. + * + * This is a manual gate and it fails silently: the stamp is compared before + * copying, so a device that already ran an older build keeps the old + * extension tree and never sees the new one. The symptom is not an error — + * it is a feature that "does not work", which sends whoever debugs it off to + * check ports, tokens and networks instead of the installer. + * + * So: **every change to `pi-extensions/ 目录` must bump this string.** + * History: "1" shipped the device bridge; "2" adds `pi-highlight/`. + * + * A content-derived fingerprint (hashing the asset tree's names and sizes) + * would remove the human step entirely and is the better long-term design — + * recorded in docs/known-gaps.md rather than done here, because the file is + * not the one being worked on right now. + */ + const val ASSET_VERSION = "2" @Volatile private var server: DeviceBridgeHttpServer? = null diff --git a/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt b/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt index 9bfbf76..c3b40d7 100644 --- a/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt +++ b/app/src/main/kotlin/app/pi/highlight/PiNodeCodeHighlighter.kt @@ -8,6 +8,7 @@ import app.pi.ui.render.PiCodeSpan import java.io.File import java.security.MessageDigest import java.util.concurrent.locks.ReentrantLock +import kotlin.concurrent.withLock /** * The app's code highlighter: pi's own highlight.js, asked over loopback. @@ -78,6 +79,9 @@ internal object PiNodeCodeHighlighter : PiCodeHighlighter { private const val MAX_CODE_CHARS = 64 * 1024 private const val MAX_CODE_LINES = 400 + /** The credentials file the guest extension publishes; see [PiHighlightClient]. */ + private const val TOKEN_FILE_NAME = "highlight-bridge.json" + @Volatile private var client: PiHighlightClient? = null @@ -159,10 +163,6 @@ internal object PiNodeCodeHighlighter : PiCodeHighlighter { /** Diagnostics for the settings/diagnostics surface; never used on a hot path. */ fun lastFailure(): String? = client?.lastFailure - - private companion object { - const val TOKEN_FILE_NAME = "highlight-bridge.json" - } } /** diff --git a/app/src/main/kotlin/app/pi/packages/AgentLayout.kt b/app/src/main/kotlin/app/pi/packages/AgentLayout.kt new file mode 100644 index 0000000..fbd12b1 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/AgentLayout.kt @@ -0,0 +1,139 @@ +package app.pi.packages + +import android.content.Context +import app.pi.engine.PiEngineHost +import app.pi.runtime.PiPaths +import java.io.File + +/** + * Where pi's user files actually are, on the host and in the guest. + * + * This is the one place in the app that has to know about a mismatch that is easy + * to get wrong and silent when you do. + * + * ## The mismatch, verified from code + * + * `PiEngineHost` sets `PI_CODING_AGENT_DIR=/root/.pi/agent` (`PiEngineHost.kt:51`, + * `:123`) and binds **only the workspace** into the guest (`:116`). So inside the + * guest, `/root/.pi/agent` is a real directory of the rootfs: + * + * /pi/runtime/rootfs/root/.pi/agent <- what pi reads and writes + * + * whereas `PiPaths.agentDir` is + * + * /pi/.pi/agent <- a *host-side mirror* + * + * `docs/extension-compatibility.md:523-524` says the agent dir is bind-mounted. + * It is not — `PiEngineHost.kt:109-117` binds `workspace.absolutePath to + * guestWorkspace` and nothing else. `DeviceBridgeController` already works around + * this by writing to **both** locations and commenting the host one as "guest path + * only if the engine binds it" (`DeviceBridgeController.kt:182-188`, `:230-233`). + * This class makes that workaround explicit and reusable instead of incidental. + * + * ## Why it matters for this layer + * + * - `trust.json` is read by pi at `/trust.json` + * (`trust-manager.ts:212-214`). Written only to the mirror, trust would never + * take effect. Written only to the rootfs, it would be **destroyed by the next + * runtime update**: `RuntimeProvisioner.wipe()` deletes `paths.runtime` + * wholesale (`RuntimeProvisioner.kt:85-91`) and only re-creates an empty + * `/root/.pi/agent` (`:185`). So it must be written to both, and the rootfs + * copy re-published after provisioning. + * - `pi install` writes `packages` into `/settings.json` and installs + * into `/npm`, both inside the volatile tree. The app cannot fix that + * from here (it would take the engine bind), but it can and does say so instead + * of reporting a durable install. + * + * ## What the app should do instead, exactly + * + * One line in `PiEngineHost.boot` makes the mirror authoritative and removes the + * whole class of bug. It is not this package's file to edit, so it is reported + * rather than applied: + * + * ```kotlin + * val argv = ProotCommand.build( + * paths = paths, + * guestCommand = guestCommand, + * cwd = guestWorkspace, + * storage = android.os.Environment.getExternalStorageDirectory(), + * extraBinds = listOf( + * workspace.absolutePath to guestWorkspace, + * paths.agentDir.absolutePath to guestAgentDir, // <- add this + * ), + * ) + * ``` + * + * Until that exists, [TrustRepository] keeps both copies in step. + */ +class AgentLayout( + context: Context, + /** Host workspace directory; the guest spelling is derived from it. */ + val hostWorkspace: File, +) { + + val paths = PiPaths( + filesDir = context.filesDir, + nativeLibDir = File(context.applicationInfo.nativeLibraryDir), + ) + + /** pi's `PI_CODING_AGENT_DIR` inside the guest (`PiEngineHost.kt:51`). */ + val guestAgentDir: String = "/root/.pi/agent" + + /** + * `$HOME` inside the guest. `ProotCommand.environment` pins it to `/root` + * (`PiRuntime.kt:142`), and `hasTrustRequiringProjectResources` compares + * against `$HOME/.agents/skills` to decide which skills directory is the + * trusted user one (`trust-manager.ts:186-188`), so the value has to come from + * there rather than from Android's notion of home. + */ + val guestHome: String = "/root" + + /** + * The guest's own home, on the host filesystem. proot does not translate paths + * for us: `--rootfs=` makes `/pi/runtime/rootfs` appear as `/`, + * so guest `/root/.pi/agent` is exactly [agentTruthDir]. + */ + val agentTruthDir: File = File(paths.rootfs, "root/.pi/agent") + + /** + * The durable host copy. `PiPaths.agentDir` is `/pi/.pi/agent` + * (`PiRuntime.kt:19`); it survives a runtime re-extract because it is not under + * `paths.runtime`. + */ + val agentMirrorDir: File = paths.agentDir + + /** + * The guest spelling of the workspace, computed **by the same rule** as + * `PiEngineHost.guestPathFor` (`:162-166`): strip the `` prefix, then + * mount under `/workspace`. `PiSessionViewModel` duplicates the same string + * (`:331`) and calls the mapping "a contract, not an implementation detail". + * + * It matters here because `pi install -l` writes `/.pi/settings.json` and + * `hasTrustRequiringProjectResources(cwd)` inspects `/.pi`, both using the + * **guest** cwd — which is also the key pi writes into `trust.json`. + */ + val guestWorkspace: String = run { + val root = paths.home.parentFile?.absolutePath ?: paths.home.absolutePath + val rel = hostWorkspace.absolutePath.removePrefix(root).trimStart('/') + if (rel.isEmpty()) "/workspace" else "/workspace/$rel" + } + + /** The one bind this layer needs when it runs a command in the workspace. */ + fun workspaceBind(): Pair = hostWorkspace.absolutePath to guestWorkspace + + /** The engine's cli.js, exactly as `PiEngineHost` computes it (`:95`). */ + val guestEngineCli: String + get() = "${PiEngineHost.ENGINE_GUEST_ROOT}/node_modules/${PiEngineHost.PI_PACKAGE}/dist/cli.js" + + /** Node inside the guest (`RuntimeProvisioner.extractNode`, `:115`). */ + val guestNode: String get() = "/opt/node/bin/node" + + /** `/.pi/settings.json`, resolved to a host path for a guest workspace. */ + fun hostProjectConfigDir(): File = File(hostWorkspace, ".pi") + + /** True when the runtime is unpacked far enough to run anything. */ + fun runtimeReady(): Boolean = paths.rootfs.isDirectory && paths.prootBinary().isFile && paths.prootLoader().isFile + + /** True when the engine cli.js the guest will exec exists on the host side. */ + fun engineInstalled(): Boolean = File(paths.rootfs, guestEngineCli.removePrefix("/")).isFile +} diff --git a/app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt b/app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt new file mode 100644 index 0000000..c522b79 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/ExtensionLifecycle.kt @@ -0,0 +1,348 @@ +package app.pi.packages + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * The install → needs-restart → restart → ready state machine. + * + * ## Why a restart is the only way, from the source + * + * pi loads extensions with `jiti` **into the pi process** at startup. There are + * exactly two things that pick up a new extension: + * + * 1. `/reload` — a *built-in* command (`slash-commands.ts:41`). Built-ins are + * excluded from `get_commands` (`docs/rpc.md:853`), are not dispatched by + * `prompt` (`agent-session.ts:1331-1343`, which only routes *extension* + * commands), and there is no `reload` command in `RpcCommand` + * (`modes/rpc/rpc-types.ts:20-74`). The reload plumbing *does* exist over RPC + * — `rpc-mode.ts:341-343` wires `reload: async () => { await session.reload() }` + * into the extension `commandContextActions` — so an **extension command** that + * calls `ctx.reload()` is reachable via `prompt`. That is the only in-process + * route, and it needs an extension the app owns to already be loaded. + * 2. A fresh engine process. + * + * Neither is a silent operation. `session.reload()` emits + * `session_shutdown {reason:"reload"}`, clears the jiti cache, re-resolves + * packages/skills/prompts/themes, and emits `session_start {reason:"reload"}` + * (`agent-session.ts:2841-2866`, `resource-loader.ts:388-547`). Killing the + * process is blunter still. + * + * ## Why the app never does it silently + * + * A restart **kills any in-flight turn**: the model call, the tool calls, the + * foreground service holding the wake lock — all of it. So the rule is: + * + * - a successful install or remove **never** restarts anything; it moves the + * machine to [State.NeedsRestart] and says so; + * - a restart requires an explicit `requestRestart` **and** a confirmation + * ([State.AwaitingConfirmation] carries the question, not the action); + * - if a turn is running, even a confirmed restart waits: the machine parks in + * [State.AwaitingIdle] and does **not** restart. There is no timeout and no + * forcing path, because there is no correct moment to kill a turn that the user + * has not chosen. The user either stops the turn or waits, and the app re-offers. + * + * ## The same requirement covers settings + * + * `PiSettingsRegistry` marks every resource row `EffectiveKind.Reload` + * (`PiCommon.kt:204` for the enum; `PiSettingsRegistry.kt:760`, `:773`, `:797`, + * `:810`, `:823` for `extensions`, `packages`, `skills`, `prompts`, `themes`), and + * `EffectiveKind.Reload` has no consumer. This machine is the consumer: a settings + * write to any of those keys goes through [noteExternalChange] and lands in exactly + * the same [State.NeedsRestart]. That is also the honest answer to "do skills and + * prompt templates need a restart" — they do; see [RefreshSemantics]. + */ +class ExtensionLifecycle { + + /** How long a restart is expected to take, so the UI can say something true. */ + val expectedRestartSeconds: IntRange = 1..3 + + sealed interface State { + val label: String + + /** Nothing pending; the engine, if running, has the current resources. */ + data object Idle : State { + override val label: String get() = "空闲" + } + + /** A package command is running. No restart may be started from here. */ + data class Installing(val action: String, val source: String) : State { + override val label: String get() = "正在$action $source" + } + + /** + * Something changed on disk that pi will not see until it reloads. This is + * the state the user must be told about every single time. + */ + data class NeedsRestart( + val changes: List, + val detail: String, + /** Set after a failed restart attempt, so the reason survives. */ + val lastError: String? = null, + ) : State { + override val label: String get() = "需要重启" + } + + /** The user asked to restart and the app is waiting for confirmation. */ + data class AwaitingConfirmation(val changes: List, val question: String) : State { + override val label: String get() = "等待确认" + } + + /** + * The user confirmed, but a turn is in flight, so nothing happens yet. + * [turnNote] is what the user is told; there is no auto-continue. + */ + data class AwaitingIdle(val changes: List, val turnNote: String) : State { + override val label: String get() = "等待回合结束" + } + + /** The restart is actually happening. */ + data class Restarting(val changes: List) : State { + override val label: String get() = "正在重启" + } + + /** The engine came back and has the new resources. */ + data class Ready(val note: String) : State { + override val label: String get() = "已就绪" + } + } + + private val _state = MutableStateFlow(State.Idle) + val state: StateFlow = _state.asStateFlow() + + val current: State get() = _state.value + + /** True while a restart is genuinely pending, in any of its waiting shapes. */ + val pendingRestart: Boolean + get() = when (_state.value) { + is State.NeedsRestart, is State.AwaitingConfirmation, is State.AwaitingIdle -> true + else -> false + } + + // ------------------------------------------------------------- transitions + + /** A package command started. Only legal from a settled state. */ + fun beginInstall(action: String, source: String): Boolean { + when (_state.value) { + is State.Installing, is State.Restarting -> return false + else -> {} + } + _state.value = State.Installing(action, source) + return true + } + + /** + * A command finished successfully and changed something pi reads at startup. + * This is where "restart is required" becomes visible — never later, never + * implicitly. + */ + fun installSucceeded(changes: List, detail: String) { + _state.value = State.NeedsRestart(changes = changes, detail = detail) + } + + /** + * A command failed. Nothing was persisted by pi's `installAndPersist` + * (`package-manager.ts:1029-1032` runs the install *before* the settings + * write, so a throw leaves settings untouched), so there is nothing to reload. + */ + fun installFailed(message: String) { + _state.value = State.Idle + lastMessage = message + } + + /** A settings row with `EffectiveKind.Reload` was written. Same requirement. */ + fun noteExternalChange(changes: List, detail: String) { + when (_state.value) { + is State.Installing, is State.Restarting -> return + else -> _state.value = State.NeedsRestart(changes, detail) + } + } + + /** + * The user pressed 重启. This does **not** restart: it produces the question. + * + * @param turnRunning the caller's live answer from the engine, not a guess. + */ + fun requestRestart(turnRunning: Boolean): RequestOutcome = when (val now = _state.value) { + is State.NeedsRestart -> if (turnRunning) { + _state.value = State.AwaitingIdle(changes = now.changes, turnNote = TURN_RUNNING_NOTE) + RequestOutcome.WaitingForTurn + } else { + _state.value = State.AwaitingConfirmation(changes = now.changes, question = confirmQuestion(now.changes)) + RequestOutcome.NeedsConfirmation + } + + is State.Ready -> { + // Idempotent: asking to restart a ready engine is a no-op, not an error. + RequestOutcome.AlreadyReady + } + + is State.AwaitingIdle -> { + // Re-asked while still busy: re-check the turn rather than assume. + if (turnRunning) { + RequestOutcome.WaitingForTurn + } else { + _state.value = State.AwaitingConfirmation(now.changes, confirmQuestion(now.changes)) + RequestOutcome.NeedsConfirmation + } + } + + is State.AwaitingConfirmation -> RequestOutcome.NeedsConfirmation + + is State.Installing -> RequestOutcome.BusyWithPackageCommand + + is State.Restarting -> RequestOutcome.BusyWithPackageCommand + + State.Idle -> RequestOutcome.NothingPending + } + + sealed interface RequestOutcome { + /** The app must show [State.AwaitingConfirmation.question] and wait. */ + data object NeedsConfirmation : RequestOutcome + + /** A turn is in flight; nothing will happen until it ends. */ + data object WaitingForTurn : RequestOutcome + + data object AlreadyReady : RequestOutcome + data object NothingPending : RequestOutcome + data object BusyWithPackageCommand : RequestOutcome + } + + /** The user declined. Back to the honest pending state. */ + fun cancelRestart() { + val now = _state.value + if (now is State.AwaitingConfirmation || now is State.AwaitingIdle) { + val changes = when (now) { + is State.AwaitingConfirmation -> now.changes + is State.AwaitingIdle -> now.changes + else -> emptyList() + } + _state.value = State.NeedsRestart( + changes = changes, + detail = "重启已取消;新装的资源仍未生效。", + ) + } + } + + /** The user confirmed and the caller is now performing the restart. */ + fun restartStarted(): Boolean { + val now = _state.value + if (now !is State.AwaitingConfirmation) return false + _state.value = State.Restarting(now.changes) + return true + } + + fun restartSucceeded() { + _state.value = State.Ready("引擎已重启,新资源已加载。") + lastMessage = null + } + + fun restartFailed(message: String) { + val now = _state.value + val changes = (now as? State.Restarting)?.changes ?: emptyList() + _state.value = State.NeedsRestart( + changes = changes, + detail = "重启失败;资源仍未生效。", + lastError = message, + ) + } + + /** Last one-shot message for the UI to surface; not part of [state]. */ + var lastMessage: String? = null + private set + + fun clearMessage() { + lastMessage = null + } + + private fun confirmQuestion(changes: List): String = buildString { + append("重启引擎以加载新资源?\n\n") + if (changes.isNotEmpty()) { + append(changes.joinToString("\n") { "· $it" }) + append("\n\n") + } + append("重启需要约 ${expectedRestartSeconds.first}–${expectedRestartSeconds.last} 秒。") + append("它会终止当前正在进行的回合(模型调用、工具调用、bash 命令都不会恢复),") + append("但已写入磁盘的会话内容不会丢失——会话是 JSONL 追加写的,重启后用 get_entries 重新挂载即可。") + } + + companion object { + /** + * Deliberately not "稍后自动重启". The app has no way to know when the turn + * will end, and a restart that fires on a guess is the silent restart this + * machine exists to prevent. + */ + const val TURN_RUNNING_NOTE: String = + "有回合正在运行,暂不重启。重启会中断模型调用和正在跑的工具调用;" + + "请先停止该回合或等它结束,然后再次点击重启。" + + /** + * The refresh answer, shown next to every restart prompt. It lives here + * rather than in [PackageStrings] because it is a statement about what pi + * does, not a label: that adding a *skill* needs a restart is not obvious + * to anyone who has not read `resource-loader.ts`. + */ + val ANSWER_HINT: String = RefreshSemantics.ANSWER + } +} + +/** + * What the app must tell the user about resource refresh, with the code path that + * decides each case. + * + * This answers the "one uncertain fact" directly, and the answer is *no*: skills and + * prompt templates are **not** re-discovered on every `get_commands` call. They are + * scanned once, into a cached array, and that array is only rebuilt by a reload. + * + * The chain, every step in pi's source: + * + * - `get_commands` builds its reply from three **cached reads** + * (`modes/rpc/rpc-mode.ts:682-713`): + * `session.extensionRunner.getRegisteredCommands()`, + * `session.promptTemplates`, and + * `session.resourceLoader.getSkills().skills`. + * - `session.promptTemplates` is a getter over `this._resourceLoader.getPrompts().prompts` + * (`core/agent-session.ts:1033-1035`). + * - `getPrompts()` returns `{ prompts: this.prompts }` and `getSkills()` returns + * `{ skills: this.skills }` — plain fields of the loader + * (`core/resource-loader.ts:308-314`), initialised to `[]` in the constructor + * (`:285-288`). + * - Those fields are assigned only by `updateSkillsFromPaths` + * (`:672-693`) and `updatePromptsFromPaths` (`:695-717`), which are called only + * from `reload()` (`:472-473` and `:487-488`, inside the method starting at + * `:388`). `reload()` sets `this.loaded = true` at `:546` and nothing else + * assigns either field. + * - `reload()` runs once at startup, when the loader is built + * (`core/sdk.ts:185-188`: `resourceLoader = new DefaultResourceLoader(...)` then + * `await resourceLoader.reload()`), and again only from `session.reload()` + * (`core/agent-session.ts:2849`). + * + * `loadSkills` (`resource-loader.ts:677-682`) does walk the filesystem on each + * call — the *scan* is live; the **result is cached**. So dropping a new `SKILL.md` + * into `.pi/skills` or editing a prompt template changes nothing the RPC client can + * observe until a reload. Adding a skill therefore needs the same restart as adding + * an extension. This contradicts any claim that `get_commands` re-discovers + * resources per call, and the citations above are why. + */ +object RefreshSemantics { + + /** Which resource kinds a reload re-resolves (`resource-loader.ts:388-547`). */ + val reloadScannedResources = listOf( + "extensions", + "skills", + "prompt templates", + "themes", + "AGENTS.md", + "SYSTEM.md / APPEND_SYSTEM.md", + ) + + /** Read by `get_commands` from a cache, so a new file is invisible until reload. */ + val cachedAcrossGetCommands = listOf("extensions", "skills", "prompt templates") + + /** One sentence, for the report and for the UI. */ + const val ANSWER: String = + "skills 与 prompt 模板不是每次 get_commands 重新扫描的:它们在 resource-loader 的 " + + "reload() 里扫描一次并缓存(resource-loader.ts:472-473、:487-488),get_commands 直接读缓存 " + + "(rpc-mode.ts:694-710)。新增技能或模板同样需要重启(或扩展命令里调用 ctx.reload())。" +} diff --git a/app/src/main/kotlin/app/pi/packages/GuestCommand.kt b/app/src/main/kotlin/app/pi/packages/GuestCommand.kt new file mode 100644 index 0000000..ec1f182 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/GuestCommand.kt @@ -0,0 +1,208 @@ +package app.pi.packages + +import android.os.Environment +import app.pi.runtime.ProotCommand +import java.io.InputStream +import java.util.concurrent.TimeUnit + +/** + * Runs one non-interactive command inside the guest, and reports exactly what + * happened. + * + * ## Why this is not [app.pi.runtime.PtyLauncher] + * + * `PtyLauncher` allocates a real pty with the guest's `script(1)` so pi's TUI can + * work. That is the wrong tool here, and specifically so: + * + * - pi decides whether it may prompt for project trust with + * `process.stdin.isTTY && process.stdout.isTTY` + * (`package-manager-cli.ts:733-735` → `createProjectTrustContext({hasUI: appMode + * === "interactive"})`, `:779-784`). **Under a pty that is `true`**, so + * `resolveProjectTrusted` reaches `ctx.ui.select` and blocks on + * `showStartupSelector` — an unseen TUI menu waiting for a keypress that will + * never come (`core/project-trust.ts:86-95`). Over a pipe it is `false`, the + * gate returns `false` deterministically (`:86-88`), and `-l` then fails with + * pi's own clear message instead of hanging. + * - `chalk` colours its output on a pty, which would put SGR sequences in + * messages the app shows the user. Over a pipe chalk emits plain text; `NO_COLOR` + * is set anyway so the outcome cannot depend on chalk's detection. + * - An install is a batch job. A pty buys nothing and costs determinism. + * + * The one thing a pty would help with is an interactive credential prompt + * (`git clone` over SSH asking for a passphrase). That case is **not** handled by + * hanging: [ENV] sets `GIT_TERMINAL_PROMPT=0` and a `BatchMode` `GIT_SSH_COMMAND`, + * which is pi's own recommendation for non-interactive runs (`docs/packages.md:89` + * — "For non-interactive runs (for example CI), you can set `GIT_TERMINAL_PROMPT=0` + * to disable credential prompts and set `GIT_SSH_COMMAND` … to fail fast"). A + * package that needs an interactive prompt therefore fails with git's message, and + * the user is pointed at the Workbench terminal tab, where the same command can be + * run under a real pty. + * + * ## What is reused + * + * The argv comes from [ProotCommand.build] and the environment from + * [ProotCommand.environment] — the same two functions [app.pi.engine.PiEngineHost] + * and `PtyLauncher` use. There is no second proot invocation recipe in this file: + * it adds environment variables for a batch run and nothing else. That matters, + * because a divergence between how the engine is launched and how `pi install` is + * launched would be invisible until a user installed a package that then behaved + * differently from the same package installed by hand. + */ +class GuestCommand(private val layout: AgentLayout) { + + /** One completed (or abandoned) guest command. */ + data class Outcome( + /** The exact argv handed to proot; reported so the user can reproduce it. */ + val argv: List, + /** The guest command string, i.e. what `bash -lc` received. */ + val guestCommand: String, + /** Null when the process never started or was killed on timeout. */ + val exitCode: Int?, + val stdout: String, + val stderr: String, + val timedOut: Boolean, + /** Non-null when proot itself could not be started. */ + val launchError: String? = null, + ) { + val ok: Boolean get() = launchError == null && !timedOut && exitCode == 0 + + /** Everything the guest said, in the order a terminal would show it. */ + val combined: String get() = buildString { + if (stdout.isNotBlank()) append(stdout.trimEnd()) + if (stderr.isNotBlank()) { + if (isNotEmpty()) append('\n') + append(stderr.trimEnd()) + } + } + + /** True when the guest produced no output at all. */ + val silent: Boolean get() = combined.isBlank() + } + + /** + * @param guestCommand a command string evaluated by `bash -lc` **inside** the + * rootfs, so it may name guest paths (`/opt/node/bin/node`, `/workspace`). + * @param cwd the guest cwd. For anything pi does with project settings this + * must be the workspace, because pi resolves `/.pi/settings.json` + * and writes the trust key from `process.cwd()`. + * @param timeoutMs hard cap. On expiry the process tree is killed + * (`ProotCommand.build` passes `--kill-on-exit`) and + * [Outcome.timedOut] is set — the app never reports a timeout as success. + */ + fun run( + guestCommand: String, + cwd: String = layout.guestWorkspace, + timeoutMs: Long = INSTALL_TIMEOUT_MS, + extraEnv: Map = emptyMap(), + ): Outcome { + val argv = ProotCommand.build( + paths = layout.paths, + guestCommand = guestCommand, + cwd = cwd, + storage = Environment.getExternalStorageDirectory(), + // The workspace is the only extra bind the engine uses; `-l` writes + // `/.pi/settings.json`, so the same bind is required here. + extraBinds = listOf(layout.workspaceBind()), + ) + val env = ProotCommand.environment(layout.paths, extra = ENV + extraEnv) + + val process = try { + ProcessBuilder(argv) + .directory(layout.paths.runtime) + .also { it.environment().putAll(env) } + .start() + } catch (error: Throwable) { + return Outcome( + argv = argv, + guestCommand = guestCommand, + exitCode = null, + stdout = "", + stderr = "", + timedOut = false, + launchError = "${error::class.java.simpleName}: ${error.message}", + ) + } + + // Both pipes must be drained concurrently. A single-threaded read of stdout + // followed by stderr deadlocks as soon as the guest fills the other pipe's + // buffer — and `npm install` on a phone prints far more than one pipe + // buffer's worth. + val out = StringBuilder() + val err = StringBuilder() + val outThread = pump(process.inputStream, out) + val errThread = pump(process.errorStream, err) + + val finished = process.waitFor(timeoutMs, TimeUnit.MILLISECONDS) + if (!finished) { + process.destroyForcibly() + // Give the readers a moment to drain what the guest already wrote; a + // killed process closes its pipes, so this cannot block indefinitely. + process.waitFor(5, TimeUnit.SECONDS) + } + outThread.join(READER_JOIN_MS) + errThread.join(READER_JOIN_MS) + + return Outcome( + argv = argv, + guestCommand = guestCommand, + exitCode = if (finished) process.exitValue() else null, + stdout = out.toString(), + stderr = err.toString(), + timedOut = !finished, + ) + } + + private fun pump(stream: InputStream, into: StringBuilder): Thread { + val thread = Thread { + runCatching { + stream.bufferedReader().use { reader -> + val buffer = CharArray(4096) + while (true) { + val read = reader.read(buffer) + if (read < 0) break + synchronized(into) { into.append(buffer, 0, read) } + } + } + } + } + thread.isDaemon = true + thread.start() + return thread + } + + companion object { + /** + * npm/git over a phone network. Long enough that a slow but working install + * is not killed, short enough that a wedged one does not hold the UI + * forever. The app states the timeout in the failure message rather than + * presenting it as a package error. + */ + const val INSTALL_TIMEOUT_MS: Long = 10 * 60 * 1000L + + /** `pi list` reads two JSON files; it should never take a minute. */ + const val LIST_TIMEOUT_MS: Long = 60 * 1000L + + private const val READER_JOIN_MS = 2_000L + + /** + * Batch-mode environment. Each entry is a decision, not a default: + * + * - `NO_COLOR` — chalk must not emit SGR into text the app displays. + * - `GIT_TERMINAL_PROMPT=0`, `GIT_SSH_COMMAND=...BatchMode=yes` — + * `docs/packages.md:89`, so a credential prompt fails fast instead of + * hanging a pipe with no reader. `ConnectTimeout=5` bounds a dead host. + * - `PI_SKIP_VERSION_CHECK=1` — the engine already sets this + * (`PiEngineHost.kt:127`); a package command must not add a surprise + * update check. + * - `CI=1` — suppresses npm's interactive-ish output formatting, which is + * what makes `pi list`'s and npm's stdout stable to parse. + */ + val ENV: Map = mapOf( + "NO_COLOR" to "1", + "GIT_TERMINAL_PROMPT" to "0", + "GIT_SSH_COMMAND" to "ssh -o BatchMode=yes -o ConnectTimeout=5", + "PI_SKIP_VERSION_CHECK" to "1", + "CI" to "1", + ) + } +} diff --git a/app/src/main/kotlin/app/pi/packages/PackageStrings.kt b/app/src/main/kotlin/app/pi/packages/PackageStrings.kt new file mode 100644 index 0000000..e0c019c --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PackageStrings.kt @@ -0,0 +1,120 @@ +package app.pi.packages + +/** + * Every Chinese string this layer shows, in one place. + * + * Two rules, both about not laundering pi's behaviour into something friendlier + * than it is: + * + * - **pi's own words are never translated.** A trust option keeps pi's label + * (`Trust parent folder (/workspace)`, `Trust (this session only)`) and a pi + * error keeps pi's message, because the user will meet the same strings in pi's + * TUI and its docs, and because a translated error cannot be searched for. + * - **The app's Chinese is additive, not substitutive.** Where pi is silent — the + * project-resource skip — the app supplies the sentence pi did not + * (`docs/extension-compatibility.md:574-575`). + * + * Comments are English; the strings are the product surface and are Chinese. + */ +object PackageStrings { + + // ------------------------------------------------------------------ screen + + const val TITLE = "扩展包与项目信任" + const val SUBTITLE = "pi 没有把包管理和 /reload 放进 RPC 协议,所以这些操作由 App 在 guest 里执行 pi 自己的命令行。" + + const val INSTALL_LABEL = "安装" + const val REMOVE_LABEL = "移除" + const val REFRESH_LABEL = "刷新列表" + const val CANCEL = "取消" + const val CONFIRM = "确定" + + const val SPEC_HINT = "npm:@scope/name@1.0.0 / git:github.com/user/repo@v1 / /绝对路径" + + const val SCOPE_USER = "全局(~/.pi/agent/settings.json)" + const val SCOPE_PROJECT = "项目(.pi/settings.json)" + + const val SCOPE_PROJECT_LOCKED = + "项目作用域需要先信任这个项目:pi 会拒绝写入未信任项目的包配置" + + "(package-manager-cli.ts:936-940)。" + + const val NO_PACKAGES = "pi list 报告没有已安装的资源包。" + const val PROJECT_PACKAGES_HIDDEN = + "注意:项目的 .pi/settings.json 里声明了资源包,但这次列表没有显示它们——" + + "项目未获信任时 pi 会把整个项目文档当成空的(settings-manager.ts:405-408)," + + "而 pi list 仍然以退出码 0 结束,什么都不会说。先处理项目信任,再刷新列表。" + const val LIST_UNPARSED = + "pi list 的输出无法解析(下方原样显示)。这不会被当成「没有包」——那正是会骗人的地方。" + + const val RUNNING = "正在执行…" + const val TIMEOUT_NOTE = + "命令超时已被终止。它可能已经写入了部分文件,请先刷新列表再决定下一步。" + + const val STDERR_TITLE = "pi 的 stderr(原样)" + const val STDOUT_TITLE = "pi 的 stdout(原样)" + const val COMMAND_TITLE = "实际执行的 guest 命令" + + const val TRUSTED = "已信任" + const val UNTRUSTED = "未信任" + const val NOT_RECORDED = "无记录" + + const val RESTART_NEEDED_TITLE = "需要重启引擎" + const val RESTART_BUTTON = "重启引擎" + const val RESTART_CONFIRM = "确认重启" + const val RESTART_STAY = "暂不重启" + const val RESTART_WAIT_TURN = "等待回合结束" + const val RESTARTING = "正在重启引擎…" + const val RESTARTING_NOTE = "预计 1–3 秒。当前正在跑的回合会被终止,会话内容不会丢失。" + + const val TRUST_ALREADY_TRUSTED = + "这个项目已获信任,项目本地的扩展、技能、模板与 settings.json 会被加载。" + + const val TRUST_NO_TRIGGER = + "这个项目没有任何需要信任的资源(.pi/settings.json、.pi/extensions、.pi/skills、" + + ".pi/prompts、.pi/themes、.pi/SYSTEM.md、.pi/APPEND_SYSTEM.md、.agents/skills)," + + "因此 pi 不会询问,也没有决定可写。" + + const val TRUST_FILE_LABEL = "trust.json" + + const val TRUST_INVALID_TITLE = "trust.json 无法解析" + const val TRUST_INVALID_ACTION = "移开损坏文件并重建" + const val TRUST_INVALID_NOTE = + "pi 在读取到非法 trust.json 时会抛错并拒绝启动(trust-manager.ts:107-121)," + + "所以这不是一个可以忽略的警告。损坏的文件会被改名保留,不会被删除。" + + const val TRUST_SESSION_ONLY_NOTE = + "「session only」按 pi 自己的语义不写入任何文件(trust-manager.ts:84、:93):" + + "只对本次操作生效。需要让 pi 也认账,请选择会持久化的那一项。" + + /** pi's five choices, explained. Keyed by pi's own label prefix. */ + fun trustSubtitle(label: String): String = when { + label == "Trust" -> + "写入 { \"<路径>\": true }。此后 pi 会加载该项目的 .pi 资源并执行项目扩展。" + label.startsWith("Trust parent folder") -> + "信任上一级目录,并删除本项目自己的条目(pi 的顺序:先写父级,再删本级)。" + + "之后这个父级下的任何项目都会被自动信任——请确认这是你要的粒度。" + label.startsWith("Trust (this session only)") -> + "仅本次操作放行,不写 trust.json。App 会用 --approve 把同样的语义传给 pi 的命令行。" + label == "Do not trust" -> + "写入 { \"<路径>\": false }。项目资源被忽略,而且不会再次询问。" + label.startsWith("Do not trust (this session only)") -> + "仅本次操作拒绝,不写 trust.json。下次仍会询问。" + else -> "" + } + + /** Why the app is showing a prompt at all, in pi's own resolution terms. */ + fun rationale(resolution: ProjectTrust.Resolution, cwd: String, hasTrigger: Boolean): String = when { + !hasTrigger -> TRUST_NO_TRIGGER + resolution.trusted -> TRUST_ALREADY_TRUSTED + else -> resolution.explanation ?: ProjectTrust.skipNote(cwd, resolution.rationale) + } + + /** One line per install/remove result, for the activity log. */ + fun doneHeadline(done: PiPackageService.Done): String = when (done) { + is PiPackageService.Done.Ok -> done.summary + is PiPackageService.Done.Failed -> "失败:${done.message}" + is PiPackageService.Done.Refused -> "已被 App 拒绝:${done.summary}" + is PiPackageService.Done.TimedOut -> done.summary + is PiPackageService.Done.NotReady -> done.summary + } +} diff --git a/app/src/main/kotlin/app/pi/packages/PiListOutput.kt b/app/src/main/kotlin/app/pi/packages/PiListOutput.kt new file mode 100644 index 0000000..545594c --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PiListOutput.kt @@ -0,0 +1,103 @@ +package app.pi.packages + +/** + * A parser for `pi list`'s stdout. + * + * The output is a rendering, not a protocol, so this is deliberately tolerant and + * always keeps the raw text next to what it managed to parse. The shape is fixed + * by `package-manager-cli.ts:970-1004`: + * + * ``` + * User packages: + * npm:foo + * /root/.pi/agent/npm/node_modules/foo + * git:github.com/u/r (filtered) + * + * Project packages: + * npm:bar + * ``` + * + * `(filtered)` is appended when the settings entry is the object form + * (`:981`); the indented line under an entry is `installedPath` (`:983-985`). + * An empty install prints `No packages installed.` and no headers (`:975-978`). + * Both are matched on the **trimmed** line, after ANSI stripping, so chalk's + * bold header cannot defeat the parser. + */ +object PiListOutput { + + private const val USER_HEADER = "User packages:" + private const val PROJECT_HEADER = "Project packages:" + private const val NO_PACKAGES = "No packages installed." + + data class Parsed( + val entries: List, + /** True when pi said there are no packages, rather than the parse failing. */ + val empty: Boolean, + /** True when output was non-empty but nothing was recognised. */ + val unrecognised: Boolean, + ) + + fun parse(stdout: String): Parsed { + val text = app.pi.rpc.Ansi.strip(stdout) + val lines = text.lines() + if (lines.any { it.trim() == NO_PACKAGES }) { + return Parsed(emptyList(), empty = true, unrecognised = false) + } + + val entries = mutableListOf() + var scope: PiPackageScope? = null + var lastIndex = -1 + + for (raw in lines) { + if (raw.isBlank()) continue + val trimmed = raw.trim() + when (trimmed) { + USER_HEADER -> { + scope = PiPackageScope.User + continue + } + PROJECT_HEADER -> { + scope = PiPackageScope.Project + continue + } + } + val indent = raw.takeWhile { it == ' ' || it == '\t' }.length + val activeScope = scope ?: continue + if (indent == 0) continue + + if (indent <= ENTRY_INDENT) { + val filtered = trimmed.endsWith(FILTERED_SUFFIX) + val source = if (filtered) trimmed.removeSuffix(FILTERED_SUFFIX).trim() else trimmed + if (source.isEmpty()) continue + entries += PiPackageEntry( + source = PiPackageSource.parse(source), + scope = activeScope, + filtered = filtered, + installedPath = null, + ) + lastIndex = entries.lastIndex + continue + } + + // Deeper than an entry: pi's `installedPath` continuation line. + if (lastIndex >= 0) { + entries[lastIndex] = entries[lastIndex].copy(installedPath = trimmed) + } + } + + val nonHeaderContent = lines.count { line -> + val t = line.trim() + t.isNotEmpty() && t != USER_HEADER && t != PROJECT_HEADER + } + return Parsed( + entries = entries, + empty = entries.isEmpty() && nonHeaderContent == 0, + unrecognised = entries.isEmpty() && nonHeaderContent > 0, + ) + } + + /** pi prints entries with two leading spaces (`package-manager-cli.ts:982`). */ + private const val ENTRY_INDENT = 2 + + private const val FILTERED_SUFFIX = " (filtered)" +} diff --git a/app/src/main/kotlin/app/pi/packages/PiPackageModel.kt b/app/src/main/kotlin/app/pi/packages/PiPackageModel.kt new file mode 100644 index 0000000..ebf10e2 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PiPackageModel.kt @@ -0,0 +1,36 @@ +package app.pi.packages + +/** + * The two value types shared by the package service, the list parser and the UI. + * + * They live in their own file so that [PiListOutput] — which is pure string + * handling over `pi list`'s output — does not have to depend on the Android-facing + * [PiPackageService] and its [GuestCommand]. Keeping the parser free of Android is + * what makes it checkable off-device, and this parser is exactly the kind of code + * that silently lies when it is wrong: a mis-parsed `pi list` looks identical to + * "no packages installed". + */ + +/** `install`/`remove` write to one of pi's two settings documents. */ +enum class PiPackageScope( + /** The CLI flag pi uses for this scope: `""` or `-l`. */ + val flag: String, + /** App-side label; pi's own names are "user settings" / "project settings". */ + val label: String, +) { + /** `~/.pi/agent/settings.json` — pi's default (`docs/packages.md:43`). */ + User("", "全局(用户)"), + + /** `.pi/settings.json` in the project — `pi install -l`. */ + Project("-l", "项目"), +} + +/** One list row, as `pi list` describes it (`package-manager-cli.ts:970-1004`). */ +data class PiPackageEntry( + val source: PiPackageSource, + val scope: PiPackageScope, + /** True for the object form in settings (`filtered` in pi's output). */ + val filtered: Boolean, + /** `pkg.installedPath`, when pi resolved one. */ + val installedPath: String?, +) diff --git a/app/src/main/kotlin/app/pi/packages/PiPackageService.kt b/app/src/main/kotlin/app/pi/packages/PiPackageService.kt new file mode 100644 index 0000000..f22f545 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PiPackageService.kt @@ -0,0 +1,363 @@ +package app.pi.packages + +import app.pi.runtime.PtyLauncher +import java.io.File + +/** + * `pi install` / `pi remove` / `pi list`, run in the guest. + * + * ## Why the CLI and not `settings.json` + * + * `docs/known-gaps.md` B5 originally proposed editing the `packages` array + * directly. That is wrong, and pi's own source says so: installing is not a + * settings edit. `DefaultPackageManager.install` (`core/package-manager.ts:1005-1027`) + * runs `npm install --prefix /npm --legacy-peer-deps` + * (`:1785-1812`) or `git clone` (`:1831-1863`), and only afterwards does + * `installAndPersist` append the source to settings (`:1029-1032`). A settings + * write alone produces a configured package with no files on disk. + * + * There is also no RPC command for any of this. Verified against the protocol's + * own type: `RpcCommand` (`modes/rpc/rpc-types.ts:20-74`) has no `install`, + * `remove`, `list` or `reload`. So the app must do it at its own layer, and the + * honest way is to run pi's own CLI, in the guest, and show what it said. + * + * ## The exact command line + * + * ``` + * /bin/bash -lc + * exec /opt/node/bin/node /opt/pi/node_modules/@earendil-works/pi-coding-agent/dist/cli.js install 'npm:foo@1.0.0' -l --approve + * ``` + * + * with guest cwd = the workspace, and the workspace bind-mounted. The js entry + * point and the node path are the same two values `PiEngineHost` uses + * (`:95`, `:104`), not a second guess. `bash -lc` and the argv are + * [ProotCommand.build]'s, reused from the engine path. + * + * Placement of flags mirrors pi's parser (`package-manager-cli.ts:375-573`): + * `-l`/`--local` is only meaningful for `install`/`remove` (`:409-416`), + * `--approve` is accepted by all three (`:454-462`), and `list` accepts neither + * `source` nor `-l`. The spec is quoted as one shell word + * ([PtyLauncher.Shell.quote]) so a `@`-bearing spec, a URL with `&`, or a path + * with a space survives. + * + * ## What "silently skip" means here, and why the app refuses to reproduce it + * + * pi's trust gate, when it denies, produces **no output at all** — no event, no + * stderr line (`docs/extension-compatibility.md:574-575`, from `loader.ts:634-637` + * and `main.ts:775-782`). The one place it does speak is the project-scoped package + * command: + * + * ``` + * Project is not trusted. Use --approve to modify local package config. + * ``` + * + * (`package-manager-cli.ts:936-940`, exit code 1). So this service reports that + * verbatim, and separately flags "project resources were skipped" whenever a + * command ran with an unresolved trust decision, even when it exited 0. An install + * that fails silently is worse than one that refuses. + */ +class PiPackageService( + private val layout: AgentLayout, + private val guest: GuestCommand, +) { + + /** + * How the project gate is satisfied for this command. + * + * `ProjectTrustRepository` owns the decision; this is only the wire form the + * CLI understands. `--approve` is pi's own "trust project-local files for this + * command" flag (`package-manager-cli.ts:454-462`), which is exactly the + * session-only answer: it trusts without persisting anything. + */ + enum class TrustPass { + /** No flag. User scope needs none; project scope then fails if untrusted. */ + None, + + /** `--approve`: trust project-local files for this one command. */ + Approve, + } + + // ------------------------------------------------------------------ results + + data class Listing( + val entries: List, + /** `pi list`'s stdout, verbatim, so a parse failure is still readable. */ + val raw: String, + val stderr: String, + val argv: List, + /** + * True when the project's own `.pi/settings.json` declares packages that + * this listing did **not** show, because the project is untrusted. + * + * This is the second silent skip in pi's package path, and it is worse than + * the first because the output looks healthy. `listConfiguredPackages` does + * read a project document (`package-manager.ts:992-1000`), but + * `SettingsManager.getProjectSettings()` returns an empty object while + * `projectTrusted` is false — `loadFromStorage` short-circuits to `{}` for + * project scope (`settings-manager.ts:405-408`, read by `:501-503`) — so + * those entries vanish from a command that exited 0. `mutate` already catches the *denial* string for `-l` + * (`package-manager-cli.ts:936-940`); nothing at all catches this one. + */ + val projectPackagesHidden: Boolean = false, + ) + + sealed interface Done { + val argv: List + val stdout: String + val stderr: String + + /** Human-readable result line, from pi's stdout or the app's own words. */ + val summary: String + + /** + * `Warning:` lines pi printed. Present on success too — pi reports broken + * settings files this way (`package-manager-cli.ts:255-263`, `:737-741`), + * and dropping them is the silent-failure shape this layer exists to avoid. + */ + val warnings: List + + /** True when project-scoped resources were skipped, whatever the exit code. */ + val projectResourcesSkipped: Boolean + + /** `Installed npm:foo` / `Removed npm:foo` / `Installed …(有警告)`. */ + data class Ok( + override val argv: List, + override val stdout: String, + override val stderr: String, + override val summary: String, + override val warnings: List = emptyList(), + override val projectResourcesSkipped: Boolean = false, + ) : Done + + /** pi ran and exited non-zero. [message] is its stderr, verbatim. */ + data class Failed( + override val argv: List, + override val stdout: String, + override val stderr: String, + val message: String, + val exitCode: Int?, + override val warnings: List = emptyList(), + override val projectResourcesSkipped: Boolean = false, + ) : Done { + override val summary: String get() = message + } + + /** The app refused before spawning anything; [message] mirrors a pi message. */ + data class Refused(override val summary: String) : Done { + override val argv: List get() = emptyList() + override val stdout: String get() = "" + override val stderr: String get() = "" + override val warnings: List get() = emptyList() + override val projectResourcesSkipped: Boolean get() = false + } + + /** Killed on the app's own timeout. Never presented as a package error. */ + data class TimedOut( + override val argv: List, + override val stdout: String, + override val stderr: String, + val timeoutMs: Long, + override val projectResourcesSkipped: Boolean = false, + ) : Done { + override val summary: String + get() = "命令超过 ${timeoutMs / 1000} 秒未返回,已终止" + override val warnings: List get() = emptyList() + } + + /** The runtime or the engine is missing, so nothing could be run. */ + data class NotReady(override val summary: String) : Done { + override val argv: List get() = emptyList() + override val stdout: String get() = "" + override val stderr: String get() = "" + override val warnings: List get() = emptyList() + override val projectResourcesSkipped: Boolean get() = false + } + } + + /** What the caller must tell the user after any successful mutation. */ + data class RestartRequired(val changes: List, val detail: String) + + // ---------------------------------------------------------------- commands + + /** + * `pi install ` (`package-manager-cli.ts:954-957`). On success pi prints + * `Installed `; the app does not re-derive that. + */ + fun install(spec: String, scope: PiPackageScope, trust: TrustPass = TrustPass.None): Done = + mutate("install", spec, scope, trust) + + /** `pi remove `; alias `uninstall` exists but the canonical verb is used. */ + fun remove(spec: String, scope: PiPackageScope, trust: TrustPass = TrustPass.None): Done = + mutate("remove", spec, scope, trust) + + private fun mutate(command: String, spec: String, scope: PiPackageScope, trust: TrustPass): Done { + PiPackageSource.validate(command, spec)?.let { return Done.Refused(it.message) } + readiness()?.let { return it } + + val outcome = guest.run( + guestCommand = commandLine(command, spec, scope, trust), + cwd = layout.guestWorkspace, + timeoutMs = GuestCommand.INSTALL_TIMEOUT_MS, + ) + return classify(outcome, successSummary = { source -> + if (command == "install") "Installed $source" else "Removed $source" + }, spec = spec) + } + + /** + * `pi list`. Always reads **both** settings documents, so it must run with the + * workspace as cwd: `listConfiguredPackages` reads `getGlobalSettings()` and + * `getProjectSettings()`, and the latter is `/.pi/settings.json` + * (`package-manager.ts:977-1003`). + */ + fun list(trust: TrustPass = TrustPass.None, projectTrusted: Boolean = false): Listing { + readiness()?.let { return Listing(emptyList(), "", it.summary, emptyList()) } + val outcome = guest.run( + guestCommand = commandLine("list", spec = null, scope = PiPackageScope.User, trust = trust), + cwd = layout.guestWorkspace, + timeoutMs = GuestCommand.LIST_TIMEOUT_MS, + ) + return Listing( + entries = PiListOutput.parse(outcome.stdout).entries, + raw = outcome.stdout, + stderr = outcome.stderr, + argv = outcome.argv, + projectPackagesHidden = !projectTrusted && projectDeclaresPackages(), + ) + } + + /** + * Reads `/.pi/settings.json` and answers whether it lists any packages. + * + * Reads the **host** copy of the project document, which is the same file pi + * reads: the workspace is the one thing the engine does bind (`PiEngineHost.kt:116`), + * so `/.pi/settings.json` and `/.pi/settings.json` are the + * same bytes. This is the one path where that is true, and the difference is + * worth remembering — see [AgentLayout]. + */ + fun projectDeclaresPackages(): Boolean { + val file = File(layout.hostProjectConfigDir(), "settings.json") + if (!file.isFile) return false + val text = runCatching { file.readText() }.getOrNull() ?: return false + val document = app.pi.rpc.PiJson.parseObjectOrNull(text) ?: return false + val packages = app.pi.rpc.SettingsDocument.lookup(document, "packages") ?: return false + val array = packages as? kotlinx.serialization.json.JsonArray ?: return false + return array.isNotEmpty() + } + + /** + * The exact string `bash -lc` receives. Public because the report and the UI's + * "what did you actually run" affordance both need it, and because a test can + * assert on it without a device. + */ + fun commandLine(command: String, spec: String?, scope: PiPackageScope, trust: TrustPass): String { + val words = mutableListOf(command) + if (spec != null) words += PtyLauncher.Shell.quote(spec) + if (scope.flag.isNotEmpty()) words += scope.flag + if (trust == TrustPass.Approve) words += "--approve" + return "exec ${layout.guestNode} ${layout.guestEngineCli} ${words.joinToString(" ")}" + } + + /** [RestartRequired] for a completed mutation, or null when nothing changed. */ + fun restartRequirement(done: Done): RestartRequired? = when (done) { + is Done.Ok -> RestartRequired( + changes = listOf(done.summary), + detail = "pi 在启动时用 jiti 把扩展加载进进程,运行中的进程不会重新扫描扩展目录。" + + "新装的包只有在 /reload 或重启引擎之后才生效。", + ) + else -> null + } + + // --------------------------------------------------------------- internals + + private fun readiness(): Done.NotReady? { + if (!layout.runtimeReady()) { + return Done.NotReady("运行时尚未就绪:rootfs 或 proot 缺失,无法在 guest 里执行 pi。") + } + if (!layout.engineInstalled()) { + return Done.NotReady("引擎未安装:找不到 ${layout.guestEngineCli}。") + } + return null + } + + private fun classify( + outcome: GuestCommand.Outcome, + successSummary: (String) -> String, + spec: String, + ): Done { + val warnings = warningsIn(outcome.stderr) + val skipped = PROJECT_SKIP_MARKERS.any { outcome.stderr.contains(it) } + + if (outcome.launchError != null) { + return Done.NotReady("无法启动 proot:${outcome.launchError}") + } + if (outcome.timedOut) { + return Done.TimedOut( + argv = outcome.argv, + stdout = outcome.stdout, + stderr = outcome.stderr, + timeoutMs = GuestCommand.INSTALL_TIMEOUT_MS, + projectResourcesSkipped = skipped, + ) + } + if (outcome.exitCode == 0) { + // pi's own success line is in stdout; if it is missing (a future pi, or + // output pi suppressed) fall back to the equivalent pi wording rather + // than showing an empty result. + val fromPi = outcome.stdout.lineSequence() + .map { it.trim() } + .firstOrNull { it.startsWith("Installed ") || it.startsWith("Removed ") } + val summary = fromPi + ?: successSummary(spec).let { if (warnings.isEmpty()) it else "$it(有警告)" } + return Done.Ok( + argv = outcome.argv, + stdout = outcome.stdout, + stderr = outcome.stderr, + summary = summary, + warnings = warnings, + projectResourcesSkipped = skipped, + ) + } + return Done.Failed( + argv = outcome.argv, + stdout = outcome.stdout, + stderr = outcome.stderr, + message = failureMessage(outcome), + exitCode = outcome.exitCode, + warnings = warnings, + projectResourcesSkipped = skipped, + ) + } + + /** + * pi's error surface is `console.error(chalk.red(\`Error: ${message}\`))` + * (`package-manager-cli.ts:1096-1100`) plus the specific trust and + * no-match lines (`:936-940`, `:960-965`). All of them land on stderr, so the + * stderr text **is** the message — quoting it is more faithful than + * reimplementing the branches, and it cannot drift from pi. + */ + private fun failureMessage(outcome: GuestCommand.Outcome): String = when { + outcome.stderr.isNotBlank() -> outcome.stderr.trim() + outcome.stdout.isNotBlank() -> outcome.stdout.trim() + else -> "pi 退出码 ${outcome.exitCode},且没有任何输出" + } + + companion object { + /** + * pi's two trust-denial strings for package commands + * (`package-manager-cli.ts:937`, applied to `install`/`remove`/`list` + * because `assertProjectTrustedForScope` throws its own message at + * `package-manager.ts:1741-1745` when scope is project). + */ + private val PROJECT_SKIP_MARKERS = listOf( + "Project is not trusted", + "refusing to access project package storage", + ) + + /** `Warning: …` lines from `reportProjectTrustWarnings`/`reportSettingsErrors`. */ + fun warningsIn(stderr: String): List = stderr.lineSequence() + .map { it.trim() } + .filter { it.startsWith("Warning:") || it.startsWith("Warning ") } + .toList() + } +} diff --git a/app/src/main/kotlin/app/pi/packages/PiPackageSource.kt b/app/src/main/kotlin/app/pi/packages/PiPackageSource.kt new file mode 100644 index 0000000..3a38adb --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PiPackageSource.kt @@ -0,0 +1,315 @@ +package app.pi.packages + +/** + * What a `pi install` argument means, classified the way pi classifies it. + * + * pi's `DefaultPackageManager.parseSource` (`core/package-manager.ts:1446-1471`) is + * the authority, and this is a transcription of it: + * + * ``` + * npm: -> npm source, spec after the prefix, name/version split by parseNpmSpec + * local -> if isLocalPath(source) (utils/paths.ts:50-64) + * git -> else if parseGitUrl(source) succeeds (utils/git.ts:172-...) + * local -> otherwise, and the *whole string* becomes a path + * ``` + * + * That last line is a real pi behaviour worth stating, because it is surprising + * and the app must not paper over it: `pi install https://example.com/pkg.tar.gz` + * is **not** a download. `parseGitUrl` requires a git protocol URL or a `git:` + * prefix, so an ordinary https URL falls through to `local`, and `install()` then + * throws `Path does not exist: ` (`package-manager.ts:1018-1023`). The + * app reports that, in pi's words, instead of inventing "URL downloads are not + * supported yet". + * + * ## This classification is not a gate + * + * The app never *rejects* a spec because of this type. pi is the parser; if the + * two disagree, pi wins and its own error is surfaced verbatim. The type is used + * for (a) the label on a list row, (b) deciding whether `npm install` will run, + * and (c) knowing that only npm and git sources have an installed path to report. + * Pre-flight refusal is limited to input that pi cannot accept at all — see + * [validate]. + */ +sealed interface PiPackageSource { + + /** The argument exactly as the user typed it; this is what settings records. */ + val raw: String + + /** + * `npm:@scope/name@version`. [spec] is what follows `npm:`; [name] is the + * package identity pi dedupes on (`package-manager.ts:224-230`, `:1731-1739`). + */ + data class Npm( + override val raw: String, + val spec: String, + val name: String, + val version: String?, + /** True for an exact version (`semver.valid`), false for a range/tag. */ + val pinned: Boolean, + ) : PiPackageSource + + /** + * A git source. [repo] is the clone URL pi would pass to `git clone` + * (`utils/git.ts:116-123`); [ref] is a pinned tag or commit. + */ + data class Git( + override val raw: String, + val repo: String, + val host: String, + val path: String, + val ref: String?, + ) : PiPackageSource + + /** + * Anything else: an absolute path, a relative path, a `file:` URL, or a bare + * name. pi resolves it against the settings file's own directory for storage + * (`package-manager.ts:1435-1444`) and against the process cwd for install. + */ + data class Local(override val raw: String) : PiPackageSource + + companion object { + + fun parse(source: String): PiPackageSource { + val trimmed = source.trim() + + if (trimmed.startsWith(NPM_PREFIX)) { + val spec = trimmed.substring(NPM_PREFIX.length).trim() + val name = npmPackageName(spec) + val version = npmVersionPart(spec) + return PiPackageSource.Npm( + raw = source, + spec = spec, + name = name, + version = version, + pinned = isExactNpmVersion(version), + ) + } + + if (isLocalPath(trimmed)) return Local(source) + + val git = parseGitUrl(trimmed) + if (git != null) { + return Git(raw = source, repo = git.repo, host = git.host, path = git.path, ref = git.ref) + } + + return Local(source) + } + + /** + * `utils/paths.ts:50-64`, verbatim including the asymmetry: `github:` is + * treated as non-local there even though `parseGitUrl` will not accept it + * either, so `github:x/y` ends up a local path that does not exist. Faithful + * to pi on purpose. + */ + fun isLocalPath(value: String): Boolean { + val trimmed = value.trim() + return !LOCAL_EXEMPT_PREFIXES.any { trimmed.startsWith(it) } + } + + private val LOCAL_EXEMPT_PREFIXES = listOf( + "npm:", + "git:", + "github:", + "http:", + "https:", + "ssh:", + ) + + const val NPM_PREFIX = "npm:" + + /** + * `parseNpmSpec` (`package-manager.ts:1731-1739`). + * + * The regex splits `@scope/name@1.2.3` into name `@scope/name`, version + * `1.2.3`, and leaves an unscoped `name` alone. + */ + fun npmPackageName(spec: String): String { + val match = NPM_SPEC.matchEntire(spec) ?: return spec + return match.groupValues[1].ifEmpty { spec } + } + + /** The `@version` half, or null. */ + fun npmVersionPart(spec: String): String? { + val match = NPM_SPEC.matchEntire(spec) ?: return null + return match.groupValues[2].ifEmpty { null } + } + + private val NPM_SPEC = Regex("^(@?[^@]+(?:/[^@]+)?)(?:@(.+))?$") + + /** + * `isExactNpmVersion` is `semver.valid(version) !== null` + * (`package-manager.ts:59-61`), so this is "is a concrete version", not + * "is a range". `1.2.3` is pinned; `^1.2`, `latest` and `>=2` are not. + * pi uses the flag to skip pinned packages during `pi update --extensions`, + * so the label the app shows is pi's own distinction. + */ + fun isExactNpmVersion(version: String?): Boolean { + if (version == null) return false + val core = version.substringBefore('+').substringBefore('-') + val parts = core.split('.') + if (parts.size != 3) return false + if (parts.any { it.isEmpty() || it.any { ch -> !ch.isDigit() } }) return false + // Prerelease/build metadata must still be well formed if present. + val rest = version.removePrefix(core) + return rest.isEmpty() || rest.startsWith("-") || rest.startsWith("+") + } + + // ------------------------------------------------------------------ git + // + // A transcription of the reachable half of `parseGitUrl` + // (`utils/git.ts:172-...`, `:104-163`). `hostedGitInfo` handles a few extra + // hosted shorthands (`gist:`, `bitbucket:`); those fall through to local + // here. That is safe in one direction only — the app must never claim a + // spec is invalid, so [validate] refuses nothing on account of this. + + fun parseGitUrl(source: String): PiPackageSource.Git? { + val trimmed = source.trim() + val hasPrefix = trimmed.startsWith("git:") + val url = if (hasPrefix) trimmed.substring(4).trim() else trimmed + if (!hasPrefix && !PROTOCOL_URL.containsMatchIn(url)) return null + + val split = splitRef(url) + val repo = split.first + val ref = split.second + + // `git@host:user/repo` survives splitRef as an scp-like string, and pi + // hands it to hosted-git-info (`utils/git.ts:132-135`, `:186-204`). The + // clone URL stays as written — `useHttpsPrefix` is false for anything + // starting with `git@` (`:192-197`). + SCP_LIKE.matchEntire(repo)?.let { scp -> + return buildGitSource(repo, scp.groupValues[1], scp.groupValues[2], ref) + } + // A protocol URL: host from the authority, path from the pathname + // (`utils/git.ts:136-148`). + if (PROTOCOL_URL.containsMatchIn(repo)) { + val parts = parseUrlParts(repo) ?: return null + return buildGitSource(repo, parts.first, parts.second, ref) + } + // `host/user/repo` shorthand, which only counts as hosted when the host + // looks like a hostname; pi then prefixes https + // (`utils/git.ts:149-160`, `:192-197`). + val slash = repo.indexOf('/') + if (slash < 0) return null + val host = repo.substring(0, slash) + val path = repo.substring(slash + 1) + if (!host.contains('.') && host != "localhost") return null + return buildGitSource("https://$repo", host, path, ref) + } + + private val PROTOCOL_URL = Regex("^(https?|ssh|git)://", RegexOption.IGNORE_CASE) + private val SCP_LIKE = Regex("^git@([^:]+):(.+)$") + + private fun buildGitSource(repo: String, host: String, rawPath: String, ref: String?): PiPackageSource.Git? { + if (rawPath.startsWith("/")) return null + val path = rawPath.removeSuffix(".git").trimStart('/') + if (host.isEmpty() || path.isEmpty() || path.split('/').size < 2) return null + if (hasUnsafeGitInstallPart(host, allowSlash = false)) return null + if (hasUnsafeGitInstallPart(path, allowSlash = true)) return null + return PiPackageSource.Git(raw = repo, repo = repo, host = host, path = path, ref = ref) + } + + /** `hasUnsafeGitInstallPart` (`utils/git.ts:84-102`). */ + private fun hasUnsafeGitInstallPart(value: String, allowSlash: Boolean): Boolean { + val decoded = runCatching { java.net.URLDecoder.decode(value, "UTF-8") }.getOrNull() ?: return true + for (candidate in listOf(value, decoded)) { + if (candidate.contains('\u0000') || candidate.contains('\\') || candidate.startsWith("/")) return true + if (!allowSlash && candidate.contains('/')) return true + if (candidate.split('/').contains("..")) return true + } + return false + } + + /** + * `splitRef` (`utils/git.ts:21-74`), all three branches. + * + * The `://` branch is the one that is easy to get wrong: the ref lives in + * the **pathname**, so `ssh://git@github.com/user/repo` has no ref at all — + * the `@` before `github.com` is user-info, not a ref separator. Reading the + * raw string with `indexOf('@')` splits that URL into `"/git"` and + * `"github.com/user/repo"`. pi avoids it with `new URL(...)` (`:36-53`), and + * so does this. + */ + private fun splitRef(url: String): Pair { + SCP_LIKE.matchEntire(url)?.let { scp -> + val pathWithMaybeRef = scp.groupValues[2] + val at = pathWithMaybeRef.indexOf('@') + if (at < 0) return url to null + val repoPath = pathWithMaybeRef.substring(0, at) + val ref = pathWithMaybeRef.substring(at + 1) + if (repoPath.isEmpty() || ref.isEmpty()) return url to null + return "git@${scp.groupValues[1]}:$repoPath" to ref + } + + if (url.contains("://")) { + return runCatching { + val uri = java.net.URI(url) + val pathname = uri.path?.trimStart('/').orEmpty() + val at = pathname.indexOf('@') + if (at < 0) return@runCatching url to null + val repoPath = pathname.substring(0, at) + val ref = pathname.substring(at + 1) + if (repoPath.isEmpty() || ref.isEmpty()) return@runCatching url to null + // `parsed.pathname = "/" + repoPath` then `parsed.toString()` + // minus a trailing slash (`utils/git.ts:45-48`). + val rebuilt = java.net.URI( + uri.scheme, + uri.userInfo, + uri.host, + uri.port, + "/$repoPath", + null, + null, + ) + rebuilt.toString().removeSuffix("/") to ref + }.getOrElse { url to null } + } + + val slash = url.indexOf('/') + if (slash < 0) return url to null + val host = url.substring(0, slash) + val pathWithMaybeRef = url.substring(slash + 1) + val at = pathWithMaybeRef.indexOf('@') + if (at < 0) return url to null + val repoPath = pathWithMaybeRef.substring(0, at) + val ref = pathWithMaybeRef.substring(at + 1) + if (repoPath.isEmpty() || ref.isEmpty()) return url to null + return "$host/$repoPath" to ref + } + + private fun parseUrlParts(url: String): Pair? = runCatching { + val uri = java.net.URI(url) + val host = uri.host ?: return null + host to uri.path.trimStart('/') + }.getOrNull() + + // -------------------------------------------------------------- validate + + /** A refusal the app makes before spawning anything. */ + data class Rejection(val message: String) + + /** + * The only pre-flight refusals, each mirroring a pi code path so the app + * never produces a message pi would not: + * + * - blank → `handlePackageCommand` prints `Missing install source.` + * (`package-manager-cli.ts:907-912`). + * - leading `-` → pi's option parser swallows it and reports + * `Unknown option for "".` (`:492-495`, `:878-883`). + * + * Everything else — a URL that is not a git URL, a relative path that does + * not exist, a package name npm will not resolve — is left to pi, whose + * message is surfaced verbatim. An install that fails silently is worse + * than one that refuses; an install the app refuses for its own reasons is + * worse than both. + */ + fun validate(command: String, source: String?): Rejection? { + if (source == null || source.isBlank()) { + return Rejection("Missing $command source.") + } + if (source.trimStart().startsWith("-")) { + return Rejection("Unknown option ${source.trim()} for \"$command\".") + } + return null + } + } +} diff --git a/app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt b/app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt new file mode 100644 index 0000000..87ec9d5 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PiPackagesScreen.kt @@ -0,0 +1,618 @@ +package app.pi.packages + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import app.pi.ui.theme.PiShapes +import app.pi.ui.theme.PiTheme + +/** + * The whole install / list / remove / restart / trust surface, as one state-driven + * screen. + * + * It is state-*driven* on purpose: everything the user sees is derived from + * [PiPackagesUiState], which is assembled outside this package from + * [PiPackageService], [ExtensionLifecycle] and [TrustRepository]. The owner of + * The `ui` package wires it up; this file never reaches into the engine, never starts + * a restart, and never writes a trust record itself. That keeps the one rule that + * matters — **no silent restart, no silent trust** — a property of the state machine + * rather than of a button's onClick. + */ +data class PiPackagesUiState( + val installedRoot: String = "", + /** The workspace whose trust decision is being shown, in guest spelling. */ + val guestWorkspace: String = "", + val spec: String = "", + val scope: PiPackageScope = PiPackageScope.User, + val busy: Boolean = false, + val lifecycle: ExtensionLifecycle.State = ExtensionLifecycle.State.Idle, + val entries: List = emptyList(), + /** `pi list`'s raw stdout, shown whenever parsing found nothing. */ + val listRaw: String = "", + val listUnparsed: Boolean = false, + /** + * `.pi/settings.json` lists packages that this listing could not show, because + * the project is untrusted. `pi list` exits 0 in that case and prints nothing + * about them, so the app has to say it. + */ + val projectPackagesHidden: Boolean = false, + /** The last command's streams and argv, verbatim, newest first. */ + val log: List = emptyList(), + /** Null when the project has no trust-requiring resources at all. */ + val trust: TrustPanel? = null, + /** Non-null when a trust record is invalid and pi would refuse to start. */ + val trustInvalid: String? = null, +) { + + data class LogLine( + val headline: String, + val command: String, + val stdout: String, + val stderr: String, + val warnings: List = emptyList(), + ) + + data class TrustPanel( + /** `True` / `False` / null, exactly as `trust.json` holds it. */ + val decision: Boolean?, + /** Which branch decided, and what it means for the user. */ + val explanation: String, + val promptOptions: List, + val promptVisible: Boolean, + /** True when project resources are being skipped right now. */ + val skippingResources: Boolean, + /** True when a project-scope package operation is blocked by this. */ + val blocksProjectPackages: Boolean, + ) + + val needsRestart: Boolean get() = lifecycle is ExtensionLifecycle.State.NeedsRestart + val awaitingIdle: Boolean get() = lifecycle is ExtensionLifecycle.State.AwaitingIdle + val awaitingConfirm: Boolean get() = lifecycle is ExtensionLifecycle.State.AwaitingConfirmation + val restarting: Boolean get() = lifecycle is ExtensionLifecycle.State.Restarting +} + +@Composable +fun PiPackagesScreen( + state: PiPackagesUiState, + onSpecChange: (String) -> Unit, + onScopeChange: (PiPackageScope) -> Unit, + onInstall: () -> Unit, + onRemove: (PiPackageEntry) -> Unit, + onRefresh: () -> Unit, + onRestartClick: () -> Unit, + onRestartConfirm: () -> Unit, + onRestartCancel: () -> Unit, + onOpenTrustPrompt: () -> Unit, + onTrustChoose: (ProjectTrust.Option) -> Unit, + onTrustDismiss: () -> Unit, + onTrustRepair: () -> Unit, +) { + if (state.awaitingConfirm) { + RestartConfirmDialog( + lifecycle = state.lifecycle, + onConfirm = onRestartConfirm, + onCancel = onRestartCancel, + ) + } + val trust = state.trust + if (trust != null && trust.promptVisible) { + PiProjectTrustPrompt( + cwd = state.guestWorkspace, + options = trust.promptOptions, + explanation = trust.explanation, + busy = state.busy, + onChoose = onTrustChoose, + onDismiss = onTrustDismiss, + ) + } + + val palette = PiTheme.palette + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .background(palette.pageBg) + .padding(horizontal = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + item { Header(state) } + trust?.let { item { TrustCard(it, state.busy, onOpenTrustPrompt) } } + state.trustInvalid?.let { item { InvalidTrustCard(it, state.busy, onTrustRepair) } } + item { LifecycleCard(state, onRestartClick, onRestartConfirm, onRestartCancel) } + item { InstallCard(state, onSpecChange, onScopeChange, onInstall, onRefresh) } + + if (state.projectPackagesHidden) { + item { + Text( + text = PackageStrings.PROJECT_PACKAGES_HIDDEN, + style = MaterialTheme.typography.bodySmall, + color = PiTheme.palette.warning, + ) + } + } + if (state.entries.isEmpty()) { + item { + Text( + text = if (state.listUnparsed) PackageStrings.LIST_UNPARSED else PackageStrings.NO_PACKAGES, + style = MaterialTheme.typography.bodySmall, + color = if (state.listUnparsed) palette.warning else palette.muted, + ) + } + } + items(state.entries, key = { "${it.scope.name}:${it.source.raw}" }) { entry -> + PackageRow(entry, state.busy, onRemove) + } + + if (state.listUnparsed && state.listRaw.isNotBlank()) { + item { RawBlock(PackageStrings.STDOUT_TITLE, state.listRaw) } + } + items(state.log, key = { it.command + it.headline }) { line -> LogCard(line) } + item { Spacer(Modifier.height(24.dp)) } + } +} + +@Composable +private fun Header(state: PiPackagesUiState) { + val palette = PiTheme.palette + Column(Modifier.padding(top = 12.dp)) { + Text(PackageStrings.TITLE, style = MaterialTheme.typography.titleLarge, color = palette.text) + Spacer(Modifier.height(4.dp)) + Text(PackageStrings.SUBTITLE, style = MaterialTheme.typography.bodySmall, color = palette.muted) + if (state.installedRoot.isNotBlank()) { + Spacer(Modifier.height(4.dp)) + Text( + text = "pi 的 agent 目录:${state.installedRoot}", + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + } + } +} + +// ------------------------------------------------------------------ trust card + +@Composable +private fun TrustCard( + trust: PiPackagesUiState.TrustPanel, + busy: Boolean, + onOpenPrompt: () -> Unit, +) { + val palette = PiTheme.palette + val decisionText = when (trust.decision) { + true -> PackageStrings.TRUSTED + false -> PackageStrings.UNTRUSTED + null -> PackageStrings.NOT_RECORDED + } + val decisionColor = when (trust.decision) { + true -> palette.success + false -> palette.error + null -> palette.warning + } + Surface(color = palette.cardBg, shape = PiShapes.card) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text("项目信任", style = MaterialTheme.typography.titleSmall, color = palette.text) + Spacer(Modifier.weight(1f)) + Text(decisionText, style = MaterialTheme.typography.labelLarge, color = decisionColor) + } + Spacer(Modifier.height(6.dp)) + Text( + text = trust.explanation, + style = MaterialTheme.typography.bodySmall, + color = if (trust.skippingResources) palette.warning else palette.muted, + ) + if (trust.blocksProjectPackages) { + Spacer(Modifier.height(6.dp)) + Text( + text = PackageStrings.SCOPE_PROJECT_LOCKED, + style = MaterialTheme.typography.labelSmall, + color = palette.warning, + ) + } + Spacer(Modifier.height(10.dp)) + OutlinedButton(onClick = onOpenPrompt, enabled = !busy) { + Text("作出信任决定…") + } + } + } +} + +@Composable +private fun InvalidTrustCard(message: String, busy: Boolean, onRepair: () -> Unit) { + val palette = PiTheme.palette + Surface(color = palette.toolErrorBg, shape = PiShapes.card) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text( + PackageStrings.TRUST_INVALID_TITLE, + style = MaterialTheme.typography.titleSmall, + color = palette.error, + ) + Spacer(Modifier.height(6.dp)) + Text(message, style = MaterialTheme.typography.bodySmall, color = palette.text) + Spacer(Modifier.height(6.dp)) + Text( + PackageStrings.TRUST_INVALID_NOTE, + style = MaterialTheme.typography.labelSmall, + color = palette.muted, + ) + Spacer(Modifier.height(10.dp)) + Button(onClick = onRepair, enabled = !busy) { Text(PackageStrings.TRUST_INVALID_ACTION) } + } + } +} + +// -------------------------------------------------------------- lifecycle card + +@Composable +private fun LifecycleCard( + state: PiPackagesUiState, + onRestartClick: () -> Unit, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + val lifecycle = state.lifecycle + if (lifecycle is ExtensionLifecycle.State.Idle) return + if (lifecycle is ExtensionLifecycle.State.Ready) { + Text( + text = lifecycle.note, + style = MaterialTheme.typography.bodySmall, + color = PiTheme.palette.success, + ) + return + } + + val palette = PiTheme.palette + val accent = when (lifecycle) { + is ExtensionLifecycle.State.NeedsRestart -> palette.warning + is ExtensionLifecycle.State.AwaitingIdle -> palette.warning + is ExtensionLifecycle.State.Restarting -> palette.accent + else -> palette.muted + } + Surface(color = palette.cardBg, shape = PiShapes.card) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text( + text = when (lifecycle) { + is ExtensionLifecycle.State.NeedsRestart -> PackageStrings.RESTART_NEEDED_TITLE + is ExtensionLifecycle.State.AwaitingIdle -> PackageStrings.RESTART_WAIT_TURN + is ExtensionLifecycle.State.Restarting -> PackageStrings.RESTARTING + is ExtensionLifecycle.State.Installing -> PackageStrings.RUNNING + else -> lifecycle.label + }, + style = MaterialTheme.typography.titleSmall, + color = accent, + ) + when (lifecycle) { + is ExtensionLifecycle.State.NeedsRestart -> { + Spacer(Modifier.height(6.dp)) + Text( + lifecycle.detail, + style = MaterialTheme.typography.bodySmall, + color = palette.muted, + ) + lifecycle.lastError?.let { error -> + Spacer(Modifier.height(6.dp)) + Text(error, style = MaterialTheme.typography.bodySmall, color = palette.error) + } + Spacer(Modifier.height(6.dp)) + Text( + text = ExtensionLifecycle.ANSWER_HINT, + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onRestartClick) { Text(PackageStrings.RESTART_BUTTON) } + TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) } + } + } + + is ExtensionLifecycle.State.AwaitingIdle -> { + Spacer(Modifier.height(6.dp)) + Text( + lifecycle.turnNote, + style = MaterialTheme.typography.bodySmall, + color = palette.warning, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + // Re-checks the turn rather than promising a restart: the + // dialog only appears when the engine reports idle. + OutlinedButton(onClick = onRestartClick) { Text(PackageStrings.RESTART_BUTTON) } + TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) } + } + } + + is ExtensionLifecycle.State.AwaitingConfirmation -> { + // The dialog is rendered by PiPackagesScreen; the card is the + // fallback if the dialog was dismissed. + Spacer(Modifier.height(6.dp)) + Text( + lifecycle.question, + style = MaterialTheme.typography.bodySmall, + color = palette.text, + ) + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onConfirm) { Text(PackageStrings.RESTART_CONFIRM) } + TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) } + } + } + + is ExtensionLifecycle.State.Restarting -> { + Spacer(Modifier.height(6.dp)) + Text( + text = PackageStrings.RESTARTING_NOTE, + style = MaterialTheme.typography.bodySmall, + color = palette.muted, + ) + } + + else -> { + Spacer(Modifier.height(6.dp)) + Text( + lifecycle.changesOrEmpty().joinToString("\n"), + style = MaterialTheme.typography.bodySmall, + color = palette.muted, + ) + } + } + } + } +} + +/** `changes` is only carried by the three pending states; the others have none. */ +private fun ExtensionLifecycle.State.changesOrEmpty(): List = when (this) { + is ExtensionLifecycle.State.NeedsRestart -> changes + is ExtensionLifecycle.State.AwaitingConfirmation -> changes + is ExtensionLifecycle.State.AwaitingIdle -> changes + is ExtensionLifecycle.State.Restarting -> changes + else -> emptyList() +} + +@Composable +private fun RestartConfirmDialog( + lifecycle: ExtensionLifecycle.State, + onConfirm: () -> Unit, + onCancel: () -> Unit, +) { + val palette = PiTheme.palette + val question = (lifecycle as? ExtensionLifecycle.State.AwaitingConfirmation)?.question + ?: return + AlertDialog( + // Dismissal is a cancel, never an implicit confirm: `onDismissRequest` + // routes to onCancel, matching pi's own "dismissal means no" + // (`project-trust.ts:90-95`) and, more importantly, never restarting on a + // back gesture. + onDismissRequest = onCancel, + title = { Text(PackageStrings.RESTART_NEEDED_TITLE, color = palette.text) }, + text = { + Column { + Text(question, style = MaterialTheme.typography.bodySmall, color = palette.muted) + Spacer(Modifier.height(8.dp)) + Text( + text = "重启是显式的:在你点下确认之前,App 不会重启引擎。", + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + } + }, + confirmButton = { Button(onClick = onConfirm) { Text(PackageStrings.RESTART_CONFIRM) } }, + dismissButton = { TextButton(onClick = onCancel) { Text(PackageStrings.RESTART_STAY) } }, + containerColor = palette.cardBg, + titleContentColor = palette.text, + textContentColor = palette.muted, + ) +} + +// ---------------------------------------------------------------- install card + +@Composable +private fun InstallCard( + state: PiPackagesUiState, + onSpecChange: (String) -> Unit, + onScopeChange: (PiPackageScope) -> Unit, + onInstall: () -> Unit, + onRefresh: () -> Unit, +) { + val palette = PiTheme.palette + Surface(color = palette.cardBg, shape = PiShapes.card) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + OutlinedTextField( + value = state.spec, + onValueChange = onSpecChange, + label = { Text(PackageStrings.SPEC_HINT) }, + singleLine = true, + enabled = !state.busy, + modifier = Modifier.fillMaxWidth(), + ) + Spacer(Modifier.height(8.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + PiPackageScope.entries.forEach { scope -> + val selected = scope == state.scope + OutlinedButton( + onClick = { onScopeChange(scope) }, + enabled = !state.busy, + ) { + Text( + text = if (scope == PiPackageScope.User) { + PackageStrings.SCOPE_USER + } else { + PackageStrings.SCOPE_PROJECT + }, + color = if (selected) palette.accent else palette.muted, + ) + } + } + } + if (state.scope == PiPackageScope.Project) { + Spacer(Modifier.height(6.dp)) + Text( + text = PackageStrings.SCOPE_PROJECT_LOCKED, + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + } + Spacer(Modifier.height(10.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + Button(onClick = onInstall, enabled = !state.busy && state.spec.isNotBlank()) { + Text(if (state.busy) PackageStrings.RUNNING else PackageStrings.INSTALL_LABEL) + } + OutlinedButton(onClick = onRefresh, enabled = !state.busy) { + Text(PackageStrings.REFRESH_LABEL) + } + } + } + } +} + +// ----------------------------------------------------------------- package row + +@Composable +private fun PackageRow( + entry: PiPackageEntry, + busy: Boolean, + onRemove: (PiPackageEntry) -> Unit, +) { + val palette = PiTheme.palette + Surface(color = palette.infoBg, shape = PiShapes.cardInner) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Row(verticalAlignment = androidx.compose.ui.Alignment.CenterVertically) { + Text( + text = when (entry.source) { + is PiPackageSource.Npm -> "npm" + is PiPackageSource.Git -> "git" + is PiPackageSource.Local -> "本地" + }, + style = MaterialTheme.typography.labelMedium, + color = palette.accent, + ) + Spacer(Modifier.weight(1f)) + Text( + text = if (entry.scope == PiPackageScope.User) { + PackageStrings.SCOPE_USER + } else { + PackageStrings.SCOPE_PROJECT + }, + style = MaterialTheme.typography.labelSmall, + color = palette.muted, + ) + } + Spacer(Modifier.height(4.dp)) + Text( + text = entry.source.raw, + style = MaterialTheme.typography.bodyMedium, + color = palette.text, + ) + (entry.source as? PiPackageSource.Npm)?.let { npm -> + Spacer(Modifier.height(2.dp)) + Text( + text = buildString { + append("包名 ${npm.name}") + npm.version?.let { append(",版本 $it") } + append(if (npm.pinned) "(精确版本,pi update --extensions 会跳过)" else "(范围/标签,可被 update 移动)") + }, + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + } + (entry.source as? PiPackageSource.Git)?.let { git -> + Spacer(Modifier.height(2.dp)) + Text( + text = buildString { + append("仓库 ${git.repo}") + append(if (git.ref != null) ",ref ${git.ref}(已钉住)" else ",未钉 ref") + }, + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + } + if (entry.filtered) { + Spacer(Modifier.height(2.dp)) + Text( + text = "settings 里是对象形式:只加载显式列出的资源(可能只加载一部分)。", + style = MaterialTheme.typography.labelSmall, + color = palette.warning, + ) + } + entry.installedPath?.let { path -> + Spacer(Modifier.height(2.dp)) + Text(path, style = MaterialTheme.typography.labelSmall, color = palette.dim) + } ?: run { + Spacer(Modifier.height(2.dp)) + Text( + text = "pi 没有报告安装路径——它可能尚未下载到磁盘,或该来源没有安装路径。", + style = MaterialTheme.typography.labelSmall, + color = palette.warning, + ) + } + Spacer(Modifier.height(8.dp)) + TextButton(onClick = { onRemove(entry) }, enabled = !busy) { + Text(PackageStrings.REMOVE_LABEL, color = palette.error) + } + } + } +} + +// ------------------------------------------------------------------- raw cards + +@Composable +private fun LogCard(line: PiPackagesUiState.LogLine) { + val palette = PiTheme.palette + Surface(color = palette.cardBg, shape = PiShapes.card) { + Column(Modifier.fillMaxWidth().padding(12.dp)) { + Text(line.headline, style = MaterialTheme.typography.titleSmall, color = palette.text) + if (line.warnings.isNotEmpty()) { + Spacer(Modifier.height(6.dp)) + line.warnings.forEach { warning -> + Text(warning, style = MaterialTheme.typography.bodySmall, color = palette.warning) + } + } + if (line.stderr.isNotBlank()) { + Spacer(Modifier.height(6.dp)) + RawBlock(PackageStrings.STDERR_TITLE, line.stderr) + } + if (line.stdout.isNotBlank()) { + Spacer(Modifier.height(6.dp)) + RawBlock(PackageStrings.STDOUT_TITLE, line.stdout) + } + Spacer(Modifier.height(6.dp)) + RawBlock(PackageStrings.COMMAND_TITLE, line.command) + } + } +} + +@Composable +private fun RawBlock(title: String, body: String) { + val palette = PiTheme.palette + Column(Modifier.fillMaxWidth()) { + Text(title, style = MaterialTheme.typography.labelSmall, color = palette.muted) + Spacer(Modifier.height(2.dp)) + Surface(color = palette.pageBg, shape = PiShapes.cardInner) { + Text( + text = body, + style = MaterialTheme.typography.bodySmall.copy(fontFamily = FontFamily.Monospace), + color = palette.toolOutput, + modifier = Modifier.fillMaxWidth().padding(8.dp), + ) + } + } +} diff --git a/app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt b/app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt new file mode 100644 index 0000000..f32b052 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/PiProjectTrustPrompt.kt @@ -0,0 +1,131 @@ +package app.pi.packages + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import app.pi.ui.theme.PiShapes +import app.pi.ui.theme.PiTheme + +/** + * pi's project-trust prompt, as a real prompt that writes a real record. + * + * ## This is not a design choice about dialog style + * + * pi's own prompt cannot appear in RPC mode: `project-trust.ts:86-88` returns false + * the moment `hasUI` is false, and `hasUI` is + * `isInitialRuntime && trustPromptMode === "interactive"` (`main.ts:753`), which RPC + * never satisfies. So the app's prompt is the *only* prompt — and if it did not + * exist, `.pi/extensions` would be skipped with no event, no stderr line and no + * error at all (`loader.ts:634-637`, `main.ts:775-782`). + * + * ## Fidelity + * + * - The heading is [ProjectTrust.promptText], pi's `formatProjectTrustPrompt` + * (`project-trust.ts:24-26`) character for character, so a user comparing this + * screen with pi's TUI sees the same question. + * - The choices are [ProjectTrust.options], pi's five + * (`trust-manager.ts:66-96`) — including `Trust parent folder`, which is the one + * a shorter menu silently drops, and the two session-only escapes. + * - The Chinese line under each label is explanation only. Selecting an option + * writes exactly what pi would write; see [TrustRepository.apply]. + * + * Dismissal maps to pi's own dismissal semantics — `resolveProjectTrusted` returns + * false when the select is dismissed (`project-trust.ts:90-95`) — so the app treats + * 取消 as "no decision this time", states that nothing was written, and leaves + * [ProjectTrust.Rationale.NoUiRefused] explaining the skip. + */ +@Composable +fun PiProjectTrustPrompt( + cwd: String, + options: List, + /** Why this prompt is being shown; from [PackageStrings.rationale]. */ + explanation: String, + /** Set while a trust decision is being written, to prevent double submission. */ + busy: Boolean = false, + onChoose: (ProjectTrust.Option) -> Unit, + onDismiss: () -> Unit, +) { + val palette = PiTheme.palette + AlertDialog( + onDismissRequest = { if (!busy) onDismiss() }, + title = { + Text( + text = "信任这个项目?", + style = MaterialTheme.typography.titleMedium, + color = palette.text, + ) + }, + text = { + Column(Modifier.verticalScroll(rememberScrollState()).heightIn(max = 420.dp)) { + // pi's exact prompt, kept as preformatted text: the second line is + // the path pi hashed, and a user may need to copy it. + Text( + text = ProjectTrust.promptText(cwd), + style = MaterialTheme.typography.bodySmall, + color = palette.muted, + ) + Spacer(Modifier.height(10.dp)) + Text( + text = explanation, + style = MaterialTheme.typography.bodySmall, + color = palette.warning, + ) + Spacer(Modifier.height(14.dp)) + options.forEach { option -> + Surface( + color = palette.selectedBg.copy(alpha = 0.45f), + shape = PiShapes.cardInner, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 3.dp) + .clickable(enabled = !busy) { onChoose(option) }, + ) { + Column(Modifier.padding(horizontal = 12.dp, vertical = 10.dp)) { + Text( + text = option.label, + style = MaterialTheme.typography.bodyLarge, + color = if (option.trusted) palette.success else palette.error, + ) + val subtitle = PackageStrings.trustSubtitle(option.label) + if (subtitle.isNotEmpty()) { + Spacer(Modifier.height(2.dp)) + Text( + text = subtitle, + style = MaterialTheme.typography.bodySmall, + color = palette.muted, + ) + } + } + } + } + Spacer(Modifier.height(6.dp)) + Text( + text = PackageStrings.TRUST_SESSION_ONLY_NOTE, + style = MaterialTheme.typography.labelSmall, + color = palette.dim, + ) + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = onDismiss, enabled = !busy) { Text(PackageStrings.CANCEL) } + }, + containerColor = palette.cardBg, + titleContentColor = palette.text, + textContentColor = palette.muted, + ) +} diff --git a/app/src/main/kotlin/app/pi/packages/ProjectTrust.kt b/app/src/main/kotlin/app/pi/packages/ProjectTrust.kt new file mode 100644 index 0000000..a702972 --- /dev/null +++ b/app/src/main/kotlin/app/pi/packages/ProjectTrust.kt @@ -0,0 +1,217 @@ +package app.pi.packages + +/** + * pi's project-trust decision, reproduced including the branch that decided it. + * + * ## The problem this file exists for + * + * In `--mode rpc`, pi's trust prompt has no UI, and with the default + * `defaultProjectTrust: "ask"` pi **silently skips** project-local `.pi/extensions`. + * Traced exactly: + * + * - trust decisions are resolved by `resolveProjectTrusted` + * (`core/project-trust.ts:46-95`); + * - the "no UI" branch is `if (!options.projectTrustContext.hasUI) return false` + * (`:86-88`); + * - `hasUI` for the CLI is `isInitialRuntime && trustPromptMode === "interactive"`, + * and RPC is not interactive (`main.ts:753`), so step 6 always fires; + * - the skip itself produces **no wire event, no stderr line and no error** + * (`docs/extension-compatibility.md:574-575`, from `loader.ts:634-637` and + * `main.ts:775-782`). + * + * So the app cannot learn from the engine that resources were skipped. It has to + * resolve the same decision itself, from the same inputs, and then *say* what it + * concluded. That is what this object does: [resolve] returns not just true/false + * but the [Rationale] that produced it, so "project extensions are not loaded" is + * a sentence the app can show instead of a silence. + * + * ## What the app can do about it + * + * Writing the same record pi would write (see [TrustRepository]) is the only + * durable fix, and it is exactly what pi's own prompt does: `Trust` stores + * `{ "": true }` (`trust-manager.ts:69`). The five choices are pi's + * own five (`:66-96`), with pi's own labels, because a shorter menu would silently + * drop `Trust parent folder` and the session-only escape hatches. + */ +object ProjectTrust { + + /** `docs/security.md:9-16`, `trust-manager.ts:30-38`. A bare `.pi` is not enough. */ + val TRUST_REQUIRING_CONFIG_ENTRIES = listOf( + "settings.json", + "extensions", + "skills", + "prompts", + "themes", + "SYSTEM.md", + "APPEND_SYSTEM.md", + ) + + const val CONFIG_DIR_NAME = ".pi" + + /** `APP_NAME` (`config.ts:502`). */ + const val APP_NAME = "pi" + + /** One of pi's five prompt choices (`trust-manager.ts:66-96`). */ + data class Option( + /** pi's exact label, shown untranslated so a user can match it to pi's docs. */ + val label: String, + val trusted: Boolean, + /** + * The `trust.json` writes this option performs. Empty means session-only: + * pi stores nothing (`trust-manager.ts:84`, `:93`). + */ + val updates: List>, + /** The key pi would call `savedPath`, when the option persists. */ + val savedPath: String?, + ) { + val sessionOnly: Boolean get() = updates.isEmpty() + } + + /** + * The five choices, exactly as `getProjectTrustOptions(cwd, {includeSessionOnly: + * true})` builds them (`trust-manager.ts:66-96`). + * + * The Chinese subtitles are the app's own addition and are kept **beside** pi's + * label, never instead of it: the label is what the user will see in pi's TUI + * and in pi's docs. + */ + fun options(canonicalCwd: String): List