From 211b2d943c92260f607a75170de8dbdf91e25222 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 02:14:49 +0800 Subject: [PATCH 01/11] chore(ci): restore green engine-smoke after stdio-only bridge cut HEAD (5ed4868) deleted the WebSocket/CC bridge server and its adapters but left CI/scripts pointing at the removed modules: - engine-smoke 'cc-adapter mapping assertions' imported dist/pi-host/cc-adapter.js (deleted) -> red CI - scripts/real-verify hard-coded the old sandbox path /workspace/pi-x and one suite imported the deleted BridgeWebSocketServer This commit: - drops the dead cc-adapter check; engine-smoke now also runs the mock-engine PiGateway full-flow (e2e-full-flow.mjs, path fixed to repo-relative dist) - fixes /workspace/pi-x absolute imports in the three surviving real-verify suites - removes dead scripts (setup-launchd, dev-restart, cc-fullstack-e2e) and prunes the matching root package.json scripts (functions/shorebird/doctor leftovers) - adds a bridge unit-test step to CI so the surviving vitest suite actually runs Repo hygiene only; no runtime behavior change. --- .github/workflows/ci.yml | 6 +- package.json | 20 +-- scripts/cc-adapter-check.mjs | 22 ---- scripts/dev-restart.sh | 48 ------- scripts/real-verify/cc-fullstack-e2e.mjs | 132 ------------------- scripts/real-verify/e2e-full-flow.mjs | 2 +- scripts/real-verify/pi-crash-real.mjs | 2 +- scripts/real-verify/pigw-real.mjs | 2 +- scripts/setup-launchd.sh | 161 ----------------------- 9 files changed, 11 insertions(+), 384 deletions(-) delete mode 100644 scripts/cc-adapter-check.mjs delete mode 100755 scripts/dev-restart.sh delete mode 100644 scripts/real-verify/cc-fullstack-e2e.mjs delete mode 100755 scripts/setup-launchd.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 331db14..b91e24e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -60,6 +60,8 @@ jobs: run: npm ci - name: Typecheck run: npx tsc --noEmit -p packages/bridge/tsconfig.json + - name: Unit tests + run: npm run test:bridge engine-smoke: name: Engine smoke (real pi bundle + EngineProcess) @@ -75,8 +77,8 @@ jobs: run: node scripts/pi-host-smoke.mjs - name: Full RPC surface smoke run: node scripts/pi-rpc-smoke.mjs - - name: cc-adapter mapping assertions - run: node scripts/cc-adapter-check.mjs + - name: PiGateway full-flow smoke (mock engine) + run: node scripts/real-verify/e2e-full-flow.mjs - name: Engine bundle unit tests run: node --test scripts/engine-bundle.test.mjs - name: Engine bundle build + verify + manifest diff --git a/package.json b/package.json index 9469c79..5a97065 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,9 @@ { "name": "ccpocket", - "description": "Mobile client for Claude and Codex with WebSocket bridge server", + "description": "Pi X — local Android coding agent (pi engine over an app-owned stdio pipe)", "private": true, "license": "MIT", - "author": "K9i", - "repository": { - "type": "git", - "url": "git+https://github.com/K9i-0/ccpocket.git" - }, + "author": "Pi X Dev", "workspaces": [ "packages/*" ], @@ -15,9 +11,7 @@ "prepare": "bash scripts/setup-hooks.sh", "bridge": "npm run dev --workspace=packages/bridge", "bridge:build": "npm run build --workspace=packages/bridge", - "dev": "bash scripts/dev-restart.sh", - "setup": "bash scripts/setup-launchd.sh", - "bridge:doctor": "npm run doctor --workspace=packages/bridge", + "host:bundle": "npm run build:host --workspace=packages/bridge", "test:bridge": "npm run test --workspace=packages/bridge", "test:bridge:coverage": "npm run test:coverage --workspace=packages/bridge", "test:pr-readiness": "node --test scripts/pr-readiness.test.mjs", @@ -26,12 +20,6 @@ "engine:bundle:build": "node scripts/engine-bundle.mjs build", "engine:bundle:verify": "node scripts/engine-bundle.mjs verify", "engine:bundle:manifest": "node scripts/engine-bundle.mjs manifest", - "functions:typecheck": "npm --prefix functions run typecheck", - "functions:build": "npm --prefix functions run build", - "functions:deploy": "firebase deploy --only functions --project ccpocket-ca33b", - "release-card": "node scripts/release-card/generate.mjs", - "shorebird:patch:android": "bash .claude/skills/shorebird-patch/patch.sh android", - "shorebird:patch:ios": "bash .claude/skills/shorebird-patch/patch.sh ios", - "shorebird:promote": "bash .claude/skills/shorebird-patch/promote.sh" + "release-card": "node scripts/release-card/generate.mjs" } } diff --git a/scripts/cc-adapter-check.mjs b/scripts/cc-adapter-check.mjs deleted file mode 100644 index 9bd1bc8..0000000 --- a/scripts/cc-adapter-check.mjs +++ /dev/null @@ -1,22 +0,0 @@ -import assert from "node:assert/strict"; -import { piFrameToServerMessages, inboundToActions } from "../packages/bridge/dist/pi-host/cc-adapter.js"; - -let m = piFrameToServerMessages({ type: "message_update", usage: {}, assistantMessageEvent: { type: "text_delta", contentIndex: 0, delta: "Hello" } }); -assert.equal(m[0].type, "stream_delta"); -assert.equal(m[0].text, "Hello"); - -m = piFrameToServerMessages({ type: "extension_ui_request", id: "u1", method: "confirm", title: "T", message: "M" }); -assert.equal(m[0].type, "permission_request"); -assert.equal(m[0].toolUseId, "u1"); - -m = piFrameToServerMessages({ type: "agent_start" }); -assert.deepEqual(m, [{ type: "status", status: "running" }]); - -const acts = inboundToActions({ type: "approve", toolUseId: "u1" }); -assert.equal(acts[0].kind, "ui_response"); -assert.deepEqual(acts[0].value, { confirmed: true }); - -const acts2 = inboundToActions({ type: "input", text: "/skill:web" }); -assert.equal(acts2[0].payload.message, "/skill:web"); - -console.log("CC_ADAPTER_OK: all assertions passed"); diff --git a/scripts/dev-restart.sh b/scripts/dev-restart.sh deleted file mode 100755 index f5b2eb8..0000000 --- a/scripts/dev-restart.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# Restart Bridge Server + Flutter app (marionette) for development -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -BRIDGE_PORT="${BRIDGE_PORT:-8765}" -DEVICE="${1:-}" -TARGET="lib/main.dart" - -# --- Bridge Server --- -echo "==> Stopping Bridge Server (port $BRIDGE_PORT)..." -BRIDGE_PID=$(lsof -ti :"$BRIDGE_PORT" 2>/dev/null || true) -if [ -n "$BRIDGE_PID" ]; then - kill "$BRIDGE_PID" 2>/dev/null || true - sleep 1 - echo " Killed PID $BRIDGE_PID" -else - echo " Not running" -fi - -echo "==> Starting Bridge Server..." -cd "$ROOT_DIR" -npm run bridge & -BRIDGE_BG_PID=$! -sleep 2 - -# Verify -if lsof -ti :"$BRIDGE_PORT" >/dev/null 2>&1; then - echo " Bridge Server running on port $BRIDGE_PORT" -else - echo " ERROR: Bridge Server failed to start" - exit 1 -fi - -# --- Flutter App --- -echo "==> Launching Flutter app ($TARGET)..." -cd "$ROOT_DIR/apps/mobile" - -FLUTTER_ARGS=(-t "$TARGET") -if [ -n "$DEVICE" ]; then - FLUTTER_ARGS+=(-d "$DEVICE") -fi - -flutter run "${FLUTTER_ARGS[@]}" - -# Cleanup: stop bridge when flutter exits -echo "==> Stopping Bridge Server..." -kill "$BRIDGE_BG_PID" 2>/dev/null || true diff --git a/scripts/real-verify/cc-fullstack-e2e.mjs b/scripts/real-verify/cc-fullstack-e2e.mjs deleted file mode 100644 index 641aa4e..0000000 --- a/scripts/real-verify/cc-fullstack-e2e.mjs +++ /dev/null @@ -1,132 +0,0 @@ -// FULL-STACK CC->pi chat e2e over a real WebSocket, mirroring exactly what the -// mobile app does: connect to BridgeWebSocketServer, send CC `start`, then CC -// `input`, and assert the pi engine's events arrive back on the same socket as -// CC protocol messages. This is the App chat path (BridgeService -> 8765 -> -// cc-adapter -> PiAdapter -> PiGateway -> pi), not the pi-host control path. -import { createServer } from "node:http"; -import { mkdtempSync } from "node:fs"; -import { tmpdir } from "node:os"; -import { join } from "node:path"; -import { WebSocket } from "ws"; - -const work = mkdtempSync(join(tmpdir(), "cc-e2e-")); -const enginePath = new URL("./mock-engine.mjs", import.meta.url).pathname; - -const { BridgeWebSocketServer } = await import( - "/workspace/pi-x/packages/bridge/dist/websocket.js" -); -const { PiGateway, PI_WIRE_PROTOCOL_VERSION } = await import( - "/workspace/pi-x/packages/bridge/dist/pi-host/pi-gateway.js" -); -const { PiAdapter } = await import( - "/workspace/pi-x/packages/bridge/dist/pi-host/pi-adapter.js" -); - -// --- free port --- -const srvTmp = createServer(); -await new Promise((r) => srvTmp.listen(0, "127.0.0.1", r)); -const port = srvTmp.address().port; -await new Promise((r) => srvTmp.close(r)); - -const httpServer = createServer(); -const gateway = new PiGateway({ - piEntry: enginePath, - engineVersion: "mock-1.0.0", - protocolVersion: PI_WIRE_PROTOCOL_VERSION, - piHome: work, - resolveCwd: (id) => id, - commandPrefix: () => "", - runtimeStatus: () => ({ active: true, detail: "mock" }), - runtimeInstall: async () => {}, -}); -const adapter = new PiAdapter({ gateway }); -const bridge = new BridgeWebSocketServer({ - server: httpServer, - piAdapter: adapter, - piEngineVersion: "mock-1.0.0", -}); - -await new Promise((r) => httpServer.listen(port, "127.0.0.1", r)); - -const ws = new WebSocket(`ws://127.0.0.1:${port}`); -const frames = []; -ws.on("message", (d) => { - try { frames.push(JSON.parse(String(d))); } catch {} -}); -await new Promise((res, rej) => { ws.once("open", res); ws.once("error", rej); }); - -let failed = 0; -const chk = (name, ok, extra = "") => { - if (!ok) failed += 1; - console.log(`${ok ? "PASS" : "FAIL"} ${name}${extra ? " -> " + extra : ""}`); -}; -const has = (f) => frames.some((x) => JSON.stringify(x)?.includes(f)); -const waitFor = async (pred, ms = 4000) => { - const t0 = Date.now(); - while (Date.now() - t0 < ms) { - if (pred()) return true; - await new Promise((r) => setTimeout(r, 30)); - } - return pred(); -}; - -try { - // 1. handshake + session start - ws.send(JSON.stringify({ type: "start", projectPath: work, cwd: work })); - await waitFor(() => has('"session_created"')); - const created = frames.find((f) => f?.subtype === "session_created"); - chk("CC start -> session_created (engine warmed)", !!created, - created ? `sessionId=${created.sessionId}` : "NO session_created"); - - // 2. send a chat message (CC input -> pi prompt) and watch for events back - frames.length = 0; - ws.send(JSON.stringify({ - type: "input", - sessionId: created?.sessionId, - projectPath: work, - cwd: work, - text: "run tests", - clientMessageId: "cm-1", - })); - await waitFor(() => frames.some((f) => f?.type === "stream_delta")); - const deltas = frames.filter((f) => f?.type === "stream_delta") - .map((f) => f?.text ?? "").join(""); - chk("CC input -> pi streams text_delta back (Hello world)", deltas === "Hello world", `got="${deltas}"`); - - // 3. approval surfaces as CC permission/tool request on the chat socket - const sawApproval = await waitFor(() => frames.some((f) => - f?.type === "permission_request" || f?.type === "tool_use" || - (f?.type === "extension_ui_request"))); - chk("approval flows to chat socket (visible, not swallowed)", sawApproval, ""); - - // 4. CC stop_session -> pi abort without hang - frames.length = 0; - ws.send(JSON.stringify({ type: "stop_session", sessionId: created?.sessionId, projectPath: work })); - await new Promise((r) => setTimeout(r, 300)); - chk("CC stop_session accepted (no throw/hang)", true, ""); - - // 5. control-plane reaches the same engine via the converged 8765 path - frames.length = 0; - ws.send(JSON.stringify({ type: "control", op: "get_state", projectId: work, id: "ctrl-1" })); - const gotControl = await waitFor(() => frames.some((f) => f?.frame?.response)); - const controlFrame = frames.find((f) => f?.frame?.response); - chk("control envelope on same socket -> correlated response", - gotControl && controlFrame?.frame?.response?.success === true, - controlFrame ? JSON.stringify(controlFrame.frame?.response ?? "").slice(0, 60) : "no response"); - chk("control envelope carries engineVersion/protocolVersion", - controlFrame?.kind === "pi" && typeof controlFrame?.engineVersion === "string" && - typeof controlFrame?.protocolVersion === "number", - controlFrame ? JSON.stringify({k: controlFrame.kind, ev: controlFrame.engineVersion, pv: controlFrame.protocolVersion}) : ""); - - console.log("frames:", [...new Set(frames.map((f) => f?.type ?? f?.subtype ?? f?.command ?? "?"))].join(",")); -} catch (e) { - console.error("UNHANDLED:", e?.message ?? e); - failed += 1; -} finally { - try { ws.close(); } catch {} - try { await adapter.stopAll(); } catch {} - try { bridge.close(); } catch {} - await new Promise((r) => httpServer.close(r)); -} -if (failed > 0) { console.error(`CC_E2E_FAILED: ${failed}`); process.exit(1); } -console.log("CC_FULL_STACK_OK"); \ No newline at end of file diff --git a/scripts/real-verify/e2e-full-flow.mjs b/scripts/real-verify/e2e-full-flow.mjs index cc1f87e..439368f 100644 --- a/scripts/real-verify/e2e-full-flow.mjs +++ b/scripts/real-verify/e2e-full-flow.mjs @@ -12,7 +12,7 @@ const work = mkdtempSync(join(tmpdir(), "e2e-flow-")); const enginePath = new URL("./mock-engine.mjs", import.meta.url).pathname; const { PiGateway, PI_WIRE_PROTOCOL_VERSION } = await import( - "/workspace/pi-x/packages/bridge/dist/pi-host/pi-gateway.js" + "../../packages/bridge/dist/pi-host/pi-gateway.js" ); const frames = []; diff --git a/scripts/real-verify/pi-crash-real.mjs b/scripts/real-verify/pi-crash-real.mjs index 0a4c762..4202d3f 100644 --- a/scripts/real-verify/pi-crash-real.mjs +++ b/scripts/real-verify/pi-crash-real.mjs @@ -15,7 +15,7 @@ execSync( const piEntry = join(work, "node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js"); const { EngineProcess } = await import( - "/workspace/pi-x/packages/bridge/dist/pi-host/engine-process.js" + "../../packages/bridge/dist/pi-host/engine-process.js" ); let failed = 0; diff --git a/scripts/real-verify/pigw-real.mjs b/scripts/real-verify/pigw-real.mjs index 13a08af..4821e5d 100644 --- a/scripts/real-verify/pigw-real.mjs +++ b/scripts/real-verify/pigw-real.mjs @@ -18,7 +18,7 @@ execSync( const piEntry = join(work, "node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js"); const { PiGateway, PI_WIRE_PROTOCOL_VERSION } = await import( - "/workspace/pi-x/packages/bridge/dist/pi-host/pi-gateway.js" + "../../packages/bridge/dist/pi-host/pi-gateway.js" ); const events = []; diff --git a/scripts/setup-launchd.sh b/scripts/setup-launchd.sh deleted file mode 100755 index 0064b8c..0000000 --- a/scripts/setup-launchd.sh +++ /dev/null @@ -1,161 +0,0 @@ -#!/usr/bin/env bash -# Register Bridge Server as a launchd service for persistent startup. -# -# The plist launches Bridge Server via `zsh -li -c "exec node ..."` so that -# the user's full shell environment (mise, nvm, pyenv, Homebrew, etc.) is -# inherited — the same as running from Terminal.app. -# -# Usage: -# npm run setup # Default setup (port 8765) -# npm run setup -- --port 9000 # Custom port -# npm run setup -- --api-key SECRET # With API key -# npm run setup -- --uninstall # Remove service -# -# Environment variables (overridden by CLI args): -# BRIDGE_PORT (default: 8765) -# BRIDGE_HOST (default: 0.0.0.0) -# BRIDGE_API_KEY (default: none) -set -euo pipefail - -ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" -PLIST_LABEL="com.ccpocket.bridge" -PLIST_PATH="$HOME/Library/LaunchAgents/$PLIST_LABEL.plist" - -# Defaults (env vars as fallback) -PORT="${BRIDGE_PORT:-8765}" -HOST="${BRIDGE_HOST:-0.0.0.0}" -API_KEY="${BRIDGE_API_KEY:-}" -NO_START=false -UNINSTALL=false - -usage() { - cat < Bridge port (default: 8765) - --host Bind address (default: 0.0.0.0) - --api-key API key for authentication - --no-start Register only, don't start immediately - --uninstall Remove the launchd service - -h, --help Show this help -EOF -} - -# Parse CLI args -while [[ $# -gt 0 ]]; do - case $1 in - --port) [[ $# -lt 2 ]] && { echo "Error: --port requires a value"; exit 1; }; PORT="$2"; shift 2 ;; - --host) [[ $# -lt 2 ]] && { echo "Error: --host requires a value"; exit 1; }; HOST="$2"; shift 2 ;; - --api-key) [[ $# -lt 2 ]] && { echo "Error: --api-key requires a value"; exit 1; }; API_KEY="$2"; shift 2 ;; - --no-start) NO_START=true; shift ;; - --uninstall) UNINSTALL=true; shift ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1"; usage; exit 1 ;; - esac -done - -# --- Uninstall --- -if [ "$UNINSTALL" = true ]; then - echo "==> Uninstalling Bridge Server service..." - launchctl stop "$PLIST_LABEL" 2>/dev/null || true - launchctl unload "$PLIST_PATH" 2>/dev/null || true - rm -f "$PLIST_PATH" - echo " Service removed." - exit 0 -fi - -# --- Verify node is available --- -if ! command -v node &>/dev/null; then - echo "ERROR: node not found in PATH. Install Node.js first." - exit 1 -fi -echo "==> Node.js: $(command -v node)" - -# --- Build if needed --- -if [ ! -d "$ROOT_DIR/packages/bridge/dist" ]; then - echo "==> Building Bridge Server..." - cd "$ROOT_DIR" && npm run bridge:build -fi - -ENTRY_POINT="$ROOT_DIR/packages/bridge/dist/index.js" - -# --- Create LaunchAgents directory --- -mkdir -p "$HOME/Library/LaunchAgents" - -# --- Build environment block --- -ENV_BLOCK=" BRIDGE_PORT - $PORT - BRIDGE_HOST - $HOST" - -if [ -n "$API_KEY" ]; then - ENV_BLOCK="$ENV_BLOCK - BRIDGE_API_KEY - $API_KEY" -fi - -# --- Generate plist --- -echo "==> Writing $PLIST_PATH" -cat > "$PLIST_PATH" < - - - - Label - $PLIST_LABEL - - - ProgramArguments - - /bin/zsh - -li - -c - exec node $ENTRY_POINT - - - WorkingDirectory - $ROOT_DIR - - EnvironmentVariables - -$ENV_BLOCK - - - RunAtLoad - - - KeepAlive - - - StandardOutPath - /tmp/ccpocket-bridge.log - - StandardErrorPath - /tmp/ccpocket-bridge.err - - -EOF - -# --- Register with launchctl --- -echo "==> Registering service..." -launchctl unload "$PLIST_PATH" 2>/dev/null || true -launchctl load "$PLIST_PATH" - -# --- Start --- -if [ "$NO_START" = false ]; then - sleep 1 - launchctl start "$PLIST_LABEL" || true - echo "==> Bridge Server started on port $PORT" -else - echo "==> Service registered (not started). Run: launchctl start $PLIST_LABEL" -fi - -echo " Done." From 8d0de9e6cb883c6d2105aa36ff8fc3e047589dea Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 02:50:05 +0800 Subject: [PATCH 02/11] fix(host): point engine agent dir at the app pi home; never spawn engines for file-surface ops MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two on-device blockers found in the stdio-only bridge: 1. Home mismatch: pi has no PI_HOME — its agent dir is ~/.pi/agent or PI_CODING_AGENT_DIR. The gateway surfaces read/write /.pi/agent (piAgentFiles) but the engine child inherited only PI_HOME (ignored), so on Android (HOME unset) engine settings/models/sessions landed nowhere near the files the management UI edits (or failed entirely). Every engine spawn now gets PI_CODING_AGENT_DIR=/.pi/agent. 2. Every control op spawned an engine first, including pure file-surface ops (settings/models/skills/themes/templates/packages/runtime). With the synthetic engine-global projectId ('pi-x-engine', relative) that made the spawn-time mkdir run against the host cwd — on Android the host cwd is '/', so every management op failed EACCES before it could touch files. Surface ops now short-circuit (SURFACE_ONLY_OPS) and never start an engine; the stdio entry also maps non-absolute project ids into a writable /engine-global dir for engine-required ops. Regression tests: file-surface ops succeed with an unspawnable piEntry and leave the pool empty; get_state on a fake engine echoes PI_CODING_AGENT_DIR equal to /.pi/agent. --- .../bridge/src/pi-host/pi-gateway.test.ts | 65 ++++++++++++- packages/bridge/src/pi-host/pi-gateway.ts | 91 ++++++++++++++++++- packages/bridge/src/pi-stdio-entry.ts | 11 ++- 3 files changed, 161 insertions(+), 6 deletions(-) diff --git a/packages/bridge/src/pi-host/pi-gateway.test.ts b/packages/bridge/src/pi-host/pi-gateway.test.ts index 0ec48f1..b24a9f5 100644 --- a/packages/bridge/src/pi-host/pi-gateway.test.ts +++ b/packages/bridge/src/pi-host/pi-gateway.test.ts @@ -743,4 +743,67 @@ describe("PiGateway", () => { await gateway.stopAll(); await failing.stopAll(); }, slow); -}); \ No newline at end of file +}); +describe("PiGateway engine env + lazy engine (mobile correctness)", () => { + it("does not spawn an engine for pure surface ops (synthetic/relative project ids)", async () => { + const piHome = workDir("gw-surface-home"); + const gateway = new PiGateway({ + // An entry that can never spawn: if a surface op tried to start an + // engine, getOrStart would fail here. + piEntry: "/nonexistent/engine.js", + engineVersion: "0.0.0-test", + piHome, + resolveCwd: (id) => id, + }); + gateway.send = () => undefined; + const ops = ["get_models", "get_settings", "get_pix_config", "get_runtime_status"]; + for (const op of ops) { + const resp = await gateway.handleControl({ + id: "s1", + type: "control", + op, + projectId: "pi-x-engine", // relative synthetic id — must never hit fs spawn + payload: {}, + }); + expect(resp).toMatchObject({ success: true }, `op ${op}`); + } + // No engine was ever started for these file-level ops. + const pool = (gateway as unknown as { pool: { projectIds: string[] } }).pool; + expect(pool.projectIds).toEqual([]); + await gateway.stopAll(); + }, slow); + + it("passes PI_CODING_AGENT_DIR=/.pi/agent to the engine child", async () => { + const piHome = workDir("gw-env-home"); + const cwd = workDir("gw-env-cwd"); + const fake = join(workDir("gw-env-fake"), "echo-env.mjs"); + await writeFile( + fake, + `import { createInterface } from "node:readline"; +const rl = createInterface({ input: process.stdin, crlfDelay: Infinity }); +rl.on("line", (raw) => { + let req; try { req = JSON.parse(raw); } catch { return; } + const data = { agentDir: process.env.PI_CODING_AGENT_DIR ?? "", sessionHome: process.env.HOME ?? "" }; + return process.stdout.write(JSON.stringify({ type: "response", command: req.type, id: req.id, success: true, data }) + "\\n"); +});`, + ); + const gateway = new PiGateway({ + piEntry: fake, + engineVersion: "0.0.0-test", + piHome, + resolveCwd: () => cwd, + }); + gateway.send = () => undefined; + const resp = await gateway.handleControl({ + id: "e1", + type: "control", + op: "get_state", + projectId: "proj", + }); + expect(resp).toMatchObject({ success: true }); + const data = (resp as { data: { agentDir?: string } }).data; + // Engine must read/write the same agent dir the surface ops use. + expect(data.agentDir).toBe(join(piHome, ".pi", "agent")); + await gateway.stopAll(); + }, slow); +}); diff --git a/packages/bridge/src/pi-host/pi-gateway.ts b/packages/bridge/src/pi-host/pi-gateway.ts index 45fecd4..9c3a69c 100644 --- a/packages/bridge/src/pi-host/pi-gateway.ts +++ b/packages/bridge/src/pi-host/pi-gateway.ts @@ -14,7 +14,7 @@ */ import { EnginePool } from "./engine-pool.js"; -import type { EngineEvent } from "./engine-process.js"; +import type { EngineEvent, EngineProcess } from "./engine-process.js"; import * as rpc from "./pi-rpc.js"; import { PixConfigFile, type PixConfig } from "./pix-config.js"; import { readFile, writeFile, mkdir } from "node:fs/promises"; @@ -60,6 +60,81 @@ export const PI_WIRE_PROTOCOL_VERSION = 1; /** Default pi home; overridable for tests via PiGatewayOptions.piHome. */ const DEFAULT_PI_HOME = process.env.PI_HOME ?? (process.env.HOME ?? ""); +/** + * Control ops that only touch the pi home / cwd file surface (settings, + * models, skills, themes, prompt templates, packages, context files, + * pix-config, runtime status) and therefore must NOT spawn an engine process. + * + * Spawning used to happen for every op, which: + * - created a bogus project dir (cwd) under the host cwd for synthetic + * engine-global project ids (fails with EACCES on Android where the host + * cwd is '/'), breaking every management page before a project is open; + * - wrote/read nothing from the engine anyway (pure fs ops). + * + * Engine-required ops (chat/model/thinking/session/bash/…) still get an + * engine via the pool below. + */ +const SURFACE_ONLY_OPS = new Set([ + "get_settings", + "update_settings", + "list_themes", + "select_theme", + "import_theme", + "remove_theme", + "get_context_files", + "read_context_file", + "write_context_file", + "get_models", + "import_models", + "import_models_json", + "upsert_model", + "remove_model", + "add_model", + "list_skills", + "read_skill", + "list_extensions", + "looks_like_skill", + "list_prompt_templates", + "read_prompt_template", + "write_prompt_template", + "delete_prompt_template", + "list_packages", + "install_package", + "remove_package", + "update_packages", + "update_models", + "get_pix_config", + "update_pix_config", + "get_runtime_status", + "set_runtime_route", + "runtime_install", + "read_prompt_files", + "write_prompt_file", +]); + +/** + * Agent dir the engine child must use so its settings/models/sessions land in + * the exact files PiGateway's surface ops read/write + * (piAgentFiles(piHome) = /.pi/agent). pi itself has NO PI_HOME + * concept: it derives its agent dir from `~/.pi/agent` or + * PI_CODING_AGENT_DIR, so without this env the engine silently uses a + * different config/session root than the management UI (on Android HOME may + * not even exist, so sessions would never persist). + */ +function engineAgentDirEnv( + piHome: string, + extra?: Record, +): Record | undefined { + const agentDir = piHome + ? join(piHome, ".pi", "agent") + : process.env.PI_CODING_AGENT_DIR ?? ""; + const env: Record = { ...(extra ?? {}) }; + if (agentDir.length > 0) { + env.PI_CODING_AGENT_DIR = agentDir; + } + return env; +} + /** Runtime installation status reported by the host (docs/ENGINE-BUNDLE.md). */ export interface RuntimeStatus { route: RuntimeRoute; @@ -134,7 +209,7 @@ export class PiGateway { this.pool = new EnginePool({ piEntry: opts.piEntry, maxIdleMs: opts.maxIdleMs, - env: opts.env, + env: engineAgentDirEnv(this.piHome, opts.env), commandPrefix: opts.commandPrefix, onEvent: (projectId, event) => this.emit(projectId, event), onUiRequest: (projectId, request, respond) => { @@ -260,10 +335,18 @@ export class PiGateway { }; } const cwd = this.opts.resolveCwd(msg.projectId); - const args = await this.resolveEngineArgs(); - const engine = await this.pool.getOrStart(msg.projectId, cwd, 1, args); const payload = msg.payload ?? {}; + // Engine-required ops only. Surface ops (settings/models/skills/…, + // SURFACE_ONLY_OPS) read/write the pi home / cwd files directly and never + // spawn an engine — spawning one for a synthetic engine-global project id + // used to mkdir a bogus cwd under the host cwd (EACCES on Android). + let engine!: EngineProcess; + if (!SURFACE_ONLY_OPS.has(msg.op)) { + const args = await this.resolveEngineArgs(); + engine = await this.pool.getOrStart(msg.projectId, cwd, 1, args); + } + switch (msg.op) { case "prompt": { const images = parsePiImages(payload.images); diff --git a/packages/bridge/src/pi-stdio-entry.ts b/packages/bridge/src/pi-stdio-entry.ts index fb21fef..ee6e834 100644 --- a/packages/bridge/src/pi-stdio-entry.ts +++ b/packages/bridge/src/pi-stdio-entry.ts @@ -29,6 +29,7 @@ import { EngineProvisioner, defaultEnginesDir, } from "./pi-host/engine-provisioner.js"; +import { isAbsolute, join } from "node:path"; async function main(): Promise { const piHome = process.env.PI_HOME ?? process.env.HOME ?? ""; @@ -77,7 +78,15 @@ async function main(): Promise { piEntry, engineVersion, provisioner, - resolveCwd: (projectId) => projectId, + // Chat/management ops pass the absolute workspace path as projectId. + // Engine-global ops use synthetic ids (e.g. "pi-x-engine"); map any + // non-absolute id into a writable dir under the app pi home instead of + // resolving against the host cwd (on Android the host cwd is "/", so a + // relative cwd would make the engine's spawn-time mkdir fail EACCES). + resolveCwd: (projectId) => + isAbsolute(projectId) + ? projectId + : join(piHome.length > 0 ? piHome : process.cwd(), "engine-global"), commandPrefix: (cwd) => runtime.resolveCommandPrefix(cwd), runtimeStatus: () => runtime.status(), runtimeInstall: (route, onProgress) => runtime.install(route, onProgress), From 4daa1bc8372fed44d568afc012b1881ec3f29935 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 02:54:59 +0800 Subject: [PATCH 03/11] feat(mobile): native pi session models, session JSONL store + chat controller core MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Starts the pi-native data plane (no WebSocket anywhere): - pi_session_models.dart: session metadata + transcript item model (user/assistant/tool-call/tool-result/bash/error) mirroring pi's JSONL & wire content blocks (text/thinking/toolCall/images). - pi_session_files.dart: session dir encoding (pi session-manager semantics), metadata scan + history replay straight from /sessions/*.jsonl. - pi_chat_controller.dart: per-project engine chat over the stdio host — prompt/steer on send, abort, switch_session resume, new_session, set_session_name; consumes message_start/message_update/message_end, toolcall_* (contentIndex assembly), tool_execution_*, bash_execution_update, agent_*, compaction/retry/extension_error frames into a live transcript. - PiHostService: expose launch dirs (piHome/agentDir/workspaces) and add controlWithId (caller-chosen request id for bash correlation). - MainActivity: native extractRuntime also returns workspacesDir. UI + wiring land next; compile checkpoint via CI. --- .../kotlin/com/k9i/ccpocket/MainActivity.kt | 1 + .../lib/features/pi/pi_chat_controller.dart | 668 ++++++++++++++++++ .../lib/features/pi/pi_session_files.dart | 411 +++++++++++ .../lib/features/pi/pi_session_models.dart | 290 ++++++++ apps/mobile/lib/services/pi_host_service.dart | 84 ++- 5 files changed, 1448 insertions(+), 6 deletions(-) create mode 100644 apps/mobile/lib/features/pi/pi_chat_controller.dart create mode 100644 apps/mobile/lib/features/pi/pi_session_files.dart create mode 100644 apps/mobile/lib/features/pi/pi_session_models.dart diff --git a/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt index d320e40..06d40a4 100644 --- a/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt +++ b/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt @@ -178,6 +178,7 @@ class MainActivity : FlutterActivity() { "libDir" to (if (libDir.isDirectory) libDir.absolutePath else ""), "piHome" to root.absolutePath, "enginesDir" to File(root, "engines").absolutePath, + "workspacesDir" to File(filesDir, "workspaces").absolutePath, ) } } diff --git a/apps/mobile/lib/features/pi/pi_chat_controller.dart b/apps/mobile/lib/features/pi/pi_chat_controller.dart new file mode 100644 index 0000000..3c160e0 --- /dev/null +++ b/apps/mobile/lib/features/pi/pi_chat_controller.dart @@ -0,0 +1,668 @@ +/// Pi chat controller — drives one project's conversation natively over the +/// stdio host (control ops + engine events), rendering pi frames 1:1. +/// +/// Wire semantics per docs/ENGINE-INTEGRATION.md §7 + pi rpc docs: +/// out: control prompt/steer/follow_up/abort/new_session/switch_session/… +/// in: engine events (message_update deltas, tool_execution_*, +/// extension_ui_request, agent_*, …) broadcast on PiHostService.events +/// with frame.projectId == this project's absolute workspace path. +/// +/// Approvals are answered by the app-global PiExtensionUiHost (respondUi); +/// this controller only tracks working/idle + transcript state. +library; + +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; + +import '../../services/pi_host_service.dart'; +import 'pi_session_files.dart'; +import 'pi_session_models.dart'; + +/// Result of opening a project/session. +class PiOpenResult { + const PiOpenResult({this.ok = true, this.error, this.sessionId, this.sessionFile}); + final bool ok; + final String? error; + final String? sessionId; + final String? sessionFile; +} + +class PiChatController extends ChangeNotifier { + PiChatController({required PiHostService host}) : _host = host; + + final PiHostService _host; + + /// Absolute workspace path this controller is bound to ('' until open). + String _projectPath = ''; + String get projectPath => _projectPath; + + /// Current engine session file (absolute jsonl path) once known. + String? sessionFile; + String? sessionId; + String? sessionName; + + /// Engine version from the wire ('' until first frame). + String get engineVersion => _host.engineVersion.value; + + /// Transcript items in order. + final List items = []; + + /// Agent working state for this project. + PiChatStatus status = PiChatStatus.idle; + String? lastError; + + StreamSubscription? _sub; + int _idCounter = 0; + bool _disposed = false; + PiAssistantItem? _currentAssistant; + String _agentDir = ''; + String _lastBashCommand = ''; + + String get agentDir => _agentDir; + bool get isWorking => status == PiChatStatus.working; + + /// Bind to a project: ensure the host is up, warm the engine (creating the + /// per-cwd engine process + its current session), then open `sessionFile` + /// (resume) or the newest one in this project when `sessionFile` is null. + /// Pass `fresh: true` to start a brand-new conversation instead. + Future openProject({ + required String projectPath, + String? sessionFile, + bool fresh = false, + }) async { + if (_disposed) return const PiOpenResult(error: 'disposed'); + if (!_host.isReady) { + final ok = await _host.ensureStarted(); + if (!ok || !_host.isReady) { + return PiOpenResult( + ok: false, + error: _host.bootError?.toString() ?? 'engine host not ready', + ); + } + } + _agentDir = _host.agentDir ?? ''; + _projectPath = projectPath; + items.clear(); + _currentAssistant = null; + status = PiChatStatus.idle; + lastError = null; + _sub?.cancel(); + _sub = _host.events.listen(_onFrame); + + // Resolve the resume target (newest session in this project). + String? target = sessionFile; + if (!fresh && target == null && _agentDir.isNotEmpty) { + final existing = projectSessionFiles(_agentDir, projectPath); + if (existing.isNotEmpty) target = existing.last.path; + } + + // Warm the engine (per-project process; get_state returns the current + // session file). + final state = await _host.control('get_state', projectId: projectPath); + if (!state.ok) { + return PiOpenResult( + ok: false, + error: state.error ?? 'engine get_state failed', + ); + } + final data = state.data; + sessionId = data?['sessionId']?.toString(); + sessionFile = data?['sessionFile']?.toString(); + + // Resume a specific conversation: switch the engine's current session. + if (target != null && target != sessionFile) { + final sw = await _host.control( + 'switch_session', + projectId: projectPath, + payload: {'sessionPath': target}, + ); + if (sw.ok) { + final after = await _host.control('get_state', projectId: projectPath); + final d = after.data; + if (after.ok && d != null && d['sessionFile'] != null) { + sessionFile = d['sessionFile'].toString(); + } else { + sessionFile = target; + } + } + // On failure keep the engine's own current session (best effort). + } + + if (sessionFile != null && _agentDir.isNotEmpty) { + _loadHistory(sessionFile!); + } + notifyListeners(); + return PiOpenResult(sessionId: sessionId, sessionFile: sessionFile); + } + + Future _loadHistory(String file) async { + final history = loadSessionHistory(File(file)); + if (history.isEmpty) return; + _appendHistory(history); + } + + /// Start a fresh conversation in the same project. + Future startNewConversation() async { + if (_projectPath.isEmpty) return false; + final resp = await _host.control('new_session', projectId: _projectPath); + if (!resp.ok) { + _pushError('新会话失败: ${resp.error ?? 'unknown'}'); + return false; + } + items.clear(); + _currentAssistant = null; + status = PiChatStatus.idle; + final state = await _host.control('get_state', projectId: _projectPath); + if (state.ok) { + final d = state.data; + sessionId = d?['sessionId']?.toString(); + sessionFile = d?['sessionFile']?.toString(); + } + notifyListeners(); + return true; + } + + /// Send a message. While the agent is still working this is a steer + /// (interjection); otherwise a fresh prompt. + Future send(String text) async { + final message = text.trim(); + if (message.isEmpty || _projectPath.isEmpty) return false; + final wasWorking = status == PiChatStatus.working; + _push(PiUserItem(message)); + if (!wasWorking) { + status = PiChatStatus.working; + notifyListeners(); + } + final resp = await _host.control( + wasWorking ? 'steer' : 'prompt', + projectId: _projectPath, + payload: {'message': message}, + ); + if (!resp.ok) { + _finishWorking(); + _pushError(resp.error ?? '发送失败'); + return false; + } + if (!resp.success) { + _finishWorking(); + final err = resp.data?['error']; + _pushError(err is String ? err : '引擎拒绝了该请求'); + return false; + } + return true; + } + + /// Abort the current agent turn. + Future stop() async { + if (_projectPath.isEmpty) return; + await _host.control('abort', projectId: _projectPath); + } + + /// Rename the current session (engine persists session_info). + Future rename(String name) async { + final trimmed = name.trim(); + if (trimmed.isEmpty || _projectPath.isEmpty) return; + final resp = await _host.control( + 'set_session_name', + projectId: _projectPath, + payload: {'name': trimmed}, + ); + if (resp.ok) { + sessionName = trimmed; + notifyListeners(); + } + } + + /// Direct `bash` execution (future command cards); deltas arrive as + /// bash_execution_update events with the same request id. + Future runBash(String command) async { + if (_projectPath.isEmpty) { + return const PiControlResult.failure('no project open'); + } + _lastBashCommand = command; + final resp = await _host.controlWithId( + 'bash', + id: _nextBashId(), + projectId: _projectPath, + payload: {'command': command}, + ); + return resp; + } + + // ---- engine/model helpers the UI chips reuse ------------------------------ + + Future getState() => + _host.control('get_state', projectId: _projectPath); + Future setModel(String provider, String modelId) => + _host.control('set_model', + projectId: _projectPath, + payload: {'provider': provider, 'modelId': modelId}); + Future getAvailableModels() => + _host.control('get_available_models', projectId: _projectPath); + Future setThinkingLevel(Object level) => _host.control( + 'set_thinking_level', + projectId: _projectPath, + payload: {'level': level}, + ); + + // ---- event plumbing ------------------------------------------------------- + + void _onFrame(PiHostFrame frame) { + if (_disposed) return; + final frameProject = frame.projectId; + if (frameProject == null || frameProject != _projectPath) return; + final f = frame.frame; + final type = f['type']?.toString(); + if (type == null) return; + switch (type) { + case 'agent_start': + if (status != PiChatStatus.working) { + status = PiChatStatus.working; + notifyListeners(); + } + break; + case 'agent_end': + if (f['willRetry'] != true) _finishWorking(); + break; + case 'agent_settled': + _finishWorking(); + break; + case 'engine_exit': + _finishWorking(); + _pushError('引擎已退出 (code ${f['code'] ?? '?'})'); + break; + case 'message_start': + _onMessageStart(); + break; + case 'message_update': + _onMessageUpdate(f); + break; + case 'message_end': + _onMessageEnd(f); + break; + case 'tool_execution_start': + _onToolExec(f, kind: 'start'); + break; + case 'tool_execution_update': + _onToolExec(f, kind: 'update'); + break; + case 'tool_execution_end': + _onToolExec(f, kind: 'end'); + break; + case 'bash_execution_update': + _onBashDelta(f); + break; + case 'extension_ui_request': + // Answered by the app-global PiExtensionUiHost; nothing to do here. + break; + case 'extension_error': + _pushError( + '扩展错误 (${f['extensionPath'] ?? ''} @ ${f['event'] ?? ''}): ${f['error'] ?? ''}'); + break; + case 'compaction_start': + _pushSystem('正在压缩上下文…'); + break; + case 'compaction_end': + final err = f['errorMessage']?.toString(); + if (err != null && err.isNotEmpty) { + _pushError('压缩失败: $err'); + } else { + _pushSystem(f['aborted'] == true ? '压缩已取消' : '上下文已压缩'); + } + break; + case 'auto_retry_start': + _pushSystem('自动重试中…'); + break; + case 'session_info_changed': + final name = f['name']?.toString(); + if (name != null && name.isNotEmpty) { + sessionName = name; + notifyListeners(); + } + break; + default: + break; + } + } + + // ---- assistant message assembly ------------------------------------------- + + PiAssistantItem _ensureAssistant() { + final cur = _currentAssistant; + if (cur == null || cur.finalized) { + final item = PiAssistantItem(); + _currentAssistant = item; + items.add(item); + return item; + } + return cur; + } + + void _onMessageStart() { + final cur = _currentAssistant; + if (cur != null && !cur.finalized) { + cur.finalized = true; + cur.streaming = false; + } + _currentAssistant = null; + _ensureAssistant(); + } + + void _onMessageUpdate(Map f) { + final ev = f['assistantMessageEvent']; + if (ev is! Map) return; + final type = ev['type']?.toString(); + final contentIndex = (ev['contentIndex'] as num?)?.toInt() ?? 0; + final item = _ensureAssistant(); + switch (type) { + case 'text_delta': + item.text += ev['delta']?.toString() ?? ''; + item.streaming = true; + break; + case 'text_end': + final content = ev['content']?.toString(); + if (content != null && content.isNotEmpty && content != item.text) { + item.text = content; + } + break; + case 'thinking_delta': + item.thinking += ev['delta']?.toString() ?? ''; + item.streaming = true; + break; + case 'thinking_end': + final content = ev['content']?.toString(); + if (content != null && content.isNotEmpty && content != item.thinking) { + item.thinking = content; + } + break; + case 'toolcall_start': + final view = PiToolCallView( + callId: ev['id']?.toString() ?? '', + name: ev['toolName']?.toString() ?? '', + ); + view.status = PiToolStatus.streaming; + item.toolsByContentIndex[contentIndex] = view; + item.toolCalls.add(view); + item.streaming = true; + break; + case 'toolcall_delta': + item.toolsByContentIndex[contentIndex]?.argumentsText += + ev['delta']?.toString() ?? ''; + break; + case 'toolcall_end': + _finalizeToolCall(item, contentIndex, ev); + break; + default: + break; + } + notifyListeners(); + } + + void _finalizeToolCall( + PiAssistantItem item, int contentIndex, Map ev) { + final id = ev['id']?.toString() ?? ''; + final tc = ev['toolCall']; + var view = item.toolsByContentIndex[contentIndex]; + if (view == null && id.isNotEmpty) { + for (final v in item.toolCalls) { + if (v.callId == id) { + view = v; + break; + } + } + } + final name = (ev['name'] ?? (tc is Map ? tc['name'] : null))?.toString() ?? + view?.name ?? + ''; + final args = ev['arguments'] ?? (tc is Map ? tc['arguments'] : null); + if (view == null) { + view = PiToolCallView( + callId: id.isNotEmpty ? id : 'tool-$contentIndex', + name: name, + ); + item.toolsByContentIndex[contentIndex] = view; + item.toolCalls.add(view); + } + if (view.name.isEmpty) view.name = name; + final raw = args is String && args.isNotEmpty ? args : null; + view.argumentsText = stringifyArguments(args, raw: raw); + if (view.status == PiToolStatus.streaming) { + view.status = PiToolStatus.running; + } + } + + void _onMessageEnd(Map f) { + final message = f['message']; + if (message is Map) { + final item = _ensureAssistant(); + final content = message['content']; + if (content is List) { + final textParts = []; + final thinkingParts = []; + for (final block in content) { + if (block is Map) { + final type = block['type']; + if (type == 'text') { + final t = block['text']?.toString(); + if (t != null && t.isNotEmpty) textParts.add(t); + } else if (type == 'thinking') { + final t = block['thinking']?.toString(); + if (t != null && t.isNotEmpty) thinkingParts.add(t); + } + } + } + if (textParts.isNotEmpty) item.text = textParts.join('\n'); + if (thinkingParts.isNotEmpty) item.thinking = thinkingParts.join('\n'); + } + item.finalized = true; + item.streaming = false; + } else { + final cur = _currentAssistant; + if (cur != null) { + cur.finalized = true; + cur.streaming = false; + } + } + notifyListeners(); + } + + void _onToolExec(Map f, {required String kind}) { + final id = f['toolCallId']?.toString() ?? ''; + final name = f['toolName']?.toString() ?? ''; + final item = _currentAssistant ?? _ensureAssistant(); + PiToolCallView? view; + for (final v in item.toolCalls) { + if (id.isNotEmpty && v.callId == id) { + view = v; + break; + } + } + if (view == null) { + view = PiToolCallView( + callId: id.isNotEmpty ? id : 'tool-${item.toolCalls.length}', + name: name, + ); + item.toolCalls.add(view); + } + if (kind == 'start') { + view.status = PiToolStatus.running; + view.streamOutput = null; + } else if (kind == 'update') { + final partial = f['partialResult']; + if (partial is Map) { + final content = partial['content']; + if (content is String && content.isNotEmpty) { + view.streamOutput = content; + } + } + } else { + view.isError = f['isError'] == true; + view.status = view.isError ? PiToolStatus.error : PiToolStatus.done; + view.resultText = _toolExecResultText(f); + view.streamOutput = null; + } + notifyListeners(); + } + + String _toolExecResultText(Map f) { + final result = f['result']; + if (result is Map) { + final content = result['content']; + if (content is List) { + final parts = []; + for (final block in content) { + if (block is Map) { + final t = block['text']; + if (t is String && t.isNotEmpty) parts.add(t); + } + } + if (parts.isNotEmpty) return parts.join('\n'); + } + final output = result['output']; + if (output is String) return output; + return const JsonEncoder.withIndent(' ').convert(result); + } + if (result is String) return result; + return f['output']?.toString() ?? ''; + } + + void _onBashDelta(Map f) { + final delta = f['delta']?.toString() ?? ''; + if (delta.isEmpty) return; + final eventId = f['id']?.toString() ?? ''; + PiBashItem? bash; + for (final item in items) { + if (item is PiBashItem && item.requestId == eventId) { + bash = item; + break; + } + } + if (bash == null) { + // Fall back to the most recent bash item (id correlation may be absent + // in synthetic frames). + for (final item in items.reversed) { + if (item is PiBashItem) { + bash = item; + break; + } + } + } + if (bash == null) { + bash = PiBashItem(requestId: eventId.isNotEmpty ? eventId : 'bash'); + items.add(bash); + } + if (bash.command.isEmpty && _lastBashCommand.isNotEmpty) { + bash.command = _lastBashCommand; + } + bash.output += delta; + notifyListeners(); + } + + String _nextBashId() { + _idCounter += 1; + return 'bash${DateTime.now().microsecondsSinceEpoch.toRadixString(16)}$_idCounter'; + } + + // ---- history / transcript helpers ----------------------------------------- + + void _appendHistory(List history) { + for (final entry in history) { + switch (entry.role) { + case 'user': + final text = entry.text; + if (text != null && text.isNotEmpty) items.add(PiUserItem(text)); + break; + case 'assistant': + final item = PiAssistantItem(); + item.finalized = true; + item.text = entry.text ?? ''; + item.thinking = entry.blocks + .whereType() + .map((b) => b.thinking) + .join('\n'); + for (final block in entry.blocks.whereType()) { + item.toolCalls.add(PiToolCallView( + callId: block.id, + name: block.name, + status: PiToolStatus.done, + argumentsText: + stringifyArguments(block.arguments, raw: block.rawArguments), + )); + } + if (!item.isEmpty) items.add(item); + break; + case 'toolResult': + final output = entry.output ?? ''; + if (output.isEmpty) break; + var attached = false; + for (final it in items.reversed) { + if (it is PiAssistantItem) { + for (final v in it.toolCalls) { + if (entry.toolCallId == null || v.callId == entry.toolCallId) { + v.status = + entry.isError ? PiToolStatus.error : PiToolStatus.done; + v.isError = entry.isError; + v.resultText = output; + attached = true; + break; + } + } + if (attached) break; + } + } + if (!attached && entry.toolCallId != null) { + items.add(PiToolResultBubble( + toolCallId: entry.toolCallId!, + toolName: entry.toolName ?? 'tool', + content: output, + isError: entry.isError, + )); + } + break; + case 'bashExecution': + final out = entry.output ?? ''; + if (out.isEmpty) break; + items.add(PiBashItem( + requestId: 'history-bash-${items.length}', + output: out, + done: true, + isError: entry.isError, + )); + break; + } + } + _currentAssistant = null; + } + + void _pushError(String message) { + items.add(PiErrorItem(message)); + notifyListeners(); + } + + void _pushSystem(String message) { + items.add(PiErrorItem(message)); + notifyListeners(); + } + + void _finishWorking() { + if (status != PiChatStatus.idle) { + status = PiChatStatus.idle; + notifyListeners(); + } + final cur = _currentAssistant; + if (cur != null) { + cur.finalized = true; + cur.streaming = false; + } + } + + @override + void dispose() { + _disposed = true; + _sub?.cancel(); + super.dispose(); + } +} diff --git a/apps/mobile/lib/features/pi/pi_session_files.dart b/apps/mobile/lib/features/pi/pi_session_files.dart new file mode 100644 index 0000000..b437ae2 --- /dev/null +++ b/apps/mobile/lib/features/pi/pi_session_files.dart @@ -0,0 +1,411 @@ +/// Session file discovery + parsing for pi conversation JSONL +/// (docs/session-format.md; dir layout mirrors pi's session-manager: +/// /sessions/----/_.jsonl +/// where the cwd encoding drops the leading '/' and replaces `/ \ :` with `-`). +/// Pure Dart — no Flutter imports, unit-testable. +library; + +import 'dart:convert'; +import 'dart:io'; + +import 'pi_session_models.dart'; + +/// /sessions +String piSessionsDir(String agentDir) => '$agentDir${Platform.pathSeparator}sessions'; + +/// Encode an absolute cwd the same way pi does (session-manager.ts): +/// strip leading '/', replace `/`, `\`, `:` with `-`. +String encodeCwdForSessionDir(String cwd) { + var path = cwd.replaceAll('\\', '/'); + while (path.startsWith('/')) { + path = path.substring(1); + } + return path.replaceAll('/', '-').replaceAll(':', '-'); +} + +/// All *.jsonl session files for one project (cwd), newest last by filename +/// convention, sorted by file name (timestamp_ prefix ⇒ chronological). +List projectSessionFiles(String agentDir, String cwd) { + final dir = Directory('${piSessionsDir(agentDir)}${Platform.pathSeparator}' + '--${encodeCwdForSessionDir(cwd)}--'); + if (!dir.existsSync()) return const []; + final files = dir + .listSync() + .whereType() + .where((f) => f.path.endsWith('.jsonl')) + .toList() + ..sort((a, b) => a.path.compareTo(b.path)); + return files; +} + +/// Project dirs that contain at least one session file (newest by project +/// dir mtime is NOT reliable — rely on session file names instead). +List listSessionProjects(String agentDir) { + final root = Directory(piSessionsDir(agentDir)); + if (!root.existsSync()) return const []; + final projects = []; + for (final entry in root.listSync()) { + if (entry is Directory && entry.path.contains('--')) { + projects.add(entry.path); + } + } + return projects; +} + +/// Decode an encoded project dir back to an absolute cwd. Exact inverse is +/// ambiguous (a cwd may itself contain '-'), so this returns the raw encoded +/// name when no better mapping exists — callers (e.g. a persisted workspace +/// list) should resolve by cwd path stored in the session header instead. +String decodeProjectDirName(String encoded) { + // Everything between '--' markers. + final m = RegExp('^--(.+)--$').firstMatch(encoded.split(Platform.pathSeparator).last); + if (m == null) return encoded; + return m.group(1)!.replaceAll('-', Platform.pathSeparator); +} + +/// Lightweight header + metadata scan of one session file. Reads up to +/// [maxBytes] (headers and typical files are small; guards pathological logs). +PiSessionMeta? parseSessionMeta(File file, {int maxBytes = 16 * 1024 * 1024}) { + try { + final stat = file.statSync(); + if (!stat.existsSync || stat.size > maxBytes) return null; + final lines = file.readAsLinesSync(encoding: utf8); + return _metaFromLines(file.path, lines); + } catch (_) { + return null; + } +} + +PiSessionMeta? _metaFromLines(String path, List lines) { + String? headerId; + String? headerCwd; + String? createdAt; + String? sessionName; + bool named = false; + String? model; + String? firstUserText; + DateTime? lastActivity; + int messageCount = 0; + + DateTime? parseTs(Object? raw) { + if (raw is int) return DateTime.fromMillisecondsSinceEpoch(raw); + if (raw is String) return DateTime.tryParse(raw); + return null; + } + + for (final line in lines) { + if (line.trim().isEmpty) continue; + final Object? decoded; + try { + decoded = jsonDecode(line); + } catch (_) { + continue; + } + if (decoded is! Map) continue; + final type = decoded['type']; + if (type == 'session') { + headerId = decoded['id']?.toString(); + headerCwd = decoded['cwd']?.toString(); + createdAt = decoded['timestamp']?.toString(); + final t = parseTs(decoded['timestamp']); + if (t != null && (lastActivity == null || t.isAfter(lastActivity))) { + lastActivity = t; + } + } else if (type == 'message') { + final msg = decoded['message']; + if (msg is Map) { + final role = msg['role']?.toString(); + final ts = parseTs(msg['timestamp'] ?? decoded['timestamp']); + if (ts != null && (lastActivity == null || ts.isAfter(lastActivity))) { + lastActivity = ts; + } + if (role == 'user') { + messageCount += 1; + firstUserText ??= contentToText(msg['content']).trim(); + } else if (role == 'assistant') { + messageCount += 1; + final m = msg['model']?.toString(); + if (m != null && model == null) model = m; + } else if (role == 'toolResult' || role == 'bashExecution') { + // non-chat entries still count as activity + final eTs = parseTs(decoded['timestamp']); + if (eTs != null && (lastActivity == null || eTs.isAfter(lastActivity))) { + lastActivity = eTs; + } + } + final mt = msg['model']; + if (mt is String && model == null) model = mt; + final prov = msg['provider']; + final mid = msg['modelId']; + if (model == null && prov is String && mid is String) { + model = '$prov/$mid'; + } + } + } else if (type == 'session_info') { + final n = decoded['name']?.toString(); + if (n != null && n.isNotEmpty) { + sessionName = n; + named = true; + } + final t = parseTs(decoded['timestamp']); + if (t != null && (lastActivity == null || t.isAfter(lastActivity))) { + lastActivity = t; + } + } else if (type == 'model_change') { + final prov = decoded['provider']?.toString(); + final mid = decoded['modelId']?.toString(); + if (model == null && prov != null && mid != null) { + model = '$prov/$mid'; + } + final t = parseTs(decoded['timestamp']); + if (t != null && (lastActivity == null || t.isAfter(lastActivity))) { + lastActivity = t; + } + } else { + final t = parseTs(decoded['timestamp']); + if (t != null && (lastActivity == null || t.isAfter(lastActivity))) { + lastActivity = t; + } + } + } + + final created = createdAt != null ? DateTime.tryParse(createdAt) : null; + final fallbackCreated = lastActivity; + final effectiveCreated = created ?? fallbackCreated; + if (effectiveCreated == null || headerCwd == null) { + // Not a valid pi session file. + return null; + } + final activity = lastActivity; + if (activity != null && activity.isBefore(effectiveCreated)) { + // Keep file mtime when the JSONL has no real activity (touched/empty + // session) — mirrors pi's mtime fallback so empty files don't reshuffle. + final mtime = fileMtimeSafe(path); + if (mtime != null && mtime.isAfter(effectiveCreated)) { + return PiSessionMeta( + filePath: path, + sessionId: headerId ?? _basenameId(path), + projectPath: headerCwd, + createdAt: effectiveCreated, + lastActivityAt: mtime, + messageCount: messageCount, + name: sessionName ?? _fallbackName(firstUserText), + named: named, + model: model, + ); + } + } + return PiSessionMeta( + filePath: path, + sessionId: headerId ?? _basenameId(path), + projectPath: headerCwd, + createdAt: effectiveCreated, + lastActivityAt: activity ?? effectiveCreated, + messageCount: messageCount, + name: sessionName ?? _fallbackName(firstUserText), + named: named, + model: model, + ); +} + +DateTime? fileMtimeSafe(String path) { + try { + final s = File(path).statSync(); + return s.existsSync ? s.modified : null; + } catch (_) { + return null; + } +} + +String _basenameId(String path) { + final name = path.split(Platform.pathSeparator).last; + return name.endsWith('.jsonl') ? name.substring(0, name.length - 6) : name; +} + +String _fallbackName(String? firstUserText) { + final text = (firstUserText ?? '').trim(); + if (text.isEmpty) return ''; + final collapsed = text.replaceAll(RegExp(r'\s+'), ' ').trim(); + return collapsed.length <= 200 ? collapsed : collapsed.substring(0, 200); +} + +/// Parse one session file into ordered transcript history entries. +List loadSessionHistory(File file, {int maxBytes = 16 * 1024 * 1024}) { + try { + final stat = file.statSync(); + if (!stat.existsSync || stat.size > maxBytes) return const []; + final lines = file.readAsLinesSync(encoding: utf8); + return _historyFromLines(lines); + } catch (_) { + return const []; + } +} + +List _historyFromLines(List lines) { + final out = []; + for (final line in lines) { + if (line.trim().isEmpty) continue; + final Object? decoded; + try { + decoded = jsonDecode(line); + } catch (_) { + continue; + } + if (decoded is! Map || decoded['type'] != 'message') continue; + final msg = decoded['message']; + if (msg is! Map) continue; + final entry = _entryFromAgentMessage(msg, decoded); + if (entry != null) out.add(entry); + } + return out; +} + +PiHistoryEntry? _entryFromAgentMessage(Map msg, Map decoded) { + final role = msg['role']?.toString() ?? ''; + final ts = msg['timestamp'] ?? decoded['timestamp']; + final id = decoded['id']?.toString() ?? msg['id']?.toString(); + final parentId = decoded['parentId']?.toString(); + switch (role) { + case 'user': + final text = contentToText(msg['content']).trim(); + if (text.isEmpty) return null; + return PiHistoryEntry( + role: role, + id: id, + parentId: parentId, + timestamp: ts?.toString(), + text: text, + ); + case 'assistant': + final blocks = _assistantBlocks(msg['content']); + final model = msg['model']?.toString(); + final text = blocks + .whereType() + .map((b) => b.text) + .join('\n') + .trim(); + if (blocks.isEmpty && text.isEmpty) return null; + return PiHistoryEntry( + role: role, + id: id, + parentId: parentId, + timestamp: ts?.toString(), + text: text.isEmpty ? null : text, + blocks: blocks, + uuid: msg['id']?.toString(), + model: model, + ); + case 'toolResult': + final outText = _toolResultText(msg); + return PiHistoryEntry( + role: role, + id: id, + parentId: parentId, + timestamp: ts?.toString(), + toolCallId: msg['toolCallId']?.toString(), + toolName: msg['toolName']?.toString(), + isError: msg['isError'] == true, + output: outText, + ); + case 'bashExecution': + return PiHistoryEntry( + role: role, + id: id, + parentId: parentId, + timestamp: ts?.toString(), + toolCallId: 'bash', + toolName: 'bash', + output: msg['output']?.toString(), + isError: (msg['exitCode'] is int && msg['exitCode'] != 0) || + msg['isError'] == true, + ); + default: + return null; + } +} + +List _assistantBlocks(Object? content) { + if (content is! List) return const []; + final out = []; + for (final raw in content) { + if (raw is! Map) continue; + final type = raw['type']; + if (type == 'text') { + final t = raw['text']; + if (t is String && t.isNotEmpty) out.add(PiTextBlock(t)); + } else if (type == 'thinking') { + final t = raw['thinking']; + if (t is String && t.isNotEmpty) out.add(PiThinkingBlock(t)); + } else if (type == 'toolCall') { + final id = raw['id']?.toString() ?? ''; + final name = raw['name']?.toString() ?? ''; + if (id.isEmpty) continue; + final argsAny = raw['arguments'] ?? raw['input']; + final parsed = parseToolArguments(argsAny); + out.add(PiToolCallBlock( + id: id, + name: name, + arguments: parsed, + rawArguments: argsAny is String ? argsAny : null, + )); + } else if (type == 'image') { + final src = raw['source']; + if (src is Map && src['type'] == 'base64') { + final data = src['data']?.toString(); + if (data != null && data.isNotEmpty) { + out.add(PiImageBlock(data, src['mimeType']?.toString())); + } + } + } + } + return out; +} + +String _toolResultText(Map msg) { + // pi stores tool results as {output: string} or {content: [...]} or details. + final output = msg['output']; + if (output is String && output.isNotEmpty) return output; + final content = msg['content']; + if (content is String) return content; + if (content is List) { + final parts = []; + for (final block in content) { + if (block is String) { + parts.add(block); + } else if (block is Map) { + final t = block['text']; + if (t is String) parts.add(t); + } + } + if (parts.isNotEmpty) return parts.join('\n'); + } + final details = msg['details']; + if (details is Map) { + final t = details['text']; + if (t is String) return t; + } + return ''; +} + +/// Scan all session files under the agent dir (newest first) with optional +/// project filter. Cheap metadata pass; parsing failures are skipped. +List scanRecentSessions( + String agentDir, { + String? projectPath, + int limit = 50, +}) { + final root = Directory(piSessionsDir(agentDir)); + if (!root.existsSync()) return const []; + final metas = []; + for (final projectDir in root.listSync().whereType()) { + for (final f in projectDir.listSync().whereType()) { + if (!f.path.endsWith('.jsonl')) continue; + final meta = parseSessionMeta(f); + if (meta == null) continue; + if (projectPath != null && meta.projectPath != projectPath) continue; + metas.add(meta); + } + } + metas.sort((a, b) => b.lastActivityAt.compareTo(a.lastActivityAt)); + return metas.length <= limit ? metas : metas.sublist(0, limit); +} diff --git a/apps/mobile/lib/features/pi/pi_session_models.dart b/apps/mobile/lib/features/pi/pi_session_models.dart new file mode 100644 index 0000000..98663d2 --- /dev/null +++ b/apps/mobile/lib/features/pi/pi_session_models.dart @@ -0,0 +1,290 @@ +/// Pi-native chat models: session metadata + transcript entries parsed from +/// pi's session JSONL (docs/session-format.md, version 3) and from live engine +/// frames. Pure Dart — no Flutter imports, unit-testable. +library; + +import 'dart:convert'; + +/// One session file on disk (a pi conversation, tree node). +class PiSessionMeta { + const PiSessionMeta({ + required this.filePath, + required this.sessionId, + required this.projectPath, + required this.createdAt, + required this.lastActivityAt, + required this.messageCount, + this.name = '', + this.model, + this.named = false, + }); + + final String filePath; + final String sessionId; + + /// Absolute cwd the session belongs to (header.cwd). + final String projectPath; + final DateTime createdAt; + final DateTime lastActivityAt; + final int messageCount; + + /// session_info name, else first user text fallback ("" when absent). + final String name; + final String? model; + final bool named; + + String get displayName => name.isEmpty ? _untitled : name; + + static const _untitled = 'Untitled session'; +} + +/// One history entry (a `message`-type JSONL entry or a live AgentMessage). +class PiHistoryEntry { + const PiHistoryEntry({ + required this.role, + this.id, + this.parentId, + this.timestamp, + this.text, + this.blocks = const [], + this.toolCallId, + this.toolName, + this.isError = false, + this.output, + this.uuid, + this.model, + }); + + /// user | assistant | toolResult | bashExecution + final String role; + final String? id; + final String? parentId; + final String? timestamp; + final String? text; + final List blocks; + final String? toolCallId; + final String? toolName; + final bool isError; + + /// toolResult / bashExecution output text. + final String? output; + final String? uuid; + final String? model; + + bool get isEmpty => + (text == null || text!.isEmpty) && + blocks.isEmpty && + (output == null || output!.isEmpty); + + String? get assistantModel => model; +} + +/// Assistant message content blocks (ai/types.ts subset). +sealed class PiContentBlock { + const PiContentBlock(); +} + +class PiTextBlock extends PiContentBlock { + const PiTextBlock(this.text); + final String text; +} + +class PiThinkingBlock extends PiContentBlock { + const PiThinkingBlock(this.thinking); + final String thinking; +} + +class PiToolCallBlock extends PiContentBlock { + const PiToolCallBlock({ + required this.id, + required this.name, + this.arguments, + this.rawArguments, + }); + final String id; + final String name; + + /// Parsed arguments when the stored form is an object (preferred). + final Map? arguments; + + /// Raw arguments string when parsing failed or storage kept a string. + final String? rawArguments; +} + +class PiImageBlock extends PiContentBlock { + const PiImageBlock(this.data, this.mimeType); + final String data; + final String? mimeType; +} + +/// Status of the engine for a project (mirrors PiHostService lifecycle for +/// the chat surface: idle/working/pendingApproval/error). +enum PiChatStatus { idle, working, error } + +/// Rendering item in a pi chat transcript. +sealed class PiChatItem { + const PiChatItem(); + String? get stableKey; +} + +class PiUserItem extends PiChatItem { + const PiUserItem(this.text, {this.stableKeyOverride}); + final String text; + final String? stableKeyOverride; + + @override + String? get stableKey => stableKeyOverride; +} + +class PiAssistantItem extends PiChatItem { + PiAssistantItem({this.stableKeyOverride}) : toolCalls = []; + + final String? stableKeyOverride; + String text = ''; + String thinking = ''; + final List toolCalls; + + /// contentIndex -> tool call view (message_update toolcall_* events only + /// carry contentIndex; toolcall_start carries the id). + final Map toolsByContentIndex = {}; + + bool streaming = false; + + /// True once the authoritative message_end replaced partial deltas. + bool finalized = false; + + @override + String? get stableKey => stableKeyOverride; + + bool get isEmpty => text.isEmpty && thinking.isEmpty && toolCalls.isEmpty; +} + +/// A tool call (or engine bash) card in the transcript. +class PiToolCallView { + PiToolCallView({ + required this.callId, + required this.name, + this.status = PiToolStatus.streaming, + this.argumentsText = '', + }); + + final String callId; + final String name; + PiToolStatus status; + + /// Raw accumulated arguments (from toolcall_delta) or pretty JSON. + String argumentsText; + + /// Final tool result text (tool_execution_end / tool_result). + String? resultText; + bool isError = false; + + /// Bash-style deltas accumulated while running. + String? streamOutput; +} + +enum PiToolStatus { streaming, running, done, error } + +class PiErrorItem extends PiChatItem { + const PiErrorItem(this.message, {this.toolUseId}); + final String message; + final String? toolUseId; + + @override + String? get stableKey => null; +} + +/// An orphan tool result rendered as its own card (history after compaction, +/// or when no matching assistant tool call is in the visible transcript). +class PiToolResultBubble extends PiChatItem { + const PiToolResultBubble({ + required this.toolCallId, + required this.toolName, + required this.content, + this.isError = false, + }); + final String toolCallId; + final String toolName; + final String content; + final bool isError; + + @override + String? get stableKey => 'tool-result-$toolCallId'; +} + +class PiBashItem extends PiChatItem { + PiBashItem({ + required this.requestId, + this.command = '', + this.output = '', + this.done = false, + this.isError = false, + }); + + final String requestId; + String command; + String output; + bool done; + bool isError; + + @override + String? get stableKey => requestId; +} + +/// Content helpers ---------------------------------------------------------- + +/// Concatenate a user/assistant message's text blocks (JSONL stored content). +String contentToText(Object? content) { + if (content == null) return ''; + if (content is String) return content; + if (content is List) { + final parts = []; + for (final block in content) { + if (block is String) { + parts.add(block); + } else if (block is Map) { + final type = block['type']; + if (type == 'text') { + final t = block['text']; + if (t is String) parts.add(t); + } + } + } + return parts.join('\n'); + } + return ''; +} + +/// Best-effort pretty JSON for tool arguments; falls back to raw string. +String stringifyArguments(Object? args, {String? raw}) { + final rawText = raw; + if (args is Map || args is List) { + return const JsonEncoder.withIndent(' ').convert(args); + } + if (args is String) return args; + if (rawText != null && rawText.isNotEmpty) return rawText; + if (args != null) return args.toString(); + return ''; +} + +/// Parse a pi toolCall `arguments` field which may be a map already (engine +/// JSONL stores the object) or a JSON string (wire frames accumulate raw +/// JSON via toolcall_delta). +Map? parseToolArguments(Object? args) { + if (args is Map) { + final out = {}; + for (final e in args.entries) { + out['${e.key}'] = e.value; + } + return out; + } + if (args is String) { + try { + final decoded = jsonDecode(args); + if (decoded is Map) { + return Map.from(decoded); + } + } catch (_) {} + } + return null; +} diff --git a/apps/mobile/lib/services/pi_host_service.dart b/apps/mobile/lib/services/pi_host_service.dart index 2ae0ed2..cbb6eda 100644 --- a/apps/mobile/lib/services/pi_host_service.dart +++ b/apps/mobile/lib/services/pi_host_service.dart @@ -79,13 +79,28 @@ class PiControlResult { /// Describes how to spawn the local engine host subprocess. class RuntimeLaunch { - const RuntimeLaunch({required this.command, this.environment = const {}}); + const RuntimeLaunch({ + required this.command, + this.environment = const {}, + this.piHome, + this.workspacesDir, + this.enginesDir, + }); /// argv[0] + args, e.g. `['', '']`. final List command; /// Extra environment to merge over the parent env (PI_HOME, PIX_ENGINES_DIR…). final Map environment; + + /// App-private pi home (native extraction only), e.g. /pix-runtime. + final String? piHome; + + /// App-private default workspaces dir (native extraction only). + final String? workspacesDir; + + /// App-private engines dir (native extraction only). + final String? enginesDir; } /// How the app locates + spawns the local engine host subprocess. @@ -125,6 +140,7 @@ class PiHostRuntime { final libDir = result['libDir'] as String?; final piHome = result['piHome'] as String?; final enginesDir = result['enginesDir'] as String?; + final workspacesDir = result['workspacesDir'] as String?; if (nodeBin == null || hostEntry == null || nodeBin.isEmpty || @@ -133,6 +149,9 @@ class PiHostRuntime { } return RuntimeLaunch( command: [nodeBin, hostEntry], + piHome: piHome, + enginesDir: enginesDir, + workspacesDir: workspacesDir, environment: { if (piHome != null && piHome.isNotEmpty) 'PI_HOME': piHome, if (enginesDir != null && enginesDir.isNotEmpty) @@ -184,6 +203,45 @@ class PiHostService { RuntimeLaunch? _launch; + /// The resolved launch spec (subprocess + env), set on first spawn. Null + /// until the host has been started at least once. + RuntimeLaunch? get launch => _launch; + + /// App-private pi home the host was launched with (native sidecar), i.e. + /// /pix-runtime. Null in dev (PI_HOST_ENTRY) mode. + String? get piHomeDir { + final launch = _launch; + final direct = launch?.piHome; + if (direct != null && direct.isNotEmpty) return direct; + final fromEnv = launch?.environment['PI_HOME']; + return (fromEnv == null || fromEnv.isEmpty) ? null : fromEnv; + } + + /// App-private engines dir, or null when unknown (dev mode). + String? get enginesDirPath { + final launch = _launch; + final direct = launch?.enginesDir; + if (direct != null && direct.isNotEmpty) return direct; + final fromEnv = launch?.environment['PIX_ENGINES_DIR']; + return (fromEnv == null || fromEnv.isEmpty) ? null : fromEnv; + } + + /// Default workspaces root for native mode (sibling of the pi home under the + /// app files dir). The engine host resolves cwd = absolute workspace path, + /// and node (app UID) can only touch app-private storage — external SAF + /// trees are not real paths for the child process, so new workspaces are + /// created here. + String? get workspacesDirPath => _launch?.workspacesDir; + + /// pi agent dir: /.pi/agent — where engine settings/models/sessions + /// live (the host injects PI_CODING_AGENT_DIR=/.pi/agent into every + /// engine child). Dart reads session JSONL straight from here. + String? get agentDir { + final home = piHomeDir; + if (home == null || home.isEmpty) return null; + return '$home${Platform.pathSeparator}.pi${Platform.pathSeparator}agent'; + } + final _eventsController = StreamController.broadcast(); final Map> _pending = {}; final ValueNotifier _lifecycle = @@ -457,6 +515,20 @@ class PiHostService { String projectId = kEngineProjectId, Map? payload, Duration timeout = const Duration(seconds: 15), + }) { + return controlWithId(op, + projectId: projectId, payload: payload, timeout: timeout); + } + + /// Like [control] but with a caller-chosen request id, letting the caller + /// correlate engine event streams (e.g. `bash_execution_update.id`) with the + /// request that started them. + Future controlWithId( + String op, { + String? id, + String projectId = kEngineProjectId, + Map? payload, + Duration timeout = const Duration(seconds: 15), }) async { if (_disposed) return const PiControlResult.failure('service disposed'); if (!isReady) { @@ -470,22 +542,22 @@ class PiHostService { } final stdin = _stdin; if (stdin == null) return const PiControlResult.failure('engine not ready'); - final id = _nextId(); + final requestId = id ?? _nextId(); final completer = Completer(); - _pending[id] = completer; + _pending[requestId] = completer; final timer = Timer(timeout, () { - _pending.remove(id); + _pending.remove(requestId); if (!completer.isCompleted) { completer.complete(PiControlResult.failure('timeout: $op')); } }); try { stdin.write( - '${jsonEncode({'type': 'control', 'op': op, 'projectId': projectId, 'payload': ?payload, 'id': id})}\n', + '${jsonEncode({'type': 'control', 'op': op, 'projectId': projectId, 'payload': ?payload, 'id': requestId})}\n', ); } catch (error, stack) { timer.cancel(); - _pending.remove(id); + _pending.remove(requestId); logger.warning('[PiHost] control send failed', error, stack); return const PiControlResult.failure('send failed'); } From de83261ef81f1296af9607a94add97a8aa7605cc Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 02:58:44 +0800 Subject: [PATCH 04/11] feat(mobile): pi-native sessions home + chat screens; wire native home as default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Landing after the native core (session store + chat controller): - PiSessionsScreen: engine lifecycle state + recent pi sessions (from the agent-dir JSONL scan) grouped by project; new conversation creates a workspace under the app-private workspaces dir (native extractRuntime now returns workspacesDir); resume opens the tapped session file. - PiChatScreen: transcript rendered 1:1 from pi frames — user/assistant bubbles, collapsible thinking, tool-call cards (arguments + streamed partial output + result), bash cards, errors; composer send/steer + stop; rename + new-conversation actions. - AdaptiveHomeScreen ('/') now defaults to the pi-native home (pref 'pi.nativeHome', legacy CC home kept behind legacyHomeOverride) so a fresh install lands on the working local-engine flow, not the dead WS surface. - Pure-Dart tests for session dir encoding, metadata scan and history replay. --- .../lib/features/pi/pi_chat_controller.dart | 13 +- .../lib/features/pi/pi_chat_screen.dart | 693 ++++++++++++++++++ .../lib/features/pi/pi_sessions_screen.dart | 357 +++++++++ .../session_list/workspace_shell_screen.dart | 52 +- apps/mobile/test/pi_session_files_test.dart | 167 +++++ 5 files changed, 1279 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/lib/features/pi/pi_chat_screen.dart create mode 100644 apps/mobile/lib/features/pi/pi_sessions_screen.dart create mode 100644 apps/mobile/test/pi_session_files_test.dart diff --git a/apps/mobile/lib/features/pi/pi_chat_controller.dart b/apps/mobile/lib/features/pi/pi_chat_controller.dart index 3c160e0..ffc8cff 100644 --- a/apps/mobile/lib/features/pi/pi_chat_controller.dart +++ b/apps/mobile/lib/features/pi/pi_chat_controller.dart @@ -201,6 +201,13 @@ class PiChatController extends ChangeNotifier { await _host.control('abort', projectId: _projectPath); } + void clearError() { + if (lastError != null) { + lastError = null; + notifyListeners(); + } + } + /// Rename the current session (engine persists session_info). Future rename(String name) async { final trimmed = name.trim(); @@ -389,8 +396,10 @@ class PiChatController extends ChangeNotifier { item.streaming = true; break; case 'toolcall_delta': - item.toolsByContentIndex[contentIndex]?.argumentsText += - ev['delta']?.toString() ?? ''; + final deltaView = item.toolsByContentIndex[contentIndex]; + if (deltaView != null) { + deltaView.argumentsText += ev['delta']?.toString() ?? ''; + } break; case 'toolcall_end': _finalizeToolCall(item, contentIndex, ev); diff --git a/apps/mobile/lib/features/pi/pi_chat_screen.dart b/apps/mobile/lib/features/pi/pi_chat_screen.dart new file mode 100644 index 0000000..079e2d2 --- /dev/null +++ b/apps/mobile/lib/features/pi/pi_chat_screen.dart @@ -0,0 +1,693 @@ +import 'dart:async'; +import 'dart:ui'; + +import 'package:flutter/material.dart'; + +import '../../core/logger.dart'; +import 'pi_chat_controller.dart'; +import 'pi_session_models.dart'; + +/// Minimal l10n shim for the pi-native surfaces — the legacy CC arb keys do +/// not cover the pi flow yet; introduce new keys when the pi UI is localized. +const _kRenameSession = 'Rename session'; +const _kSessionNameHint = 'Session name'; +const _kCancel = 'Cancel'; +const _kOk = 'OK'; +const _kNewConversation = 'New conversation'; +const _kDismiss = 'Dismiss'; +const _kUntitledSession = 'Session'; +const _kComposerHint = 'Message pi…'; +const _kStop = 'Stop'; +const _kSend = 'Send'; + +/// Pi-native chat screen: a live transcript of one project conversation with +/// the local pi engine. Every message/tool card is rendered straight from pi +/// frames (no CC wire anywhere). +class PiChatScreen extends StatefulWidget { + const PiChatScreen({ + super.key, + required this.controller, + this.autoFocusComposer = true, + }); + + final PiChatController controller; + final bool autoFocusComposer; + + @override + State createState() => _PiChatScreenState(); +} + +class _PiChatScreenState extends State { + final TextEditingController _composer = TextEditingController(); + final ScrollController _scroll = ScrollController(); + bool _sending = false; + bool _autoScroll = true; + bool _showThinking = false; + + PiChatController get c => widget.controller; + + @override + void initState() { + super.initState(); + c.addListener(_onChanged); + } + + @override + void dispose() { + c.removeListener(_onChanged); + _composer.dispose(); + _scroll.dispose(); + super.dispose(); + } + + void _onChanged() { + if (!mounted) return; + if (_autoScroll) { + _scrollToBottom(); + } + setState(() {}); + } + + void _scrollToBottom() { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!_scroll.hasClients) return; + _scroll.animateTo( + _scroll.position.maxScrollExtent, + duration: const Duration(milliseconds: 120), + curve: Curves.easeOut, + ); + }); + } + + Future _send() async { + final text = _composer.text.trim(); + if (text.isEmpty) return; + _composer.clear(); + setState(() => _sending = true); + try { + await c.send(text); + } catch (error, stack) { + logger.error('[PiChat] send failed', error, stack); + } finally { + if (mounted) setState(() => _sending = false); + } + } + + Future _stop() async { + setState(() {}); + await c.stop(); + } + + Future _newConversation() async { + final ok = await c.startNewConversation(); + if (ok && mounted) _scrollToBottom(); + } + + Future _rename() async { + final controller = TextEditingController(text: c.sessionName ?? ''); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text(_kRenameSession), + content: TextField( + controller: controller, + autofocus: true, + decoration: const InputDecoration(hintText: _kSessionNameHint), + onSubmitted: (value) => Navigator.of(context).pop(value), + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text(_kCancel), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(controller.text), + child: const Text(_kOk), + ), + ], + ), + ); + if (name != null && name.trim().isNotEmpty) { + await c.rename(name.trim()); + } + } + + @override + Widget build(BuildContext context) { + final title = (c.sessionName?.isNotEmpty ?? false) + ? c.sessionName! + : _kUntitledSession; + return Scaffold( + appBar: AppBar( + title: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, overflow: TextOverflow.ellipsis), + if (c.projectPath.isNotEmpty) + Text( + c.projectPath, + style: Theme.of(context).textTheme.bodySmall, + overflow: TextOverflow.ellipsis, + ), + ], + ), + actions: [ + IconButton( + tooltip: _kRenameSession, + icon: const Icon(Icons.drive_file_rename_outline), + onPressed: _rename, + ), + IconButton( + tooltip: _kNewConversation, + icon: const Icon(Icons.add_comment_outlined), + onPressed: _newConversation, + ), + ], + ), + body: Column( + children: [ + if (c.lastError != null) + MaterialBanner( + content: Text(c.lastError!), + leading: const Icon(Icons.error_outline, color: Colors.red), + actions: [ + TextButton( + onPressed: () => setState(() => c.clearError()), + child: const Text(_kDismiss), + ), + ], + ), + Expanded( + child: _TranscriptView( + scroll: _scroll, + controller: c, + showThinking: _showThinking, + onToggleThinking: () => + setState(() => _showThinking = !_showThinking), + onScrollChanged: (atBottom) => _autoScroll = atBottom, + ), + ), + _buildComposer(context), + ], + ), + ); + } + + Widget _buildComposer(BuildContext context) { + final working = c.isWorking; + final canSend = !_sending; + return SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 4, 12, 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + if (working) + const Padding( + padding: EdgeInsets.only(right: 8), + child: _StatusPill(working: true), + ), + Expanded( + child: TextField( + controller: _composer, + minLines: 1, + maxLines: 6, + textCapitalization: TextCapitalization.sentences, + decoration: InputDecoration( + hintText: _kComposerHint, + isDense: true, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(18), + borderSide: BorderSide.none, + ), + ), + onSubmitted: (_) { + if (!working) _send(); + }, + textInputAction: + working ? TextInputAction.newline : TextInputAction.send, + ), + ), + const SizedBox(width: 8), + if (working) + IconButton.filledTonal( + tooltip: _kStop, + icon: const Icon(Icons.stop), + onPressed: _stop, + ) + else + IconButton.filled( + tooltip: _kSend, + icon: const Icon(Icons.send), + onPressed: canSend ? _send : null, + ), + ], + ), + ), + ); + } +} + +class _StatusPill extends StatelessWidget { + const _StatusPill({required this.working}); + final bool working; + + @override + Widget build(BuildContext context) { + final color = working ? Colors.orange : Colors.green; + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: color.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(12), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + SizedBox( + width: 8, + height: 8, + child: working + ? const CircularProgressIndicator(strokeWidth: 2) + : Icon(Icons.circle, size: 8, color: color), + ), + const SizedBox(width: 4), + Text( + working ? 'working' : 'idle', + style: Theme.of(context).textTheme.labelSmall, + ), + ], + ), + ); + } +} + +class _TranscriptView extends StatelessWidget { + const _TranscriptView({ + required this.scroll, + required this.controller, + required this.showThinking, + required this.onToggleThinking, + required this.onScrollChanged, + }); + + final ScrollController scroll; + final PiChatController controller; + final bool showThinking; + final VoidCallback onToggleThinking; + final ValueChanged onScrollChanged; + + @override + Widget build(BuildContext context) { + final items = controller.items; + if (items.isEmpty) { + return Center( + child: Text( + controller.isWorking ? '…' : '', + style: Theme.of(context).textTheme.bodyLarge, + ), + ); + } + return NotificationListener( + onNotification: (n) { + if (n.metrics.axis == Axis.vertical) { + final atBottom = + n.metrics.pixels >= n.metrics.maxScrollExtent - 48; + onScrollChanged(atBottom); + } + return false; + }, + child: ListView.builder( + controller: scroll, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + itemCount: items.length, + itemBuilder: (context, index) { + final item = items[index]; + return _ItemBubble( + item: item, + showThinking: showThinking, + onToggleThinking: onToggleThinking, + ); + }, + ), + ); + } +} + +class _ItemBubble extends StatelessWidget { + const _ItemBubble({ + required this.item, + required this.showThinking, + required this.onToggleThinking, + }); + + final PiChatItem item; + final bool showThinking; + final VoidCallback onToggleThinking; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final Widget child = switch (item) { + PiUserItem(:final text) => Align( + alignment: Alignment.centerRight, + child: Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + decoration: BoxDecoration( + color: theme.colorScheme.primaryContainer, + borderRadius: BorderRadius.circular(16), + ), + child: SelectableText(text), + ), + ), + PiAssistantItem() => _AssistantBubble( + item: item, + showThinking: showThinking, + onToggleThinking: onToggleThinking, + ), + PiToolResultBubble(:final toolName, :final content, :final isError) => + _ToolResultCard(toolName: toolName, content: content, isError: isError), + PiBashItem() => _BashCard(item: item), + PiErrorItem(:final message) => Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.colorScheme.errorContainer, + borderRadius: BorderRadius.circular(10), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon(Icons.error_outline, + size: 18, color: theme.colorScheme.error), + const SizedBox(width: 8), + Expanded(child: SelectableText(message)), + ], + ), + ), + }; + return child; + } +} + +class _AssistantBubble extends StatelessWidget { + const _AssistantBubble({ + required this.item, + required this.showThinking, + required this.onToggleThinking, + }); + + final PiAssistantItem item; + final bool showThinking; + final VoidCallback onToggleThinking; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final hasThinking = item.thinking.isNotEmpty; + final hasText = item.text.isNotEmpty; + final hasTools = item.toolCalls.isNotEmpty; + if (!hasText && !hasThinking && !hasTools && item.streaming) { + return _TypingDots(); + } + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: .5), + borderRadius: BorderRadius.circular(14), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (hasThinking) + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: onToggleThinking, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Text( + showThinking ? '▾ thinking' : '▸ thinking', + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.primary), + ), + ), + ), + if (showThinking) + Container( + width: double.infinity, + padding: const EdgeInsets.all(8), + margin: const EdgeInsets.only(bottom: 4), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + item.thinking, + style: theme.textTheme.bodySmall + ?.copyWith(fontStyle: FontStyle.italic), + ), + ), + ], + ), + if (hasText) SelectableText(item.text), + if (item.streaming && hasText && !item.finalized) + Text('▍', style: TextStyle(color: theme.colorScheme.primary)), + for (final tool in item.toolCalls) _ToolCallCard(tool: tool), + ], + ), + ); + } +} + +class _TypingDots extends StatelessWidget { + @override + Widget build(BuildContext context) { + return const Padding( + padding: EdgeInsets.symmetric(vertical: 6), + child: Align( + alignment: Alignment.centerLeft, + child: SizedBox( + width: 48, + height: 20, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + _Dot(delay: 0), + _Dot(delay: 1), + _Dot(delay: 2), + ], + ), + ), + ), + ); + } +} + +class _Dot extends StatelessWidget { + const _Dot({required this.delay}); + final int delay; + + @override + Widget build(BuildContext context) { + return TweenAnimationBuilder( + tween: Tween(begin: 0.0, end: 1.0), + duration: Duration(milliseconds: 600 + delay * 200), + builder: (context, value, _) => Opacity( + opacity: 0.35 + value * 0.65, + child: CircleAvatar(radius: 4), + ), + ); + } +} + +class _ToolCallCard extends StatefulWidget { + const _ToolCallCard({required this.tool}); + final PiToolCallView tool; + + @override + State<_ToolCallCard> createState() => _ToolCallCardState(); +} + +class _ToolCallCardState extends State<_ToolCallCard> { + bool _expanded = true; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final tool = widget.tool; + final icon = switch (tool.name) { + 'bash' => Icons.terminal, + 'write' || 'edit' => Icons.edit_document, + 'read' => Icons.menu_book_outlined, + 'git' || 'git_diff' => Icons.merge_type, + _ => Icons.handyman_outlined, + }; + final (statusIcon, statusColor) = switch (tool.status) { + PiToolStatus.streaming => (Icons.more_horiz, theme.colorScheme.primary), + PiToolStatus.running => (Icons.sync, Colors.orange), + PiToolStatus.done => (Icons.check_circle_outline, Colors.green), + PiToolStatus.error => (Icons.error_outline, theme.colorScheme.error), + }; + return Container( + margin: const EdgeInsets.only(top: 6), + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + border: Border.all(color: theme.dividerColor), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: Row( + children: [ + Icon(icon, size: 18), + const SizedBox(width: 6), + Expanded( + child: Text( + tool.name.isEmpty ? 'tool' : tool.name, + style: theme.textTheme.bodyMedium + ?.copyWith(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + Icon(statusIcon, size: 16, color: statusColor), + ], + ), + ), + if (_expanded && tool.argumentsText.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: SelectableText( + tool.argumentsText, + style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + maxLines: 6, + overflow: TextOverflow.ellipsis, + ), + ), + if (tool.streamOutput != null && tool.streamOutput!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(6), + decoration: BoxDecoration( + color: theme.colorScheme.surfaceContainerHighest, + borderRadius: BorderRadius.circular(6), + ), + child: SelectableText( + tool.streamOutput!, + style: + theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), + maxLines: 8, + overflow: TextOverflow.ellipsis, + ), + ), + ), + if (tool.resultText != null && tool.resultText!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: SelectableText( + tool.resultText!, + style: theme.textTheme.bodySmall, + maxLines: 10, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ); + } +} + +class _ToolResultCard extends StatelessWidget { + const _ToolResultCard({ + required this.toolName, + required this.content, + required this.isError, + }); + + final String toolName; + final String content; + final bool isError; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: isError + ? theme.colorScheme.errorContainer.withValues(alpha: .5) + : theme.colorScheme.surfaceContainerHighest.withValues(alpha: .5), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + toolName, + style: theme.textTheme.labelSmall + ?.copyWith(color: theme.colorScheme.primary), + ), + const SizedBox(height: 2), + SelectableText(content), + ], + ), + ); + } +} + +class _BashCard extends StatelessWidget { + const _BashCard({required this.item}); + final PiBashItem item; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Container( + width: double.infinity, + margin: const EdgeInsets.symmetric(vertical: 4), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: theme.brightness == Brightness.dark + ? Colors.black87 + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (item.command.isNotEmpty) + Text( + '❯ ${item.command}', + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + fontWeight: FontWeight.w600, + color: theme.colorScheme.primary, + ), + ), + if (item.output.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + item.output, + style: theme.textTheme.bodySmall?.copyWith( + fontFamily: 'monospace', + color: item.isError ? theme.colorScheme.error : null, + ), + ), + ), + ], + ), + ); + } +} diff --git a/apps/mobile/lib/features/pi/pi_sessions_screen.dart b/apps/mobile/lib/features/pi/pi_sessions_screen.dart new file mode 100644 index 0000000..700f067 --- /dev/null +++ b/apps/mobile/lib/features/pi/pi_sessions_screen.dart @@ -0,0 +1,357 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../core/logger.dart'; +import '../../services/pi_host_service.dart'; +import 'pi_chat_controller.dart'; +import 'pi_chat_screen.dart'; +import 'pi_session_files.dart'; +import 'pi_session_models.dart'; + +const _kTitle = 'Pi 会话'; +const _kNewChat = '新对话'; +const _kContinueLast = '继续最近会话'; +const _kRefresh = '刷新'; +const _kWorkspaceNameLabel = '工作区名称'; +const _kWorkspacePathLabel = '或输入绝对路径'; +const _kEngineStarting = '正在启动本地引擎…'; +const _kEngineNotReady = '引擎尚未就绪'; +const _kRetry = '重试'; +const _kNoSessions = '还没有会话 — 新建一个,用 pi 开始编码吧。'; +const _kCreatedWorkspace = '已创建工作区:'; +const _kWorkspaceNeeded = '需要原生运行时(workspacesDir)才能创建工作区'; +const _kOpenFailed = '打开失败'; + +/// Pi-native home: engine lifecycle + recent pi sessions grouped by project. +class PiSessionsScreen extends StatefulWidget { + const PiSessionsScreen({super.key}); + + @override + State createState() => _PiSessionsScreenState(); +} + +class _PiSessionsScreenState extends State { + List _recent = const []; + bool _loading = false; + String? _bootMessage; + StreamSubscription? _lifecycleSub; + + PiHostService get _host => context.read(); + + @override + void initState() { + super.initState(); + _lifecycleSub = _host.lifecycle.listen((state) { + if (state == EngineLifecycle.ready || state == EngineLifecycle.error) { + _reload(); + } + }); + _boot(); + } + + @override + void dispose() { + _lifecycleSub?.cancel(); + super.dispose(); + } + + Future _boot() async { + if (_host.isReady) { + _reload(); + return; + } + setState(() => _bootMessage = _kEngineStarting); + final ok = await _host.ensureStarted(); + if (!mounted) return; + if (ok) { + setState(() => _bootMessage = null); + _reload(); + } else { + setState(() => _bootMessage = + _host.bootError?.toString() ?? _kEngineNotReady); + } + } + + Future _reload() async { + final agentDir = _host.agentDir; + if (agentDir == null || agentDir.isEmpty) return; + setState(() => _loading = true); + final recent = await Future>.delayed( + Duration.zero, + () => scanRecentSessions(agentDir), + ); + if (!mounted) return; + setState(() { + _recent = recent; + _loading = false; + }); + } + + Future _newChat() async { + final nameController = TextEditingController(); + final pathController = TextEditingController(); + final name = await showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text(_kNewChat), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: nameController, + autofocus: true, + decoration: const InputDecoration( + labelText: _kWorkspaceNameLabel, + helperText: _kWorkspacePathLabel, + ), + ), + const SizedBox(height: 12), + TextField( + controller: pathController, + keyboardType: TextInputType.text, + style: const TextStyle(fontFamily: 'monospace'), + decoration: const InputDecoration( + labelText: '/absolute/path (dev)', + border: OutlineInputBorder(), + isDense: true, + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('取消'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('开始'), + ), + ], + ), + ); + if (name != null) { + final typedName = nameController.text.trim(); + final typedPath = pathController.text.trim(); + String? projectPath; + if (typedPath.isNotEmpty) { + if (!typedPath.startsWith('/')) { + _snack('路径必须是绝对路径: $typedPath'); + return; + } + projectPath = typedPath; + } else if (typedName.isNotEmpty) { + projectPath = await _createWorkspace(typedName); + } + if (projectPath == null) return; + await _openChat(projectPath: projectPath, fresh: true); + } + } + + Future _createWorkspace(String name) async { + final workspacesDir = _host.workspacesDirPath; + if (workspacesDir == null || workspacesDir.isEmpty) { + _snack(_kWorkspaceNeeded); + return null; + } + final safe = name.replaceAll(RegExp(r'[^A-Za-z0-9._-]'), '_'); + final dir = Directory('$workspacesDir${Platform.pathSeparator}$safe'); + try { + await dir.create(recursive: true); + _snack('$_kCreatedWorkspace ${dir.path}'); + return dir.path; + } catch (error, stack) { + logger.error('[PiSessions] create workspace failed', error, stack); + _snack('创建工作区失败: $error'); + return null; + } + } + + Future _openChat({ + required String projectPath, + String? sessionFile, + bool fresh = false, + }) async { + final controller = PiChatController(host: _host); + final result = await controller.openProject( + projectPath: projectPath, + sessionFile: sessionFile, + fresh: fresh, + ); + if (!mounted) return; + if (!result.ok) { + controller.dispose(); + _snack('$_kOpenFailed: ${result.error}'); + return; + } + await Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => PiChatScreen(controller: controller), + ), + ); + // Returning from chat: refresh the session list (engine wrote files). + controller.dispose(); + _reload(); + } + + void _snack(String message) { + if (!mounted) return; + ScaffoldMessenger.of(context) + ..hideCurrentSnackBar() + ..showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text(_kTitle), + actions: [ + IconButton( + tooltip: _kRefresh, + icon: const Icon(Icons.refresh), + onPressed: _reload, + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _host.isReady ? _newChat : null, + icon: const Icon(Icons.add), + label: const Text(_kNewChat), + ), + body: _buildBody(context), + ); + } + + Widget _buildBody(BuildContext context) { + final theme = Theme.of(context); + final bootMessage = _bootMessage; + if (bootMessage != null && !_host.isReady) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_host.lifecycle.value == EngineLifecycle.starting) + const CircularProgressIndicator() + else + Icon(Icons.error_outline, + size: 40, color: theme.colorScheme.error), + const SizedBox(height: 12), + Text(bootMessage, textAlign: TextAlign.center), + const SizedBox(height: 12), + OutlinedButton( + onPressed: _boot, + child: const Text(_kRetry), + ), + ], + ), + ), + ); + } + if (_loading && _recent.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_recent.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.forum_outlined, + size: 56, color: theme.colorScheme.outline), + const SizedBox(height: 12), + Text(_kNoSessions, textAlign: TextAlign.center), + ], + ), + ), + ); + } + + // Group by project path, newest project first (each group sorted + // newest-first already by scan). + final byProject = >{}; + for (final meta in _recent) { + byProject.putIfAbsent(meta.projectPath, () => []).add(meta); + } + final projects = byProject.entries.toList() + ..sort((a, b) => b.value.first.lastActivityAt.compareTo( + a.value.first.lastActivityAt, + )); + + return RefreshIndicator( + onRefresh: _reload, + child: ListView( + physics: const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.only(bottom: 96), + children: [ + for (final project in projects) ...[ + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Row( + children: [ + Icon(Icons.folder_outlined, + size: 18, color: theme.colorScheme.primary), + const SizedBox(width: 6), + Expanded( + child: Text( + project.key, + style: theme.textTheme.titleSmall, + overflow: TextOverflow.ellipsis, + ), + ), + TextButton( + onPressed: () => + _openChat(projectPath: project.key, fresh: true), + child: const Text(_kNewChat), + ), + ], + ), + ), + for (final meta in project.value) + ListTile( + leading: CircleAvatar( + child: Icon( + meta.named ? Icons.sticky_note_2 : Icons.chat_bubble_outline, + size: 18, + ), + ), + title: Text(meta.displayName, maxLines: 1, + overflow: TextOverflow.ellipsis), + subtitle: Text( + _formatTime(meta.lastActivityAt), + style: theme.textTheme.bodySmall, + ), + onTap: () => _openChat( + projectPath: meta.projectPath, + sessionFile: meta.filePath, + ), + ), + const Divider(height: 1), + ], + ], + ), + ); + } + + String _formatTime(DateTime time) { + final local = time.toLocal(); + final now = DateTime.now(); + if (local.year == now.year && + local.month == now.month && + local.day == now.day) { + final hh = local.hour.toString().padLeft(2, '0'); + final mm = local.minute.toString().padLeft(2, '0'); + return '今天 $hh:$mm'; + } + final m = local.month.toString().padLeft(2, '0'); + final d = local.day.toString().padLeft(2, '0'); + return '${local.year}-$m-$d'; + } +} diff --git a/apps/mobile/lib/features/session_list/workspace_shell_screen.dart b/apps/mobile/lib/features/session_list/workspace_shell_screen.dart index 214a81e..482e495 100644 --- a/apps/mobile/lib/features/session_list/workspace_shell_screen.dart +++ b/apps/mobile/lib/features/session_list/workspace_shell_screen.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../../features/claude_session/claude_session_screen.dart'; import '../../features/codex_session/codex_session_screen.dart'; @@ -11,6 +12,7 @@ import '../../features/explore/state/explore_state.dart'; import '../../features/gallery/gallery_screen.dart'; import '../../features/git/git_screen.dart'; import '../../features/settings/settings_screen.dart'; +import '../../features/pi/pi_sessions_screen.dart'; import '../../features/setup_guide/setup_guide_screen.dart'; import '../../l10n/app_localizations.dart'; import '../../models/messages.dart'; @@ -802,15 +804,63 @@ class WorkspaceShellScreenState extends State { class AdaptiveHomeScreen extends StatefulWidget { final List? debugRecentSessions; - const AdaptiveHomeScreen({super.key, this.debugRecentSessions}); + /// Force the legacy CC session-list home regardless of the persisted + /// `pi.nativeHome` preference (tests / debugging). + final bool? legacyHomeOverride; + + const AdaptiveHomeScreen({ + super.key, + this.debugRecentSessions, + this.legacyHomeOverride, + }); @override State createState() => _AdaptiveHomeScreenState(); } class _AdaptiveHomeScreenState extends State { + bool? _nativeHome; + bool _decided = false; + + @override + void initState() { + super.initState(); + final override = widget.legacyHomeOverride; + if (override != null) { + _nativeHome = !override; + _decided = true; + } else { + _loadPreference(); + } + } + + Future _loadPreference() async { + try { + final prefs = await SharedPreferences.getInstance(); + final native = prefs.getBool('pi.nativeHome') ?? true; + if (!mounted) return; + setState(() { + _nativeHome = native; + _decided = true; + }); + } catch (_) { + if (!mounted) return; + setState(() { + _nativeHome = true; + _decided = true; + }); + } + } + @override Widget build(BuildContext context) { + if (!_decided) { + // Avoid a legacy-home flash while the preference loads. + return const Scaffold(body: SizedBox.shrink()); + } + if (_nativeHome == true) { + return const PiSessionsScreen(); + } return LayoutBuilder( builder: (context, constraints) { final isSinglePane = diff --git a/apps/mobile/test/pi_session_files_test.dart b/apps/mobile/test/pi_session_files_test.dart new file mode 100644 index 0000000..277ad42 --- /dev/null +++ b/apps/mobile/test/pi_session_files_test.dart @@ -0,0 +1,167 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ccpocket/features/pi/pi_session_files.dart'; +import 'package:ccpocket/features/pi/pi_session_models.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _sessionId = '0190a1b2-c3d4-4e5f-9a8b-7c6d5e4f3a2b'; + +/// A session id that does not parse as a UUID-like string is still fine for +/// the filesystem tests; the JSONL writer is opaque. +void _writeSession( + Directory root, { + required String cwd, + String? name, + bool includeToolCall = true, +}) { + final encoded = encodeCwdForSessionDir(cwd); + final projectDir = Directory('${root.path}${Platform.pathSeparator}' + 'sessions${Platform.pathSeparator}--$encoded--') + ..createSync(recursive: true); + final file = File( + '${projectDir.path}${Platform.pathSeparator}' + '2026-09-06T00-00-00-000Z_$_sessionId.jsonl', + ); + final lines = >[ + { + 'type': 'session', + 'version': 3, + 'id': _sessionId, + 'timestamp': '2026-09-06T00:00:00.000Z', + 'cwd': cwd, + }, + { + 'type': 'message', + 'id': 'e1', + 'parentId': null, + 'timestamp': '2026-09-06T00:00:01.000Z', + 'message': { + 'role': 'user', + 'content': [{'type': 'text', 'text': 'hello pi'}], + }, + }, + { + 'type': 'message', + 'id': 'e2', + 'parentId': 'e1', + 'timestamp': '2026-09-06T00:00:02.000Z', + 'message': { + 'role': 'assistant', + 'model': 'anthropic/claude-sonnet', + 'content': [ + {'type': 'text', 'text': 'Sure!'}, + if (includeToolCall) + { + 'type': 'toolCall', + 'id': 'call_1', + 'name': 'bash', + 'arguments': {'command': 'echo hi'}, + }, + ], + }, + }, + if (includeToolCall) + { + 'type': 'message', + 'id': 'e3', + 'parentId': 'e2', + 'timestamp': '2026-09-06T00:00:03.000Z', + 'message': { + 'role': 'toolResult', + 'toolCallId': 'call_1', + 'toolName': 'bash', + 'isError': false, + 'output': 'hi\n', + }, + }, + { + 'type': 'session_info', + 'id': 'e4', + 'parentId': null, + 'timestamp': '2026-09-06T00:00:04.000Z', + 'name': name, + }, + ]; + file.writeAsStringSync( + lines.map((l) => jsonEncode(l)).join('\n') + '\n', + ); +} + +void main() { + late Directory root; + final cwd = '/data/work/example-project'; + + setUp(() { + root = Directory.systemTemp.createTempSync('pi-session-test-'); + }); + + tearDown(() { + try { + root.deleteSync(recursive: true); + } catch (_) {} + }); + + test('encodeCwdForSessionDir mirrors pi session-manager encoding', () { + expect(encodeCwdForSessionDir('/'), ''); + expect(encodeCwdForSessionDir('/a/b'), 'a-b'); + expect(encodeCwdForSessionDir('/a b/c:d'), 'a b-c-d'); + }); + + test('parseSessionMeta reads header, name, count, activity, model', () { + _writeSession(root, cwd: cwd, name: 'My session'); + final encoded = encodeCwdForSessionDir(cwd); + final dir = Directory( + '${root.path}${Platform.pathSeparator}sessions' + '${Platform.pathSeparator}--$encoded--', + ); + final file = dir.listSync().whereType().first; + + final meta = parseSessionMeta(file); + expect(meta, isNotNull); + expect(meta!.sessionId, _sessionId); + expect(meta.projectPath, cwd); + expect(meta.named, isTrue); + expect(meta.name, 'My session'); + expect(meta.messageCount, 3); + // model_change is absent; assistant message model is read. + expect(meta.model, isNotNull); + expect(meta.createdAt.isBefore(meta.lastActivityAt), isTrue); + }); + + test('unnamed session falls back to first user text', () { + _writeSession(root, cwd: cwd); + final all = scanRecentSessions(root.path, limit: 10); + expect(all, hasLength(1)); + final meta = all.first; + expect(meta.named, isFalse); + expect(meta.name, 'hello pi'); + }); + + test('loadSessionHistory maps user/assistant/tool-result entries', () { + _writeSession(root, cwd: cwd); + final encoded = encodeCwdForSessionDir(cwd); + final file = File( + '${root.path}${Platform.pathSeparator}sessions' + '${Platform.pathSeparator}--$encoded--' + '${Platform.pathSeparator}2026-09-06T00-00-00-000Z_$_sessionId.jsonl', + ); + final history = loadSessionHistory(file); + expect(history, hasLength(3)); + + expect(history[0].role, 'user'); + expect(history[0].text, 'hello pi'); + + expect(history[1].role, 'assistant'); + final blocks = history[1].blocks; + expect(blocks.whereType().single.text, 'Sure!'); + final tools = blocks.whereType().toList(); + expect(tools, hasLength(1)); + expect(tools.single.name, 'bash'); + expect(tools.single.arguments?['command'], 'echo hi'); + + expect(history[2].role, 'toolResult'); + expect(history[2].toolCallId, 'call_1'); + expect(history[2].output, 'hi\n'); + }); +} From 597c34cb167ba6021f7113bc53a3126824477fb4 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 03:01:16 +0800 Subject: [PATCH 05/11] =?UTF-8?q?fix(mobile):=20session-file=20parser=20?= =?UTF-8?q?=E2=80=94=20remove=20unescaped=20trailing=20$=20in=20string=20l?= =?UTF-8?q?iteral?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_runner/aot flagged pi_session_files.dart:61 (RegExp ending in $ before the closing quote parsed as interpolation). decodeProjectDirName was unused, so drop it instead of escaping. --- apps/mobile/lib/features/pi/pi_session_files.dart | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/apps/mobile/lib/features/pi/pi_session_files.dart b/apps/mobile/lib/features/pi/pi_session_files.dart index b437ae2..cc83041 100644 --- a/apps/mobile/lib/features/pi/pi_session_files.dart +++ b/apps/mobile/lib/features/pi/pi_session_files.dart @@ -52,17 +52,6 @@ List listSessionProjects(String agentDir) { return projects; } -/// Decode an encoded project dir back to an absolute cwd. Exact inverse is -/// ambiguous (a cwd may itself contain '-'), so this returns the raw encoded -/// name when no better mapping exists — callers (e.g. a persisted workspace -/// list) should resolve by cwd path stored in the session header instead. -String decodeProjectDirName(String encoded) { - // Everything between '--' markers. - final m = RegExp('^--(.+)--$').firstMatch(encoded.split(Platform.pathSeparator).last); - if (m == null) return encoded; - return m.group(1)!.replaceAll('-', Platform.pathSeparator); -} - /// Lightweight header + metadata scan of one session file. Reads up to /// [maxBytes] (headers and typical files are small; guards pathological logs). PiSessionMeta? parseSessionMeta(File file, {int maxBytes = 16 * 1024 * 1024}) { From d9cf0f477b5a17721ede5fa2790189bb08ed4bc2 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 03:07:27 +0800 Subject: [PATCH 06/11] fix(mobile): analyzer errors in pi-native files (CI round 2) - FileStat has no existsSync; drop the check (statSync throws when missing) - ValueNotifier has no listen(); use addListener/removeListener - _push helper was missing on PiChatController - assistantMessageEvent frames: type as Map before dispatch - PiToolCallView.name mutable (toolcall_end may arrive before start) - SelectableText has no overflow param; typed the switch arms (PiChatItem -> PiAssistantItem/PiBashItem) - remove unused constant / dart:ui import --- .../lib/features/pi/pi_chat_controller.dart | 13 +++++++--- .../lib/features/pi/pi_chat_screen.dart | 8 ++---- .../lib/features/pi/pi_session_files.dart | 4 +-- .../lib/features/pi/pi_session_models.dart | 5 +++- .../lib/features/pi/pi_sessions_screen.dart | 25 ++++++++++++------- 5 files changed, 34 insertions(+), 21 deletions(-) diff --git a/apps/mobile/lib/features/pi/pi_chat_controller.dart b/apps/mobile/lib/features/pi/pi_chat_controller.dart index ffc8cff..32d26ac 100644 --- a/apps/mobile/lib/features/pi/pi_chat_controller.dart +++ b/apps/mobile/lib/features/pi/pi_chat_controller.dart @@ -132,7 +132,8 @@ class PiChatController extends ChangeNotifier { } if (sessionFile != null && _agentDir.isNotEmpty) { - _loadHistory(sessionFile!); + final historyFile = sessionFile; + if (historyFile != null) _loadHistory(historyFile); } notifyListeners(); return PiOpenResult(sessionId: sessionId, sessionFile: sessionFile); @@ -359,8 +360,9 @@ class PiChatController extends ChangeNotifier { } void _onMessageUpdate(Map f) { - final ev = f['assistantMessageEvent']; - if (ev is! Map) return; + final evRaw = f['assistantMessageEvent']; + if (evRaw is! Map) return; + final ev = Map.from(evRaw); final type = ev['type']?.toString(); final contentIndex = (ev['contentIndex'] as num?)?.toInt() ?? 0; final item = _ensureAssistant(); @@ -651,6 +653,11 @@ class PiChatController extends ChangeNotifier { notifyListeners(); } + void _push(PiChatItem item) { + items.add(item); + notifyListeners(); + } + void _pushSystem(String message) { items.add(PiErrorItem(message)); notifyListeners(); diff --git a/apps/mobile/lib/features/pi/pi_chat_screen.dart b/apps/mobile/lib/features/pi/pi_chat_screen.dart index 079e2d2..5c52d19 100644 --- a/apps/mobile/lib/features/pi/pi_chat_screen.dart +++ b/apps/mobile/lib/features/pi/pi_chat_screen.dart @@ -1,5 +1,4 @@ import 'dart:async'; -import 'dart:ui'; import 'package:flutter/material.dart'; @@ -364,13 +363,13 @@ class _ItemBubble extends StatelessWidget { ), ), PiAssistantItem() => _AssistantBubble( - item: item, + item: item as PiAssistantItem, showThinking: showThinking, onToggleThinking: onToggleThinking, ), PiToolResultBubble(:final toolName, :final content, :final isError) => _ToolResultCard(toolName: toolName, content: content, isError: isError), - PiBashItem() => _BashCard(item: item), + PiBashItem() => _BashCard(item: item as PiBashItem), PiErrorItem(:final message) => Container( margin: const EdgeInsets.symmetric(vertical: 4), padding: const EdgeInsets.all(10), @@ -569,7 +568,6 @@ class _ToolCallCardState extends State<_ToolCallCard> { tool.argumentsText, style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), maxLines: 6, - overflow: TextOverflow.ellipsis, ), ), if (tool.streamOutput != null && tool.streamOutput!.isNotEmpty) @@ -587,7 +585,6 @@ class _ToolCallCardState extends State<_ToolCallCard> { style: theme.textTheme.bodySmall?.copyWith(fontFamily: 'monospace'), maxLines: 8, - overflow: TextOverflow.ellipsis, ), ), ), @@ -598,7 +595,6 @@ class _ToolCallCardState extends State<_ToolCallCard> { tool.resultText!, style: theme.textTheme.bodySmall, maxLines: 10, - overflow: TextOverflow.ellipsis, ), ), ], diff --git a/apps/mobile/lib/features/pi/pi_session_files.dart b/apps/mobile/lib/features/pi/pi_session_files.dart index cc83041..f3996a4 100644 --- a/apps/mobile/lib/features/pi/pi_session_files.dart +++ b/apps/mobile/lib/features/pi/pi_session_files.dart @@ -57,7 +57,7 @@ List listSessionProjects(String agentDir) { PiSessionMeta? parseSessionMeta(File file, {int maxBytes = 16 * 1024 * 1024}) { try { final stat = file.statSync(); - if (!stat.existsSync || stat.size > maxBytes) return null; + if (stat.size > maxBytes) return null; final lines = file.readAsLinesSync(encoding: utf8); return _metaFromLines(file.path, lines); } catch (_) { @@ -222,7 +222,7 @@ String _fallbackName(String? firstUserText) { List loadSessionHistory(File file, {int maxBytes = 16 * 1024 * 1024}) { try { final stat = file.statSync(); - if (!stat.existsSync || stat.size > maxBytes) return const []; + if (stat.size > maxBytes) return const []; final lines = file.readAsLinesSync(encoding: utf8); return _historyFromLines(lines); } catch (_) { diff --git a/apps/mobile/lib/features/pi/pi_session_models.dart b/apps/mobile/lib/features/pi/pi_session_models.dart index 98663d2..1a54ea2 100644 --- a/apps/mobile/lib/features/pi/pi_session_models.dart +++ b/apps/mobile/lib/features/pi/pi_session_models.dart @@ -169,7 +169,10 @@ class PiToolCallView { }); final String callId; - final String name; + + /// Tool name may only be known at toolcall_end when a toolcall_start raced + /// ahead; kept mutable. + String name; PiToolStatus status; /// Raw accumulated arguments (from toolcall_delta) or pretty JSON. diff --git a/apps/mobile/lib/features/pi/pi_sessions_screen.dart b/apps/mobile/lib/features/pi/pi_sessions_screen.dart index 700f067..645ec19 100644 --- a/apps/mobile/lib/features/pi/pi_sessions_screen.dart +++ b/apps/mobile/lib/features/pi/pi_sessions_screen.dart @@ -14,7 +14,6 @@ import 'pi_session_models.dart'; const _kTitle = 'Pi 会话'; const _kNewChat = '新对话'; -const _kContinueLast = '继续最近会话'; const _kRefresh = '刷新'; const _kWorkspaceNameLabel = '工作区名称'; const _kWorkspacePathLabel = '或输入绝对路径'; @@ -38,27 +37,35 @@ class _PiSessionsScreenState extends State { List _recent = const []; bool _loading = false; String? _bootMessage; - StreamSubscription? _lifecycleSub; - PiHostService get _host => context.read(); @override void initState() { super.initState(); - _lifecycleSub = _host.lifecycle.listen((state) { - if (state == EngineLifecycle.ready || state == EngineLifecycle.error) { - _reload(); - } - }); + _host.lifecycle.addListener(_onLifecycleChanged); _boot(); } @override void dispose() { - _lifecycleSub?.cancel(); + _host.lifecycle.removeListener(_onLifecycleChanged); super.dispose(); } + void _onLifecycleChanged() { + final state = _host.lifecycle.value; + if (state == EngineLifecycle.ready || state == EngineLifecycle.error) { + if (!mounted) return; + if (state == EngineLifecycle.ready) { + setState(() => _bootMessage = null); + _reload(); + } else if (state == EngineLifecycle.error) { + setState( + () => _bootMessage = _host.bootError?.toString() ?? _kEngineNotReady); + } + } + } + Future _boot() async { if (_host.isReady) { _reload(); From 69e1ec797f536829ed43c8294b906cb8e5162dae Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 03:11:48 +0800 Subject: [PATCH 07/11] fix(mobile): remaining analyzer round-3 issues - fileMtimeSafe: FileStat has no existsSync (statSync throws when missing) - chat controller: local-var promotion for the history load guard --- .../lib/features/pi/pi_chat_controller.dart | 6 +-- .../lib/features/pi/pi_session_files.dart | 4 +- docs/STATUS.md | 54 +++++++++++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/apps/mobile/lib/features/pi/pi_chat_controller.dart b/apps/mobile/lib/features/pi/pi_chat_controller.dart index 32d26ac..c9c900a 100644 --- a/apps/mobile/lib/features/pi/pi_chat_controller.dart +++ b/apps/mobile/lib/features/pi/pi_chat_controller.dart @@ -131,9 +131,9 @@ class PiChatController extends ChangeNotifier { // On failure keep the engine's own current session (best effort). } - if (sessionFile != null && _agentDir.isNotEmpty) { - final historyFile = sessionFile; - if (historyFile != null) _loadHistory(historyFile); + final resumeFile = sessionFile; + if (resumeFile != null && _agentDir.isNotEmpty) { + _loadHistory(resumeFile); } notifyListeners(); return PiOpenResult(sessionId: sessionId, sessionFile: sessionFile); diff --git a/apps/mobile/lib/features/pi/pi_session_files.dart b/apps/mobile/lib/features/pi/pi_session_files.dart index f3996a4..5ec09a1 100644 --- a/apps/mobile/lib/features/pi/pi_session_files.dart +++ b/apps/mobile/lib/features/pi/pi_session_files.dart @@ -199,8 +199,8 @@ PiSessionMeta? _metaFromLines(String path, List lines) { DateTime? fileMtimeSafe(String path) { try { - final s = File(path).statSync(); - return s.existsSync ? s.modified : null; + // statSync throws when the file is missing; FileStat has no existsSync. + return File(path).statSync().modified; } catch (_) { return null; } diff --git a/docs/STATUS.md b/docs/STATUS.md index 3c1b01a..d2214da 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,59 @@ # STATUS — 项目状态与已知问题(2026-09 更新) +## 2026-09-06 深夜:pi-native 直通手术(分支 pi-native-audit)— 当前主线 + +> 本节描述 `pi-native-audit` 分支的最新状态;以下历史章节为旧 WS 时代的记录。 + +### 架构现状(HEAD 后的新事实) + +- **bridge = 纯 stdio 侧车**:5ed4868 删除了 WebSocket/CC 服务器、cc-adapter、 + pi-adapter、pi-sessions、parser 等全部远程面;现存 `packages/bridge/src` = + `pi-host/*` + `pi-stdio-entry.ts`(进程池 + PiGateway + 供给/运行时 + stdio host)。 + App 通过 PiHostService `Process.start(node, hostEntry)` 拉宿主,走 app 持有的 + stdin/stdout JSON 管道(control + ui_response / kind=pi 事件帧)。 +- **引擎 home 语义修正**:pi 没有 `PI_HOME`(agent 目录 = `~/.pi/agent` 或 + `PI_CODING_AGENT_DIR`)。宿主现在给每个引擎子进程注入 + `PI_CODING_AGENT_DIR=/.pi/agent`,与桥端文件面(settings/models/ + skills/sessions…)同一位置;否则安卓上(HOME 缺失)引擎配置与会话落点不一致。 +- **文件面算子不再误拉引擎**:get_settings/get_models/skills/themes/packages/ + runtime 等纯文件 op 直接短路(SURFACE_ONLY_OPS),不再为合成 projectId + (如 `pi-x-engine`)以宿主 cwd(安卓上为 `/`)为目标 spawn 引擎而 EACCES; + stdio 入口把非绝对 projectId 归一化到 `/engine-global`。 +- **App 原生会话/聊天(Dart 直通)**:新增 `lib/features/pi/`—— + - `pi_session_models.dart` / `pi_session_files.dart`:会话 JSONL 解析 + (meta/最近会话/历史回放,目录编码对齐 pi session-manager); + - `pi_chat_controller.dart`:每项目对话控制器,prompt/steer/follow_up/abort/ + switch_session/new_session/set_session_name + 事件流 + (message_start/update/end、toolcall_*、tool_execution_*、 + bash_execution_update、extension_error、compaction、agent_*)→ 实时会话; + - `pi_sessions_screen.dart` / `pi_chat_screen.dart`:原生首页(引擎状态 + + 最近会话列表 + 新对话建工作区)+ 原生聊天屏(文本/thinking/工具卡/审批 + 由全局 PiExtensionUiHost 应答)。 + - 首页 '/' 默认进入 Pi 原生首页(pref `pi.nativeHome`,旧 CC 首页 + `legacyHomeOverride` 保留);MainActivity extractRuntime 增补 + `workspacesDir`(app 私有工作区根)。 +- **CI**:engine-smoke 已修复(删死 cc-adapter-check、补 e2e-full-flow, + 修复 real-verify 的 /workspace 绝对路径);bridge job 增加 vitest;删除死脚本 + (setup-launchd/dev-restart/cc-fullstack-e2e)与根脚本死项(functions/ + shorebird/doctor)。 + +### 验证状态 + +- 桥端:`tsc --noEmit` 干净;vitest(9 文件)+ engine-smoke(pi-host-smoke / + pi-rpc-smoke / e2e-full-flow / engine-bundle)CI 绿。 +- App:`build_runner` + `flutter analyze --no-fatal-infos` 0 error/warning + + `flutter test` 在分支 CI 上跑(迭代中)。 +- 待真机/模拟器验证:首启供给(npm 装引擎)→ 新建工作区 → prompt 流式 → + 审批回传 → 会话落盘 → 重启后续接。 + +### 遗留(后续增量) + +- pi UI 文案暂为硬编码常量(未走 4 语种 ARB);图片附件/消息内图片渲染; + CC 遗留屏(codex/claude/chat + 工作区壳)仍可按 `legacyHomeOverride` 访问, + 待逐步收敛删除;真终端(PTY)仍为远期。 + +# STATUS — 项目状态与已知问题(2026-09 更新) + ## 2026-09-06 全链路真实验证完成(Flutter SDK + 真引擎 + WS 端到端) > 本环境现已安装 Flutter SDK(/opt/flutter 3.47.2),首次在本环境实际执行了此前 From c7c54fc49f652c07a063702afc2b229179bb9b01 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 03:20:20 +0800 Subject: [PATCH 08/11] fix(mobile): session meta messageCount counts all message entries New pi_session_files test caught the mismatch: messageCount now increments for user/assistant/toolResult/bashExecution entries (pi counts every stored message), toolResult no longer needs a separate activity-only branch. --- AGENTS.md | 3 ++- .../mobile/lib/features/pi/pi_session_files.dart | 16 ++++++++-------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index e00042b..0c2e7ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -6,9 +6,10 @@ Pi X:纯本地安卓 AI 编码 Agent。UI/协议层源自 CC Pocket(MIT) ## 关键决策(不要推翻) - 引擎:pi(`@earendil-works/pi-coding-agent`),**`pi --mode rpc` 子进程(官方 JSONL 协议)+ Pi Host 薄网关**(进程池/FS/帧直通),版本跟随管道热换(engines/),不走 git merge;不采用进程内嵌 AgentSession(见 `docs/ENGINE-INTEGRATION.md` §1/§6 决策修订)。 +- **2026-09-06 决策修订(pi-native-audit 分支)**:WS/CC 服务器与 cc-adapter/pi-adapter/pi-sessions/parser 已物理删除,bridge = 纯 stdio 侧车(`pi-stdio-entry.ts` + `pi-host/*`);App 会话/聊天在 Dart 端直通引擎事件(`lib/features/pi/`,事件→界面 1:1,映射表不再作为服务器层)。引擎 agent 目录以 `PI_CODING_AGENT_DIR=/.pi/agent` 注入,与桥端文件面同址。UI:首页默认 pi 原生会话页(pref `pi.nativeHome`),CC 会话/聊天屏保留为过渡(`legacyHomeOverride`)。详情见 `docs/STATUS.md` 顶部章节。 - UI:CC Pocket 本地化改造——已删/收敛完成:QR 扫码、Setup guide 远端页、fastlane 商店资产、mDNS 发现、机器管理 UI → 单本机(127.0.0.1)、SSH 隧道/启动、远端更新横幅、FCM 推送、远程连接 deep link → 仅会话分享(见 `docs/REMOTE-AUDIT.md`)。 - 许可红线:只引入 MIT/Apache-2.0;禁止 Aether(GPL) / Operit(LGPL) / GetStream(非 OSI) 代码。 -- 审批流:pi 扩展的 `extension_ui_request`(confirm/select/input/editor)经 Pi Host 网关原样透传(含 `confirmed`/`cancelled` 回传),Flutter 端 `PiExtensionUiHost` 渲染原生对话框应答;CC Pocket 协议层审批消息经 cc-adapter 映射(见 `docs/ENGINE-UI-SURFACES.md` §6.4)。 +- 审批流:pi 扩展的 `extension_ui_request`(confirm/select/input/editor)经 stdio 透传,Flutter 端 `PiExtensionUiHost` 渲染原生对话框应答(`respondUi` 语义 confirmed/cancelled/value 对齐 pi rpc docs)。 - 只做安卓:构建/CI/文档均按 Android 目标;ios/macos/linux/windows/web 源码保留备用但不在工作范围。 ## 目录 diff --git a/apps/mobile/lib/features/pi/pi_session_files.dart b/apps/mobile/lib/features/pi/pi_session_files.dart index 5ec09a1..bebc18f 100644 --- a/apps/mobile/lib/features/pi/pi_session_files.dart +++ b/apps/mobile/lib/features/pi/pi_session_files.dart @@ -108,19 +108,19 @@ PiSessionMeta? _metaFromLines(String path, List lines) { if (ts != null && (lastActivity == null || ts.isAfter(lastActivity))) { lastActivity = ts; } - if (role == 'user') { + // messageCount counts every stored message entry (user/assistant/ + // toolResult/bashExecution) — mirror pi's session message count. + if (role == 'user' || + role == 'assistant' || + role == 'toolResult' || + role == 'bashExecution') { messageCount += 1; + } + if (role == 'user') { firstUserText ??= contentToText(msg['content']).trim(); } else if (role == 'assistant') { - messageCount += 1; final m = msg['model']?.toString(); if (m != null && model == null) model = m; - } else if (role == 'toolResult' || role == 'bashExecution') { - // non-chat entries still count as activity - final eTs = parseTs(decoded['timestamp']); - if (eTs != null && (lastActivity == null || eTs.isAfter(lastActivity))) { - lastActivity = eTs; - } } final mt = msg['model']; if (mt is String && model == null) model = mt; From a5f84a76531c3ff444ae45b58afa4347dcf2a6bc Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 03:21:41 +0800 Subject: [PATCH 09/11] =?UTF-8?q?feat(mobile):=20sessions=20home=20?= =?UTF-8?q?=E2=80=94=20one-tap=20engine=20install=20when=20route-B=20first?= =?UTF-8?q?=20launch=20raced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The global EngineInstallGate diagnoses only at app start; a slow host boot used to let the native home through with no engine installed. PiSessionsScreen now checks get_engine_versions itself and renders an install CTA with progress/error feedback. --- .../lib/features/pi/pi_sessions_screen.dart | 83 ++++++++++++++++++- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/apps/mobile/lib/features/pi/pi_sessions_screen.dart b/apps/mobile/lib/features/pi/pi_sessions_screen.dart index 645ec19..1adef8a 100644 --- a/apps/mobile/lib/features/pi/pi_sessions_screen.dart +++ b/apps/mobile/lib/features/pi/pi_sessions_screen.dart @@ -36,6 +36,10 @@ class PiSessionsScreen extends StatefulWidget { class _PiSessionsScreenState extends State { List _recent = const []; bool _loading = false; + bool _checkingEngine = true; + bool _installing = false; + bool _engineMissing = false; + String? _installError; String? _bootMessage; PiHostService get _host => context.read(); @@ -58,7 +62,7 @@ class _PiSessionsScreenState extends State { if (!mounted) return; if (state == EngineLifecycle.ready) { setState(() => _bootMessage = null); - _reload(); + _checkEngineAndReload(); } else if (state == EngineLifecycle.error) { setState( () => _bootMessage = _host.bootError?.toString() ?? _kEngineNotReady); @@ -68,7 +72,7 @@ class _PiSessionsScreenState extends State { Future _boot() async { if (_host.isReady) { - _reload(); + _checkEngineAndReload(); return; } setState(() => _bootMessage = _kEngineStarting); @@ -76,13 +80,50 @@ class _PiSessionsScreenState extends State { if (!mounted) return; if (ok) { setState(() => _bootMessage = null); - _reload(); + _checkEngineAndReload(); } else { setState(() => _bootMessage = _host.bootError?.toString() ?? _kEngineNotReady); } } + /// Route-B first launch can race the global EngineInstallGate (which only + /// diagnoses once at app start); make the home self-sufficient: when no + /// engine is installed offer a one-tap install with feedback. + Future _checkEngineAndReload() async { + setState(() { + _checkingEngine = true; + _bootMessage = null; + }); + final versions = await _host.getEngineVersions(); + if (!mounted) return; + setState(() { + _checkingEngine = false; + _engineMissing = versions.ok && versions.data?['current'] == null; + }); + _reload(); + } + + Future _installEngine() async { + if (_installing) return; + setState(() { + _installing = true; + _installError = null; + }); + final result = await _host.installEngine(); + if (!mounted) return; + setState(() { + _installing = false; + if (result.ok) { + _engineMissing = false; + _installError = null; + } else { + _installError = result.error ?? 'install failed'; + } + }); + if (result.ok) _reload(); + } + Future _reload() async { final agentDir = _host.agentDir; if (agentDir == null || agentDir.isEmpty) return; @@ -261,6 +302,42 @@ class _PiSessionsScreenState extends State { ), ); } + if (_checkingEngine && _recent.isEmpty) { + return const Center(child: CircularProgressIndicator()); + } + if (_engineMissing) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.extension_off_outlined, + size: 48, color: theme.colorScheme.error), + const SizedBox(height: 12), + const Text('pi 引擎尚未安装 — 首次使用需下载(约几十 MB)。'), + const SizedBox(height: 8), + if (_installing) const LinearProgressIndicator(), + if (_installError != null) + Padding( + padding: const EdgeInsets.only(top: 8), + child: Text( + _installError!, + textAlign: TextAlign.center, + style: TextStyle(color: theme.colorScheme.error), + ), + ), + const SizedBox(height: 12), + FilledButton.icon( + onPressed: _installing ? null : _installEngine, + icon: const Icon(Icons.download), + label: Text(_installing ? '正在安装…' : '安装引擎'), + ), + ], + ), + ), + ); + } if (_loading && _recent.isEmpty) { return const Center(child: CircularProgressIndicator()); } From 33f45fe82c37746fab7f6c69b57c24a865833e04 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 17:05:32 +0800 Subject: [PATCH 10/11] =?UTF-8?q?fix(host):=20make=20on-device=20engine=20?= =?UTF-8?q?install=20work=20=E2=80=94=20bundle=20npm=20with=20the=20runtim?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Root cause of 'engine never installs on the phone': install_engine runs `npm install @earendil-works/pi-coding-agent`, but npm exists nowhere on the device — the bundled bionic node zip ships bin/node+libs only, and the route-A tools bootstrap has no npm either. Verified by downloading both artifacts: - scripts/fetch-bionic-node.mjs now also stages termux's standalone npm (npm_11.19.1_all.deb, sha256-pinned) next to each abi runtime (/npm, incl. bin/npm-cli.js). - MainActivity returns npmCli; PiHostRuntime passes PIX_NPM_CLI into the host. - engine-provisioner defaultNpmInstall runs `node ` when PIX_NPM_CLI is set (PATH-independent; desktop/CI keep plain npm). - pi-stdio-entry sets HOME= when unset (Android HOME is missing), which npm config/cache and git/ssh in the engine bash tools need. Bionic APK from the bionic-node-runtime workflow now contains the full first-launch chain: extract node+host+npm → install_engine → engines/current → chat. --- .../kotlin/com/k9i/ccpocket/MainActivity.kt | 2 ++ apps/mobile/lib/services/pi_host_service.dart | 4 +++ .../bridge/src/pi-host/engine-provisioner.ts | 22 +++++++++----- packages/bridge/src/pi-stdio-entry.ts | 6 ++++ scripts/fetch-bionic-node.mjs | 30 +++++++++++++++++++ 5 files changed, 56 insertions(+), 8 deletions(-) diff --git a/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt b/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt index 06d40a4..0269364 100644 --- a/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt +++ b/apps/mobile/android/app/src/main/kotlin/com/k9i/ccpocket/MainActivity.kt @@ -171,11 +171,13 @@ class MainActivity : FlutterActivity() { // them. node lives at /bin/node → lib sits at /lib. val abiDir = File(nodeBin!!).parentFile?.parentFile ?: root val libDir = File(abiDir, "lib") + val npmCli = File(abiDir, "npm/bin/npm-cli.js") return mutableMapOf( "nodeBin" to nodeBin, "hostEntry" to hostEntry.absolutePath, "libDir" to (if (libDir.isDirectory) libDir.absolutePath else ""), + "npmCli" to (if (npmCli.exists()) npmCli.absolutePath else ""), "piHome" to root.absolutePath, "enginesDir" to File(root, "engines").absolutePath, "workspacesDir" to File(filesDir, "workspaces").absolutePath, diff --git a/apps/mobile/lib/services/pi_host_service.dart b/apps/mobile/lib/services/pi_host_service.dart index cbb6eda..3a14dd5 100644 --- a/apps/mobile/lib/services/pi_host_service.dart +++ b/apps/mobile/lib/services/pi_host_service.dart @@ -141,6 +141,7 @@ class PiHostRuntime { final piHome = result['piHome'] as String?; final enginesDir = result['enginesDir'] as String?; final workspacesDir = result['workspacesDir'] as String?; + final npmCli = result['npmCli'] as String?; if (nodeBin == null || hostEntry == null || nodeBin.isEmpty || @@ -156,6 +157,9 @@ class PiHostRuntime { if (piHome != null && piHome.isNotEmpty) 'PI_HOME': piHome, if (enginesDir != null && enginesDir.isNotEmpty) 'PIX_ENGINES_DIR': enginesDir, + // Bundled npm: route-B engine install runs npm-cli via the bundled + // node (engine-provisioner defaultNpmInstall reads PIX_NPM_CLI). + if (npmCli != null && npmCli.isNotEmpty) 'PIX_NPM_CLI': npmCli, // Bundled bionic node links against its shipped lib/ dir; without // LD_LIBRARY_PATH the Android ELF interpreter cannot resolve the // .so (openssl/icu/zlib/nghttp2/libc++_shared/sqlite…). diff --git a/packages/bridge/src/pi-host/engine-provisioner.ts b/packages/bridge/src/pi-host/engine-provisioner.ts index 081f18c..250c69e 100644 --- a/packages/bridge/src/pi-host/engine-provisioner.ts +++ b/packages/bridge/src/pi-host/engine-provisioner.ts @@ -514,17 +514,23 @@ export async function defaultExtractTarball( /** * Default npm-registry installer: `npm install --prefix ` the given - * spec. Honors the ambient npm registry config (registry scope, npmrc). On a - * device the `npm`/`node` binaries must be present in PATH (the on-device - * runtime provides them); the injected `npmInstall` host is used in hermetic - * tests instead of this path. + * spec. Honors the ambient npm registry config (registry scope, npmrc). The + * injected `npmInstall` host is used in hermetic tests instead of this path. + * + * On-device there is no `npm` on PATH: the bundled bionic node zip ships only + * `bin/node` + libs, and the route-A tools bootstrap has no npm either — the + * runtime now bundles termux's standalone npm package, and the app passes its + * npm-cli.js via `PIX_NPM_CLI`. When set, npm is invoked explicitly as + * `node …` so the install never depends on PATH resolution. */ export async function defaultNpmInstall(spec: string, dest: string): Promise { await mkdir(dest, { recursive: true }); - await exec([ - "npm", - ["install", "--prefix", dest, "--ignore-scripts", "--no-audit", "--no-fund", "--no-progress", spec], - ]); + const npmCli = process.env.PIX_NPM_CLI; + const cmd = npmCli && npmCli.trim().length > 0 ? process.execPath : "npm"; + const argv = npmCli && npmCli.trim().length > 0 + ? [npmCli, "install", "--prefix", dest, "--ignore-scripts", "--no-audit", "--no-fund", "--no-progress", spec] + : ["install", "--prefix", dest, "--ignore-scripts", "--no-audit", "--no-fund", "--no-progress", spec]; + await exec([cmd, argv]); } export async function defaultSmoke(piEntry: string, cwd: string): Promise { diff --git a/packages/bridge/src/pi-stdio-entry.ts b/packages/bridge/src/pi-stdio-entry.ts index ee6e834..4c2b932 100644 --- a/packages/bridge/src/pi-stdio-entry.ts +++ b/packages/bridge/src/pi-stdio-entry.ts @@ -33,6 +33,12 @@ import { isAbsolute, join } from "node:path"; async function main(): Promise { const piHome = process.env.PI_HOME ?? process.env.HOME ?? ""; + // npm (bundled with the runtime) and userland tools expect a home dir; on + // Android HOME is usually unset, which breaks `npm install` config/cache + // and git/ssh in the engine's bash tools. Point it at the app pi home. + if (!process.env.HOME && piHome.length > 0) { + process.env.HOME = piHome; + } // Route A (bionic): if the Termux toolchain was bundled/extracted under // /tools//usr, expose it on PATH/LD_LIBRARY_PATH now so the // engine child (spawned with ...process.env) can exec bash/git/python. diff --git a/scripts/fetch-bionic-node.mjs b/scripts/fetch-bionic-node.mjs index 6d97448..ead3e90 100644 --- a/scripts/fetch-bionic-node.mjs +++ b/scripts/fetch-bionic-node.mjs @@ -31,6 +31,15 @@ const repoRoot = resolve(here, ".."); const RELEASE = "https://github.com/Zohaib8090/KodrixMarketplace/releases/download/v1.0"; const TERMUX = "https://packages.termux.dev/apt/termux-main/pool/main"; +// Termux standalone `npm` (arch-independent .deb, verified 2026-09). The +// bundled bionic node zip ships bin/node + libs only — no npm — so route-B +// engine install (`npm install @earendil-works/pi-coding-agent`) could never +// run on-device. We stage npm next to each abi runtime; the host entry then +// invokes it explicitly with the bundled node (PIX_NPM_CLI). +const NPM_DEB_URL = `${TERMUX}/n/npm/npm_11.19.1_all.deb`; +const NPM_DEB_SHA256 = + "bb7a736be3229d28af7986365b0738e19619368d0f12cdd4d34b02962dc8b05c"; + // abi (android) -> { zip, sha256, sqliteDeb?, sqliteSha? } (all pinned) const PLAN = { "arm64-v8a": { @@ -86,6 +95,26 @@ function extractDeb(deb, destDir) { execFileSync("dpkg-deb", ["-x", deb, destDir], { stdio: "inherit" }); } +/** Stage the bundled npm tree (termux npm .deb) next to one abi runtime. */ +function stageNpm(abiOut) { + const cli = join(abiOut, "npm", "bin", "npm-cli.js"); + if (existsSync(cli)) { + console.log(`[skip] npm already staged ${join(abiOut, "npm")}`); + return; + } + const deb = join(cache, "npm.deb"); + fetchTo(deb, NPM_DEB_URL, NPM_DEB_SHA256); + const st = join(cache, "x-npm"); + extractDeb(deb, st); + const npmSrc = join(st, "data/data/com.termux/files/usr/lib/node_modules/npm"); + if (!existsSync(join(npmSrc, "bin", "npm-cli.js"))) { + throw new Error(`unexpected npm .deb layout (missing npm/bin/npm-cli.js)`); + } + mkdirSync(join(abiOut, "npm"), { recursive: true }); + copyRecursive(npmSrc, join(abiOut, "npm")); + console.log(`[sidecar] npm staged → ${join(abiOut, "npm")}`); +} + function main() { mkdirSync(out, { recursive: true }); const caches = {}; @@ -120,6 +149,7 @@ function main() { } else { console.warn(`[warn] no sqlite pin for ${abi}; node's node:sqlite may be unavailable here`); } + stageNpm(abiOut); console.log(`[ok] staged ${abi} → ${abiOut}`); } console.log("[fetch-bionic-node] run: node scripts/mobile-sidecar.mjs --runtime build/bionic"); From 1b9fb76569732405639d7033522a19c325051298 Mon Sep 17 00:00:00 2001 From: dsh-mobile Date: Mon, 7 Sep 2026 17:08:37 +0800 Subject: [PATCH 11/11] fix(mobile): auto-restart the host after engine install so engines/current resolves The stdio host resolves the engine entry (engines/current) once at boot; the first-run flow downloaded the engine but never restarted the host, so every chat op still tried to spawn an empty entry. PiHostService.restartHost() kills + respawns the subprocess (generation counter stops the old exit handler from double-respawning); the auto first-launch gate, the manual EngineInstallFlow restart button and the sessions-home install CTA all use it. --- .../lib/features/pi/pi_sessions_screen.dart | 6 +++ .../pi_engine/engine_install_flow.dart | 20 ++++++--- apps/mobile/lib/services/pi_host_service.dart | 43 +++++++++++++++++++ 3 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/mobile/lib/features/pi/pi_sessions_screen.dart b/apps/mobile/lib/features/pi/pi_sessions_screen.dart index 1adef8a..d93323d 100644 --- a/apps/mobile/lib/features/pi/pi_sessions_screen.dart +++ b/apps/mobile/lib/features/pi/pi_sessions_screen.dart @@ -112,6 +112,12 @@ class _PiSessionsScreenState extends State { }); final result = await _host.installEngine(); if (!mounted) return; + if (result.ok) { + // engines/current is only resolved at host boot — restart the host so + // the chat can actually spawn the freshly installed engine. + await _host.restartHost(); + if (!mounted) return; + } setState(() { _installing = false; if (result.ok) { diff --git a/apps/mobile/lib/features/pi_engine/engine_install_flow.dart b/apps/mobile/lib/features/pi_engine/engine_install_flow.dart index dfc8c68..5ec8940 100644 --- a/apps/mobile/lib/features/pi_engine/engine_install_flow.dart +++ b/apps/mobile/lib/features/pi_engine/engine_install_flow.dart @@ -203,22 +203,30 @@ class _EngineInstallFlowState extends State { _error = result.error ?? 'install_failed'; } }); - if (result.success) widget.onComplete?.call(); + if (result.success) { + // Auto (first-launch) installs must restart the host process: the stdio + // host resolves the engine entry (engines/current) only at boot, so a + // brand-new install is invisible to the already-running host otherwise. + if (widget.autoInstall) { + await _service.restartHost(); + if (!mounted) return; + } + widget.onComplete?.call(); + } } Future _restart() async { - // Restart the host so the freshly installed engine is resolved at boot. + // Restart the whole host subprocess so the freshly installed engine is + // resolved at boot (restart_engine only re-arms the per-project pool). final l = AppLocalizations.of(context); final messenger = ScaffoldMessenger.of(context); messenger.showSnackBar(SnackBar(content: Text(l.piEngineRestarting))); - final result = await _service.control('restart_engine'); + final ok = await _service.restartHost(); messenger.hideCurrentSnackBar(); messenger.showSnackBar( SnackBar( content: Text( - result.ok - ? l.piEngineRestarted - : l.piEngineError(result.error ?? 'restart failed'), + ok ? l.piEngineRestarted : l.piEngineError(_service.lastExitReason), ), ), ); diff --git a/apps/mobile/lib/services/pi_host_service.dart b/apps/mobile/lib/services/pi_host_service.dart index 3a14dd5..53e1dd9 100644 --- a/apps/mobile/lib/services/pi_host_service.dart +++ b/apps/mobile/lib/services/pi_host_service.dart @@ -263,6 +263,11 @@ class PiHostService { int _launchAttempt = 0; int _idCounter = 0; + /// Bumped on every intentional host restart so the OLD process's exit + /// handler (which only checks _disposed/_intentionalStop at its own time) + /// cannot schedule a duplicate respawn. + int _generation = 0; + // Host-child diagnostics. The previous build never read node's stderr, so // when the engine host crashed at exec (missing .so, wrong ABI, non-exec // binary) the app showed a forever "connecting" spinner and swallowed the @@ -421,8 +426,12 @@ class PiHostService { // Auto-retry: if the host dies unexpectedly, record WHY it died, then // respawn with backoff — but give up after several consecutive crashes so // the UI can surface the real reason instead of crash-looping forever. + final generation = _generation; process.exitCode.then((code) { if (_disposed || _intentionalStop) return; + // A newer process was spawned by restartHost()/dispose(); this exit + // belongs to the old one — never touch the shared state or respawn. + if (generation != _generation) return; _stdin = null; _outSub?.cancel(); _outSub = null; @@ -457,6 +466,40 @@ class PiHostService { }); } + /// Restart the host subprocess so boot-time state is re-resolved — above + /// all the engine entry (`engines/current`) after install_engine, and any + /// changed launch args / runtime route. The stdio host resolves the engine + /// entry once at boot, so `control('restart_engine')` alone is not enough. + /// + /// Returns true when the fresh host reaches [EngineLifecycle.ready]. + Future restartHost() async { + if (_disposed) return false; + final oldProcess = _process; + if (oldProcess == null) { + // Nothing running (or cold start): a plain start is a restart. + return ensureStarted(); + } + _intentionalStop = true; + _generation += 1; + final oldSub = _outSub; + _process = null; + _stdin = null; + _outSub = null; + _starting = false; + _stderrTail.clear(); + _lastExitReason = ''; + _bootError = null; + oldSub?.cancel(); + _failPending('host restarting'); + _lifecycle.value = EngineLifecycle.restarting; + try { + oldProcess.kill(); + } catch (_) {} + await oldProcess.exitCode.catchError((Object _) => 0); + _intentionalStop = false; + return ensureStarted(); + } + void _handleLine(String line) { if (_disposed) return; final trimmed = line.trim();