From 1807246b7eb7a469f962818c63e30cca30b1ec4b Mon Sep 17 00:00:00 2001 From: xjx <371425010@qq.com> Date: Wed, 12 Aug 2026 01:49:51 +0800 Subject: [PATCH] Add portable skill management and harden protected delivery (#10) ## What changed - add App and PetCore portable Agent Pet Maker skill management, with fd-relative ownership and race-safe install/uninstall - improve Pi no-model failure projection, session bubble message selection, child-session filtering, onboarding rendering, and related App behavior - delete verified PR source branches with exact leases after merge while dispatching exact-main CI first - document direct/train multi-agent delivery, explicit add/commit/clean/push checks, post-merge worktree audits, and exact-SHA release proof ## Validation - full `cargo test -p petcore` - Swift package suite: 734 tests - Pi connector privacy tests - promotion/release Python tests and actionlint - `./script/validate_pre_push.sh` - independent specification and safety reviews: no remaining blockers This PR changes the protected CI control plane, so it is intended for full CI followed by trusted exact-head squash merge. --------- Co-authored-by: gd-ops --- .github/workflows/auto-merge.yml | 15 + .gitignore | 3 + AGENTS.md | 1 + CHANGELOG.md | 32 + README.md | 1 + README.zh-CN.md | 1 + .../AgentPetCompanion/App/AppStore.swift | 67 + .../AgentPetCompanion/App/Localization.swift | 41 + .../App/PortableMakerSkill.swift | 55 + .../Overlay/OverlayGeometry.swift | 53 +- .../Overlay/OverlayValidationContract.swift | 15 +- .../Resources/Localizable.xcstrings | 246 +++ .../Resources/en.lproj/Localizable.strings | 41 + .../zh-Hans.lproj/Localizable.strings | 41 + .../Views/MakerSessionWorkspace.swift | 18 + .../Views/OnboardingView.swift | 45 +- .../PortableMakerSkillManagementSheet.swift | 418 +++++ .../Views/SharedProductComponents.swift | 120 +- .../AgentPetCompanionCore/PetCoreClient.swift | 4 +- .../AgentConnectionsTests.swift | 4 +- .../OnboardingViewTests.swift | 14 +- .../OverlayGeometryTests.swift | 11 +- .../PortableMakerSkillManagementTests.swift | 160 ++ .../SharedProductComponentsTests.swift | 11 +- .../AgentPetCompanionTests/UIModelTests.swift | 28 +- crates/petcore/src/adapter_contracts.rs | 25 +- crates/petcore/src/agent_state.rs | 22 +- crates/petcore/src/app_server.rs | 67 +- crates/petcore/src/connections.rs | 27 +- crates/petcore/src/daemon.rs | 12 +- crates/petcore/src/db.rs | 16 +- crates/petcore/src/diagnostics.rs | 5 + crates/petcore/src/event_envelope.rs | 1 + crates/petcore/src/lib.rs | 1 + crates/petcore/src/portable_skill.rs | 1575 +++++++++++++++++ crates/petcore/src/rpc.rs | 104 +- .../petcore/tests/agent_state_arbitration.rs | 251 ++- crates/petcore/tests/app_server_transport.rs | 4 +- crates/petcore/tests/connector_contracts.rs | 2 +- crates/petcore/tests/daemon_http_security.rs | 8 +- .../petcore/tests/event_envelope_security.rs | 24 + docs/architecture/data-model.md | 3 +- docs/architecture/overview.md | 6 +- docs/architecture/runtime-and-ipc.md | 2 + docs/development/parallel-development.md | 61 +- docs/development/validation.md | 2 +- docs/integrations/agent-connectors.md | 10 +- docs/release/macos-release.md | 6 +- plugins/connector-privacy.test.mjs | 57 +- plugins/pi/agent-pet-companion.ts.tpl | 45 +- schemas/agent-event.schema.json | 2 +- script/ci_proof_promotion.py | 97 +- script/tests/test_ci_proof_promotion.py | 104 ++ .../test_release_distribution_contracts.py | 6 + script/validate_build_scripts_safety.sh | 5 + script/validate_real_agent_connectors.sh | 4 +- script/validate_test_isolation.sh | 23 +- 57 files changed, 3792 insertions(+), 230 deletions(-) create mode 100644 apps/macos/Sources/AgentPetCompanion/App/PortableMakerSkill.swift create mode 100644 apps/macos/Sources/AgentPetCompanion/Views/PortableMakerSkillManagementSheet.swift create mode 100644 apps/macos/Tests/AgentPetCompanionTests/PortableMakerSkillManagementTests.swift create mode 100644 crates/petcore/src/portable_skill.rs diff --git a/.github/workflows/auto-merge.yml b/.github/workflows/auto-merge.yml index 0e78288..853ed02 100644 --- a/.github/workflows/auto-merge.yml +++ b/.github/workflows/auto-merge.yml @@ -131,8 +131,10 @@ jobs: PR_STATE: ${{ steps.pull_request.outputs.state }} RECOVERED_COMMIT: ${{ steps.pull_request.outputs.merge_commit }} run: | + newly_merged=0 if [[ "$PR_STATE" == "open" ]]; then gh pr merge --repo "$GITHUB_REPOSITORY" --squash --match-head-commit "$HEAD_SHA" "$PR_URL" + newly_merged=1 fi pull="$( gh api \ @@ -149,6 +151,7 @@ jobs: test "$merge_commit" = "$RECOVERED_COMMIT" fi echo "commit=$merge_commit" >>"$GITHUB_OUTPUT" + echo "newly_merged=$newly_merged" >>"$GITHUB_OUTPUT" - name: Dispatch exact merged main commit validation if: steps.pull_request.outputs.base == 'main' @@ -200,3 +203,15 @@ jobs: run: | printf 'Merged the task PR into %s; no post-merge train CI was dispatched.\n' \ "$BASE_BRANCH" >>"$GITHUB_STEP_SUMMARY" + + - name: Delete only the exact newly merged source branch + env: + PR_NUMBER: ${{ steps.pull_request.outputs.number }} + ALLOW_DELETE: ${{ steps.merge.outputs.newly_merged }} + run: | + ./script/ci_proof_promotion.py delete-merged-head \ + --repository "$GITHUB_REPOSITORY" \ + --pull-request-number "$PR_NUMBER" \ + --head-ref "$HEAD_BRANCH" \ + --head-commit "$HEAD_SHA" \ + --allow-delete "$ALLOW_DELETE" diff --git a/.gitignore b/.gitignore index 1da6072..5280a4f 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,6 @@ generation-jobs/ .agent-pet-companion/ tmp/ temp/ + +# Local subagent artifacts +.pi-subagents/ diff --git a/AGENTS.md b/AGENTS.md index 609f5b5..56cc6e7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ docs/ - Treat local `main` as read-only. Choose either a direct PR to `main` for a hotfix/small isolated change or a task PR to the main Agent's shared `gd-ops/train/*` for parallel/cross-component work; the train then opens one final PR to `main`. Follow [parallel development](docs/development/parallel-development.md). - Give every Agent/session an independent `gd-ops/task/*` or `gd-ops/fix/*` branch and worktree. One Agent owns each write path; sub-Agents hand work off by PR and never write directly into the train or another Agent's branch. - The main Agent coordinates a shared train, shared schemas/manifests/version files, changelog-fragment consolidation, dependency order, and the final train PR. Train tasks record user-visible changes under `changes/unreleased/`; direct and final train PRs consume them into root `CHANGELOG.md`. +- Before any handoff, explicitly stage the intended paths, audit the cached diff, commit, require a clean worktree, then push/open and verify the PR. After merge, the coordinator audits every worktree and local branch, fast-forwards local `main` only in a dedicated clean worktree, and removes only owned clean branches whose merged PR and deleted remote ref are proven. Follow the complete [commit and cleanup checklist](docs/development/parallel-development.md). - Prefer typed schemas and structured parsers over ad hoc string parsing. - Keep user-facing text bilingual when it belongs in public documentation or product onboarding. - Record user-visible changes under `[Unreleased]` in root `CHANGELOG.md`; every GitHub Release, tag, and changelog version must match one-to-one. diff --git a/CHANGELOG.md b/CHANGELOG.md index 71172b3..fc37baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,8 +14,18 @@ Keep each user-visible change in its own bullet. Write the English text first, t ## [Unreleased] +### Added / 新增 + +- AI Pet Maker now includes a Universal Pet Maker Skill manager for the provider-neutral `agent-pet-maker` workflow. Users can inspect its requirements and versions, install or adopt it only at `~/agent/skills/agent-pet-maker`, update or reinstall the complete App-managed directory, reveal it in Finder, and explicitly uninstall it. The App does not infer whether an Agent discovers that directory and preserves unowned conflicting content. + + AI 宠物制作现新增面向通用 `agent-pet-maker` 工作流的“通用宠物制作技能”管理器。用户可以查看技能依赖与版本,仅在 `~/agent/skills/agent-pet-maker` 安装或纳入管理,更新或重新安装完整受管目录,在 Finder 中打开目录,并明确卸载;App 不推断 Agent 是否识别该目录,也不会覆盖存在冲突的非受管内容。 + ### Changed / 变更 +- Protected PR delivery now dispatches exact-main validation before bounded source cleanup, then removes a newly merged direct, task, or train source branch with an exact-head atomic lease. Closed-PR replay never deletes a recreated ref, even at the old SHA. Agent guidance now requires an explicit stage, cached-diff audit, commit, clean-worktree, push/PR lifecycle and a repository-wide post-merge worktree/branch audit; Release guidance also recognizes both trusted exact-commit main `push` and `workflow_dispatch` proofs. + + 受保护 PR 交付现在会先派发精确 main 验证,再通过绑定精确 head 的原子 lease 有界清理本次新合并的 direct、task 或 train 源分支;closed PR replay 即使遇到旧 SHA 重建的 ref 也绝不删除。Agent 规范现要求显式暂存、cached diff 审计、提交、clean worktree、推送/PR 的完整生命周期,以及合并后的全仓 worktree/分支审计;Release 规范也统一认可受信任的同 commit main `push` 与 `workflow_dispatch` 证明。 + - Local pre-push validation is now a bounded formatting, syntax, contract, and compile gate, while required GitHub CI runs complete source checks in parallel across Rust shards, Swift interaction proof, integration contracts, overlay validation, stress, and App assembly. Agents choose either a direct hotfix/small PR to protected `main` or coordinated task PRs to one shared train followed by a final train PR; repository-owned ready PRs are squash-merged by a trusted workflow only after `Required CI`. A successful trusted `main` validation emits an exact-commit source proof that GitHub Release reuses before dual-architecture packaging, and an explicit Release dispatch can bind a missing tag only after that proof succeeds. Fail-closed ruleset automation protects both `main` and integration trains after the new required check is proven remotely. Complete direct and final train PR proofs are now promoted to the squash-merged `main` commit when their repository, PR run, ordered parents, artifact, and exact Git tree all match. Run/attempt-bound merge tickets prevent a completed check from being reused after a PR changes base or lane. Each Rust shard now emits its own run/attempt completion marker, and a separate exact-set gate blocks candidate, main, and `Required CI` proof issuance if GitHub's matrix summary omits a shard. The replay-safe post-CI merger explicitly dispatches validation for the exact main SHA, avoiding GitHub's suppression of ordinary events produced by `GITHUB_TOKEN`; control-plane PRs fail closed to trusted manual merge so they cannot rewrite and prove their own gate. This removes the duplicate post-merge Rust, Swift, integration, stress, overlay, and App-assembly run while preserving a main-commit-bound Release proof; promotion uncertainty automatically falls back to complete main CI, and task PRs merged into a train still run only their path-scoped checks. @@ -24,6 +34,28 @@ Keep each user-visible change in its own bullet. Write the English text first, t direct PR 与最终 train PR 的完整证明现可在仓库、PR run、有序父提交、artifact 与精确 Git tree 全部匹配时晋升到 squash 合并后的 `main` commit。绑定 run/attempt 的 merge ticket 会阻止 PR 更换 base 或通道后复用旧检查。每个 Rust 分片现会分别生成绑定 run/attempt 的完成标记,独立的精确集合门禁会在 GitHub 矩阵汇总遗漏任一分片时阻止候选、main 与 `Required CI` 证明签发。可重放恢复的 CI 后合并器会为精确 main SHA 显式派发验证,从而避开 GitHub 对 `GITHUB_TOKEN` 所产生普通事件的抑制;控制面 PR 会 fail closed 转为受信任人工合并,不能重写并自证门禁。此机制删除合并后重复的 Rust、Swift、集成、压力、悬浮层与 App 组装任务,同时保留绑定 main commit 的 Release 证明;晋升存在任何不确定性时都会自动回退完整 main CI,合入 train 的 task PR 仍只运行按路径检查。 +- Grouped and flat desktop session bubbles now retain exactly one body message: the newest Agent reply or explicit thinking/plan text, shown in at most two lines. Tool and other lifecycle activity continue to update the separate top-right status indicator without entering or replacing the body, while navigation notices keep their existing priority. + + 按 Agent 聚合与平铺展示的桌面会话气泡现在都只保留一条正文消息:最近的 Agent 回复或明确的思考/计划文本,最多展示两行。工具及其他生命周期活动仍会更新右上角独立状态标识,但不会进入或替换正文;导航提示继续保持原有优先级。 + +### Fixed / 修复 + +- The first-run demo now hands each pet action to Metal without retaining or cross-fading the prior action's drawable, preventing stalled-looking playback and multiple action frames from appearing together. + + 首次运行演示现在会在各宠物动作之间正确交接 Metal 渲染内容,不再保留或交叉淡化上一动作的 drawable,避免播放看似卡帧及多个动作帧同时出现。 + +- Child and sub-Agent sessions are now consistently excluded from desktop bubbles, including Pi parallel-review sessions whose reserved `subagent-*` identity lacks a `parentSession` header. + + 子会话和子 Agent 会话现在会被一致地排除在桌面气泡之外,包括缺少 `parentSession` 头、但使用保留 `subagent-*` 标识的 Pi parallel-review 会话。 + +- Pi connection checks now accept the current diagnostic canary as proof that the managed Extension loaded, even when the probe process later exits because no model is configured, so the App no longer keeps asking for an irrelevant restart. + + Pi 连接检查现在会将当前诊断 canary 视为受管 Extension 已加载的证据;即使探针进程随后因未配置模型而退出,App 也不会继续提示无关的重启操作。 + +- A Pi prompt submitted without a selected model now reaches the desktop bubble as an immediate failed state instead of remaining indefinitely at session started; the connector uses Pi's public model-presence context without reading provider credentials. + + Pi 未选择模型时提交的消息现在会立即把桌面气泡更新为失败状态,不再无限停留在“会话已开始”;连接器仅使用 Pi 公开的模型存在状态,不读取任何提供商凭据。 + ## [0.3.6] - 2026-08-11 ### Changed / 变更 diff --git a/README.md b/README.md index edff5da..cca9303 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,7 @@ In **AI Pet Maker**, describe the appearance, style, and important traits you wa - In-App creation supports low and standard resolution. - Creation progress is retained, and you can answer questions inside the App when a task needs your input. +- The **Universal Pet Maker Skill** button installs and manages the provider-neutral `agent-pet-maker` workflow at `~/agent/skills/agent-pet-maker`. The App shows its requirements and version but leaves Agent directory discovery for you to confirm. - You can also import compatible `.petpack` V3 packages, including high-resolution packages made by external workflows. - The three bundled pets are read-only defaults. Customizing one creates a new pet instead of replacing the original. diff --git a/README.zh-CN.md b/README.zh-CN.md index 7cbb587..2423582 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -61,6 +61,7 @@ Agent 连接按 Agent 和会话组织,不要求你逐个配置项目目录。A - App 内支持低分辨率和标准分辨率制作。 - 制作任务会保留进度;遇到需要确认的问题时,可以直接在 App 内回复。 +- **通用宠物制作技能**按钮可在 `~/agent/skills/agent-pet-maker` 安装和管理通用 `agent-pet-maker` 工作流。App 会展示依赖与版本,是否能发现该目录则由用户根据 Agent 自行确认。 - 也可以导入符合 `.petpack` V3 规范的宠物包,包括由外部流程制作的高清宠物包。 - 三个内置宠物是只读默认资源;修改它们时会创建一个新的自定义宠物,不会覆盖原版。 diff --git a/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift b/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift index 0f4f0cb..a739caf 100644 --- a/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift +++ b/apps/macos/Sources/AgentPetCompanion/App/AppStore.swift @@ -743,6 +743,9 @@ final class AppStore: ObservableObject { @Published private(set) var petLibraryNotice: PetLibraryNotice? @Published private(set) var diagnosticsExportState = DiagnosticsExportState.idle @Published private(set) var connectionOperationState = AgentConnectionOperationState.idle + @Published private(set) var portableMakerSkillStatus: PortableMakerSkillStatus? + @Published private(set) var portableMakerSkillOperation = PortableMakerSkillOperation.idle + @Published private(set) var portableMakerSkillFailure: PortableMakerSkillFailure? @Published private(set) var manualAppInstallationRequest: AppManualInstallationRequest? @Published private(set) var appUpdateConvergenceState = AppUpdateConvergenceState.idle @@ -5466,6 +5469,66 @@ final class AppStore: ObservableObject { launchConnectionOperation(.init(kind: .repair, sources: [source])) } + func refreshPortableMakerSkillStatus() async { + guard !portableMakerSkillOperation.isBusy else { return } + portableMakerSkillOperation = .checking + portableMakerSkillFailure = nil + defer { portableMakerSkillOperation = .idle } + do { + portableMakerSkillStatus = try await requestPortableMakerSkill( + method: "portable_skill.status" + ) + } catch { + portableMakerSkillFailure = .load + } + } + + func installPortableMakerSkill() async { + guard !portableMakerSkillOperation.isBusy else { return } + portableMakerSkillOperation = .installing + portableMakerSkillFailure = nil + defer { portableMakerSkillOperation = .idle } + do { + portableMakerSkillStatus = try await requestPortableMakerSkill( + method: "portable_skill.install" + ) + } catch { + portableMakerSkillFailure = .install + } + } + + func uninstallPortableMakerSkill() async { + guard !portableMakerSkillOperation.isBusy else { return } + portableMakerSkillOperation = .uninstalling + portableMakerSkillFailure = nil + defer { portableMakerSkillOperation = .idle } + do { + portableMakerSkillStatus = try await requestPortableMakerSkill( + method: "portable_skill.uninstall" + ) + } catch { + portableMakerSkillFailure = .uninstall + } + } + + private func requestPortableMakerSkill( + method: String + ) async throws -> PortableMakerSkillStatus { + let result = try await requestPetCore( + method: method, + timeout: .seconds(180) + ) + let data = try JSONSerialization.data(withJSONObject: result) + let status = try JSONDecoder().decode(PortableMakerSkillStatus.self, from: data) + guard status.schemaVersion == "apc.portable-skill-status.v1", + status.name == "agent-pet-maker", + status.targetDisplayPath == "~/agent/skills/agent-pet-maker" + else { + throw PetCoreClientError.invalidResponse + } + return status + } + func repairConnections(_ sources: [AgentSource]) { launchConnectionOperation(.init(kind: .repair, sources: sources)) } @@ -7184,6 +7247,8 @@ final class AppStore: ObservableObject { || method.hasPrefix("pet.") || method.hasPrefix("petpack.") || method.hasPrefix("connections.") + || method == "portable_skill.install" + || method == "portable_skill.uninstall" || method == "behavior.patch" || method == "onboarding.update" || method == "overlay.placement.update" @@ -7213,6 +7278,8 @@ final class AppStore: ObservableObject { "connections.repair", "connections.uninstall", "connections.refresh_installed", + "portable_skill.install", + "portable_skill.uninstall", "product.convergence.update": true default: diff --git a/apps/macos/Sources/AgentPetCompanion/App/Localization.swift b/apps/macos/Sources/AgentPetCompanion/App/Localization.swift index 68cec58..79937fc 100644 --- a/apps/macos/Sources/AgentPetCompanion/App/Localization.swift +++ b/apps/macos/Sources/AgentPetCompanion/App/Localization.swift @@ -328,6 +328,47 @@ enum APCLocalizationKey: String, CaseIterable, Sendable { case studioActionRestart = "studio.action.restart" case studioActionStart = "studio.action.start" case studioActionNew = "studio.action.new" + case studioPortableSkillAction = "studio.portable_skill.action" + case studioPortableSkillTitle = "studio.portable_skill.title" + case studioPortableSkillSubtitle = "studio.portable_skill.subtitle" + case studioPortableSkillAboutTitle = "studio.portable_skill.about.title" + case studioPortableSkillAboutDetail = "studio.portable_skill.about.detail" + case studioPortableSkillCompatibilityTitle = "studio.portable_skill.compatibility.title" + case studioPortableSkillCompatibilityDetail = "studio.portable_skill.compatibility.detail" + case studioPortableSkillInstallationTitle = "studio.portable_skill.installation.title" + case studioPortableSkillLocationLabel = "studio.portable_skill.location" + case studioPortableSkillBundledVersionLabel = "studio.portable_skill.version.bundled" + case studioPortableSkillInstalledVersionLabel = "studio.portable_skill.version.installed" + case studioPortableSkillNotInstalled = "studio.portable_skill.version.not_installed" + case studioPortableSkillChecking = "studio.portable_skill.checking" + case studioPortableSkillCheckingDetail = "studio.portable_skill.checking.detail" + case studioPortableSkillManagedTitle = "studio.portable_skill.managed.title" + case studioPortableSkillManagedDetail = "studio.portable_skill.managed.detail" + case studioPortableSkillStateMissing = "studio.portable_skill.state.missing" + case studioPortableSkillStateMissingDetail = "studio.portable_skill.state.missing.detail" + case studioPortableSkillStateCurrent = "studio.portable_skill.state.current" + case studioPortableSkillStateCurrentDetail = "studio.portable_skill.state.current.detail" + case studioPortableSkillStateUpdateAvailable = "studio.portable_skill.state.update_available" + case studioPortableSkillStateUpdateAvailableDetail = "studio.portable_skill.state.update_available.detail" + case studioPortableSkillStateNeedsReinstall = "studio.portable_skill.state.needs_reinstall" + case studioPortableSkillStateNeedsReinstallDetail = "studio.portable_skill.state.needs_reinstall.detail" + case studioPortableSkillStateUnmanagedCurrent = "studio.portable_skill.state.unmanaged_current" + case studioPortableSkillStateUnmanagedCurrentDetail = "studio.portable_skill.state.unmanaged_current.detail" + case studioPortableSkillStateConflict = "studio.portable_skill.state.conflict" + case studioPortableSkillStateConflictDetail = "studio.portable_skill.state.conflict.detail" + case studioPortableSkillActionInstall = "studio.portable_skill.action.install" + case studioPortableSkillActionManage = "studio.portable_skill.action.manage" + case studioPortableSkillActionUpdate = "studio.portable_skill.action.update" + case studioPortableSkillActionReinstall = "studio.portable_skill.action.reinstall" + case studioPortableSkillActionUninstall = "studio.portable_skill.action.uninstall" + case studioPortableSkillActionOpenFolder = "studio.portable_skill.action.open_folder" + case studioPortableSkillActionRefresh = "studio.portable_skill.action.refresh" + case studioPortableSkillErrorTitle = "studio.portable_skill.error.title" + case studioPortableSkillErrorLoad = "studio.portable_skill.error.load" + case studioPortableSkillErrorInstall = "studio.portable_skill.error.install" + case studioPortableSkillErrorUninstall = "studio.portable_skill.error.uninstall" + case studioPortableSkillUninstallConfirmTitle = "studio.portable_skill.uninstall_confirm.title" + case studioPortableSkillUninstallConfirmDetail = "studio.portable_skill.uninstall_confirm.detail" case studioPageTitle = "studio.page.title" case studioPageModifyFormat = "studio.page.modify_format" case studioPageModifySession = "studio.page.modify_session" diff --git a/apps/macos/Sources/AgentPetCompanion/App/PortableMakerSkill.swift b/apps/macos/Sources/AgentPetCompanion/App/PortableMakerSkill.swift new file mode 100644 index 0000000..27e1790 --- /dev/null +++ b/apps/macos/Sources/AgentPetCompanion/App/PortableMakerSkill.swift @@ -0,0 +1,55 @@ +import Foundation + +enum PortableMakerSkillState: String, Decodable, Equatable, Sendable { + case missing + case current + case updateAvailable = "update_available" + case needsReinstall = "needs_reinstall" + case unmanagedCurrent = "unmanaged_current" + case conflict +} + +struct PortableMakerSkillStatus: Decodable, Equatable, Sendable { + let schemaVersion: String + let name: String + let state: PortableMakerSkillState + let targetDisplayPath: String + let expectedVersion: String + let installedVersion: String? + let managed: Bool + let targetExists: Bool + let canInstall: Bool + let canUpdate: Bool + let canReinstall: Bool + let canUninstall: Bool + + enum CodingKeys: String, CodingKey { + case schemaVersion = "schema_version" + case name + case state + case targetDisplayPath = "target_display_path" + case expectedVersion = "expected_version" + case installedVersion = "installed_version" + case managed + case targetExists = "target_exists" + case canInstall = "can_install" + case canUpdate = "can_update" + case canReinstall = "can_reinstall" + case canUninstall = "can_uninstall" + } +} + +enum PortableMakerSkillOperation: Equatable { + case idle + case checking + case installing + case uninstalling + + var isBusy: Bool { self != .idle } +} + +enum PortableMakerSkillFailure: Equatable { + case load + case install + case uninstall +} diff --git a/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayGeometry.swift b/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayGeometry.swift index e541cfe..5f94348 100644 --- a/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayGeometry.swift +++ b/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayGeometry.swift @@ -1264,6 +1264,21 @@ enum OverlayGeometry { static let bubbleDetailLineLimit = 2 static let bubbleStandaloneSummaryLineLimit = 2 static let bubbleStandaloneMetadataSpacing: CGFloat = 3 + static func bubbleStandaloneMetadataHeight( + fontScale: BubbleFontScale = .standard + ) -> CGFloat { + max( + bubbleHeaderAvatarWidth, + ceil(lineHeight(for: OverlayBubbleTypography.measurementFont( + .caption1, + scale: fontScale + ))), + ceil(lineHeight(for: OverlayBubbleTypography.measurementFont( + .caption2, + scale: fontScale + ))) + 2 + ) + } static func bubbleDetailTextHeight( fontScale: BubbleFontScale = .standard ) -> CGFloat { @@ -2369,16 +2384,9 @@ enum OverlayGeometry { fontScale: BubbleFontScale ) -> CGFloat { if isStandaloneSessionCard { - let metadataHeight = max( - bubbleHeaderAvatarWidth, - lineHeight(for: OverlayBubbleTypography.measurementFont( - .caption1, - scale: fontScale - )) - ) return ceil( bubbleSessionVerticalPadding * 2 - + metadataHeight + + bubbleStandaloneMetadataHeight(fontScale: fontScale) + bubbleStandaloneMetadataSpacing + bubbleStandaloneSummaryTextHeight(fontScale: fontScale) ) @@ -2682,23 +2690,14 @@ struct OverlaySessionContent: Equatable, Identifiable { APCLocalizedPresentation.sessionSurfaceTitle($0, locale: locale) } } - var secondaryDetailText: String? { - let details = detailCandidates - guard let primary = details.first else { return nil } - return details.dropFirst().first { - Self.normalizedText($0) != Self.normalizedText(primary) - } - } var primaryDetailText: String { detailCandidates.first ?? "" } var standaloneSummaryText: String { - primaryDetailText.isEmpty ? statusText : primaryDetailText + primaryDetailText } var detailText: String { - [primaryDetailText, secondaryDetailText] - .compactMap { $0 } - .joined(separator: "\n") + primaryDetailText } var accessibilityReadingOrder: [String] { [ @@ -2707,7 +2706,6 @@ struct OverlaySessionContent: Equatable, Identifiable { sessionTitle, statusText, primaryDetailText, - secondaryDetailText, accessibilityFallbackDetailText, actionLabel, ] @@ -2717,11 +2715,16 @@ struct OverlaySessionContent: Equatable, Identifiable { accessibilityReadingOrder.joined(separator: ", ") } private var detailCandidates: [String] { - [ - navigationNotice?.localizedText(), - Self.compactMessage(messageText), - Self.compactMessage(activityText), - ].compactMap { $0 } + if let navigationNotice { + return [navigationNotice.localizedText()] + } + if let message = Self.compactMessage(messageText) { + return [message] + } + if let activity = Self.compactMessage(activityText) { + return [activity] + } + return [] } /// Preserves a complete VoiceOver state-to-action reading order when /// PetCore intentionally omits displayable session copy. This text is diff --git a/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayValidationContract.swift b/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayValidationContract.swift index 6a632be..2bb9de4 100644 --- a/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayValidationContract.swift +++ b/apps/macos/Sources/AgentPetCompanion/Overlay/OverlayValidationContract.swift @@ -272,9 +272,15 @@ public enum AgentPetCompanionUIValidationContract { "active bubble did not render the bounded current activity detail" ) try require( - session.detailText.contains(session.activityText) - && session.detailText.contains(session.messageText), - "active bubble did not keep activity and assistant message together" + session.detailText == session.messageText, + "active bubble rendered more than one body message" + ) + var navigationFailure = session + navigationFailure.navigationNotice = .failed + try require( + navigationFailure.detailText + == APCLocalization.text(.overlaySessionNavigationFailed), + "navigation feedback no longer replaced ordinary bubble body copy" ) try require( content.agentName == "Codex", @@ -536,10 +542,9 @@ public enum AgentPetCompanionUIValidationContract { "Session exact", APCLocalizedPresentation.lifecycleTitle(.waiting), "A response is required", - APCLocalization.text(.overlayDetailNeedsInput), exact.actionLabel, ], - "VoiceOver order was not Agent, surface, session, status, message, activity, action" + "VoiceOver order was not Agent, surface, session, status, visible message, action" ) } diff --git a/apps/macos/Sources/AgentPetCompanion/Resources/Localizable.xcstrings b/apps/macos/Sources/AgentPetCompanion/Resources/Localizable.xcstrings index d55078c..e72a875 100644 --- a/apps/macos/Sources/AgentPetCompanion/Resources/Localizable.xcstrings +++ b/apps/macos/Sources/AgentPetCompanion/Resources/Localizable.xcstrings @@ -13365,6 +13365,252 @@ } } }, + "studio.portable_skill.action": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Universal Pet Maker Skill" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "通用宠物制作技能" } } + } + }, + "studio.portable_skill.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Universal Pet Maker Skill" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "通用宠物制作技能" } } + } + }, + "studio.portable_skill.subtitle": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Install and manage Agent Pet Maker for compatible agents." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "为兼容的 Agent 安装和管理 Agent Pet Maker" } } + } + }, + "studio.portable_skill.about.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Create pets from any compatible agent" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "在兼容的 Agent 中制作宠物" } } + } + }, + "studio.portable_skill.about.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Agent Pet Maker creates or revises validated nine-action pet packages with an agent that can generate and inspect images and run the required local tools." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "Agent Pet Maker 可通过具备图片生成与检查能力、并能运行所需本地工具的 Agent,创建或修改经过验证的九动作宠物包。" } } + } + }, + "studio.portable_skill.compatibility.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Requirements are documented inside the Skill" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "依赖要求详见技能内说明" } } + } + }, + "studio.portable_skill.compatibility.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Its preflight checks Python, Pillow, PetCore CLI, and media support. The App does not determine whether an agent discovers ~/agent/skills; confirm that with your agent." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "技能预检会检查 Python、Pillow、PetCore CLI 与媒体支持。App 不判断 Agent 是否识别 ~/agent/skills,请由你自行确认。" } } + } + }, + "studio.portable_skill.installation.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Installation" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "安装信息" } } + } + }, + "studio.portable_skill.location": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Location" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "安装位置" } } + } + }, + "studio.portable_skill.version.bundled": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Bundled version" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "App 内置版本" } } + } + }, + "studio.portable_skill.version.installed": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Installed version" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已安装版本" } } + } + }, + "studio.portable_skill.version.not_installed": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Not installed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "未安装" } } + } + }, + "studio.portable_skill.checking": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Checking" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在检查" } } + } + }, + "studio.portable_skill.checking.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reading the fixed Skill location and App management receipt." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "正在读取固定技能目录与 App 管理回执。" } } + } + }, + "studio.portable_skill.managed.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "App-managed content" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "App 受管内容" } } + } + }, + "studio.portable_skill.managed.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "When you update, reinstall, or uninstall, the App directly replaces or removes the entire managed agent-pet-maker directory, including local changes inside it." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "更新、重新安装或卸载时,App 会直接替换或删除整个受管 agent-pet-maker 目录,其中的本地修改也会一并处理。" } } + } + }, + "studio.portable_skill.state.missing": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Not installed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "尚未安装" } } + } + }, + "studio.portable_skill.state.missing.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Install the complete Skill at the fixed location to make it available to agents that discover that directory." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "将完整技能安装到固定位置后,能够发现该目录的 Agent 即可使用。" } } + } + }, + "studio.portable_skill.state.current": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Up to date" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已是最新版本" } } + } + }, + "studio.portable_skill.state.current.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The managed Skill matches the version bundled with this App." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "受管技能与当前 App 内置版本完全一致。" } } + } + }, + "studio.portable_skill.state.update_available": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Update available" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "有可用更新" } } + } + }, + "studio.portable_skill.state.update_available.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The installed Skill differs from the bundled version. Updating replaces the entire managed directory." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "已安装技能与 App 内置版本不同;更新会直接替换整个受管目录。" } } + } + }, + "studio.portable_skill.state.needs_reinstall": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reinstall recommended" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "建议重新安装" } } + } + }, + "studio.portable_skill.state.needs_reinstall.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The managed Skill is incomplete or changed. Reinstall it to restore the bundled content." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "受管技能不完整或内容已变化,可重新安装以恢复 App 内置内容。" } } + } + }, + "studio.portable_skill.state.unmanaged_current": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Ready to manage" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "可以纳入管理" } } + } + }, + "studio.portable_skill.state.unmanaged_current.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The existing files exactly match the bundled Skill but are not yet App-managed. You can adopt them safely." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "现有文件与 App 内置技能完全一致,但尚未受管,可以安全纳入 App 管理。" } } + } + }, + "studio.portable_skill.state.conflict": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Location conflict" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "安装位置冲突" } } + } + }, + "studio.portable_skill.state.conflict.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The fixed location contains external content or has an unsafe parent. The App will not overwrite it because it is not App-managed." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "固定位置包含外部内容或父目录不安全。由于它不受 App 管理,App 不会覆盖。" } } + } + }, + "studio.portable_skill.action.install": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Install Skill" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "安装技能" } } + } + }, + "studio.portable_skill.action.manage": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Manage with App" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "由 App 管理" } } + } + }, + "studio.portable_skill.action.update": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Update Skill" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "更新技能" } } + } + }, + "studio.portable_skill.action.reinstall": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Reinstall" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "重新安装" } } + } + }, + "studio.portable_skill.action.uninstall": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Uninstall" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "卸载" } } + } + }, + "studio.portable_skill.action.open_folder": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Open Folder" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "打开目录" } } + } + }, + "studio.portable_skill.action.refresh": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Refresh" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "刷新" } } + } + }, + "studio.portable_skill.error.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Skill operation failed" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "技能操作失败" } } + } + }, + "studio.portable_skill.error.load": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Skill status could not be loaded. Check the local service, then try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法读取技能状态,请检查本地服务后重试。" } } + } + }, + "studio.portable_skill.error.install": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The Skill could not be installed or replaced. Review the location status, then try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法安装或替换技能,请检查安装位置状态后重试。" } } + } + }, + "studio.portable_skill.error.uninstall": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "The managed Skill could not be removed. Refresh its status, then try again." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "无法删除受管技能,请刷新状态后重试。" } } + } + }, + "studio.portable_skill.uninstall_confirm.title": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "Uninstall Agent Pet Maker?" } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "卸载 Agent Pet Maker?" } } + } + }, + "studio.portable_skill.uninstall_confirm.detail": { + "localizations": { + "en": { "stringUnit": { "state": "translated", "value": "This removes the entire App-managed ~/agent/skills/agent-pet-maker directory, including local changes inside it." } }, + "zh-Hans": { "stringUnit": { "state": "translated", "value": "这会删除整个 App 受管的 ~/agent/skills/agent-pet-maker 目录,其中的本地修改也会一并删除。" } } + } + }, "studio.action.start": { "localizations": { "en": { diff --git a/apps/macos/Sources/AgentPetCompanion/Resources/en.lproj/Localizable.strings b/apps/macos/Sources/AgentPetCompanion/Resources/en.lproj/Localizable.strings index 1683876..ba8eed0 100644 --- a/apps/macos/Sources/AgentPetCompanion/Resources/en.lproj/Localizable.strings +++ b/apps/macos/Sources/AgentPetCompanion/Resources/en.lproj/Localizable.strings @@ -166,6 +166,47 @@ "studio.action.restart" = "Start Over"; "studio.action.start" = "Create Pet"; "studio.action.new" = "Create New Pet"; +"studio.portable_skill.action" = "Universal Pet Maker Skill"; +"studio.portable_skill.title" = "Universal Pet Maker Skill"; +"studio.portable_skill.subtitle" = "Install and manage Agent Pet Maker for compatible agents."; +"studio.portable_skill.about.title" = "Create pets from any compatible agent"; +"studio.portable_skill.about.detail" = "Agent Pet Maker creates or revises validated nine-action pet packages with an agent that can generate and inspect images and run the required local tools."; +"studio.portable_skill.compatibility.title" = "Requirements are documented inside the Skill"; +"studio.portable_skill.compatibility.detail" = "Its preflight checks Python, Pillow, PetCore CLI, and media support. The App does not determine whether an agent discovers ~/agent/skills; confirm that with your agent."; +"studio.portable_skill.installation.title" = "Installation"; +"studio.portable_skill.location" = "Location"; +"studio.portable_skill.version.bundled" = "Bundled version"; +"studio.portable_skill.version.installed" = "Installed version"; +"studio.portable_skill.version.not_installed" = "Not installed"; +"studio.portable_skill.checking" = "Checking"; +"studio.portable_skill.checking.detail" = "Reading the fixed Skill location and App management receipt."; +"studio.portable_skill.managed.title" = "App-managed content"; +"studio.portable_skill.managed.detail" = "When you update, reinstall, or uninstall, the App directly replaces or removes the entire managed agent-pet-maker directory, including local changes inside it."; +"studio.portable_skill.state.missing" = "Not installed"; +"studio.portable_skill.state.missing.detail" = "Install the complete Skill at the fixed location to make it available to agents that discover that directory."; +"studio.portable_skill.state.current" = "Up to date"; +"studio.portable_skill.state.current.detail" = "The managed Skill matches the version bundled with this App."; +"studio.portable_skill.state.update_available" = "Update available"; +"studio.portable_skill.state.update_available.detail" = "The installed Skill differs from the bundled version. Updating replaces the entire managed directory."; +"studio.portable_skill.state.needs_reinstall" = "Reinstall recommended"; +"studio.portable_skill.state.needs_reinstall.detail" = "The managed Skill is incomplete or changed. Reinstall it to restore the bundled content."; +"studio.portable_skill.state.unmanaged_current" = "Ready to manage"; +"studio.portable_skill.state.unmanaged_current.detail" = "The existing files exactly match the bundled Skill but are not yet App-managed. You can adopt them safely."; +"studio.portable_skill.state.conflict" = "Location conflict"; +"studio.portable_skill.state.conflict.detail" = "The fixed location contains external content or has an unsafe parent. The App will not overwrite it because it is not App-managed."; +"studio.portable_skill.action.install" = "Install Skill"; +"studio.portable_skill.action.manage" = "Manage with App"; +"studio.portable_skill.action.update" = "Update Skill"; +"studio.portable_skill.action.reinstall" = "Reinstall"; +"studio.portable_skill.action.uninstall" = "Uninstall"; +"studio.portable_skill.action.open_folder" = "Open Folder"; +"studio.portable_skill.action.refresh" = "Refresh"; +"studio.portable_skill.error.title" = "Skill operation failed"; +"studio.portable_skill.error.load" = "The Skill status could not be loaded. Check the local service, then try again."; +"studio.portable_skill.error.install" = "The Skill could not be installed or replaced. Review the location status, then try again."; +"studio.portable_skill.error.uninstall" = "The managed Skill could not be removed. Refresh its status, then try again."; +"studio.portable_skill.uninstall_confirm.title" = "Uninstall Agent Pet Maker?"; +"studio.portable_skill.uninstall_confirm.detail" = "This removes the entire App-managed ~/agent/skills/agent-pet-maker directory, including local changes inside it."; "studio.page.title" = "AI Pet Maker"; "studio.page.modify_format" = "Revise %@"; "studio.page.modify_session" = "AI Revision Session"; diff --git a/apps/macos/Sources/AgentPetCompanion/Resources/zh-Hans.lproj/Localizable.strings b/apps/macos/Sources/AgentPetCompanion/Resources/zh-Hans.lproj/Localizable.strings index e9433ed..9e4416b 100644 --- a/apps/macos/Sources/AgentPetCompanion/Resources/zh-Hans.lproj/Localizable.strings +++ b/apps/macos/Sources/AgentPetCompanion/Resources/zh-Hans.lproj/Localizable.strings @@ -166,6 +166,47 @@ "studio.action.restart" = "重新开始"; "studio.action.start" = "开始制作"; "studio.action.new" = "制作新宠物"; +"studio.portable_skill.action" = "通用宠物制作技能"; +"studio.portable_skill.title" = "通用宠物制作技能"; +"studio.portable_skill.subtitle" = "为兼容的 Agent 安装和管理 Agent Pet Maker"; +"studio.portable_skill.about.title" = "在兼容的 Agent 中制作宠物"; +"studio.portable_skill.about.detail" = "Agent Pet Maker 可通过具备图片生成与检查能力、并能运行所需本地工具的 Agent,创建或修改经过验证的九动作宠物包。"; +"studio.portable_skill.compatibility.title" = "依赖要求详见技能内说明"; +"studio.portable_skill.compatibility.detail" = "技能预检会检查 Python、Pillow、PetCore CLI 与媒体支持。App 不判断 Agent 是否识别 ~/agent/skills,请由你自行确认。"; +"studio.portable_skill.installation.title" = "安装信息"; +"studio.portable_skill.location" = "安装位置"; +"studio.portable_skill.version.bundled" = "App 内置版本"; +"studio.portable_skill.version.installed" = "已安装版本"; +"studio.portable_skill.version.not_installed" = "未安装"; +"studio.portable_skill.checking" = "正在检查"; +"studio.portable_skill.checking.detail" = "正在读取固定技能目录与 App 管理回执。"; +"studio.portable_skill.managed.title" = "App 受管内容"; +"studio.portable_skill.managed.detail" = "更新、重新安装或卸载时,App 会直接替换或删除整个受管 agent-pet-maker 目录,其中的本地修改也会一并处理。"; +"studio.portable_skill.state.missing" = "尚未安装"; +"studio.portable_skill.state.missing.detail" = "将完整技能安装到固定位置后,能够发现该目录的 Agent 即可使用。"; +"studio.portable_skill.state.current" = "已是最新版本"; +"studio.portable_skill.state.current.detail" = "受管技能与当前 App 内置版本完全一致。"; +"studio.portable_skill.state.update_available" = "有可用更新"; +"studio.portable_skill.state.update_available.detail" = "已安装技能与 App 内置版本不同;更新会直接替换整个受管目录。"; +"studio.portable_skill.state.needs_reinstall" = "建议重新安装"; +"studio.portable_skill.state.needs_reinstall.detail" = "受管技能不完整或内容已变化,可重新安装以恢复 App 内置内容。"; +"studio.portable_skill.state.unmanaged_current" = "可以纳入管理"; +"studio.portable_skill.state.unmanaged_current.detail" = "现有文件与 App 内置技能完全一致,但尚未受管,可以安全纳入 App 管理。"; +"studio.portable_skill.state.conflict" = "安装位置冲突"; +"studio.portable_skill.state.conflict.detail" = "固定位置包含外部内容或父目录不安全。由于它不受 App 管理,App 不会覆盖。"; +"studio.portable_skill.action.install" = "安装技能"; +"studio.portable_skill.action.manage" = "由 App 管理"; +"studio.portable_skill.action.update" = "更新技能"; +"studio.portable_skill.action.reinstall" = "重新安装"; +"studio.portable_skill.action.uninstall" = "卸载"; +"studio.portable_skill.action.open_folder" = "打开目录"; +"studio.portable_skill.action.refresh" = "刷新"; +"studio.portable_skill.error.title" = "技能操作失败"; +"studio.portable_skill.error.load" = "无法读取技能状态,请检查本地服务后重试。"; +"studio.portable_skill.error.install" = "无法安装或替换技能,请检查安装位置状态后重试。"; +"studio.portable_skill.error.uninstall" = "无法删除受管技能,请刷新状态后重试。"; +"studio.portable_skill.uninstall_confirm.title" = "卸载 Agent Pet Maker?"; +"studio.portable_skill.uninstall_confirm.detail" = "这会删除整个 App 受管的 ~/agent/skills/agent-pet-maker 目录,其中的本地修改也会一并删除。"; "studio.page.title" = "AI 宠物制作"; "studio.page.modify_format" = "修改 %@"; "studio.page.modify_session" = "AI 修改会话"; diff --git a/apps/macos/Sources/AgentPetCompanion/Views/MakerSessionWorkspace.swift b/apps/macos/Sources/AgentPetCompanion/Views/MakerSessionWorkspace.swift index 7bcdbc5..2a80a68 100644 --- a/apps/macos/Sources/AgentPetCompanion/Views/MakerSessionWorkspace.swift +++ b/apps/macos/Sources/AgentPetCompanion/Views/MakerSessionWorkspace.swift @@ -63,6 +63,7 @@ private struct MakerSplitDividerAnchorKey: PreferenceKey { private struct MakerWorkspaceHeader: View { @EnvironmentObject private var store: AppStore + @State private var portableSkillManagerPresented = false private var unfinishedJob: GenerationStudioHistoryRecord? { store.generationHistorySnapshot.jobs.first(where: MakerSessionPolicy.isUnfinished) @@ -76,6 +77,19 @@ private struct MakerWorkspaceHeader: View { summary: APCLocalization.text(.studioWorkspacePageSubtitle) ) + Button { + portableSkillManagerPresented = true + } label: { + Label( + APCLocalization.text(.studioPortableSkillAction), + systemImage: "wand.and.stars" + ) + } + .buttonStyle(.bordered) + .controlSize(.large) + .help(APCLocalization.text(.studioPortableSkillSubtitle)) + .accessibilityIdentifier("maker.portable-skill.open") + Button { store.beginMakerDraft() } label: { @@ -92,6 +106,10 @@ private struct MakerWorkspaceHeader: View { .padding(.bottom, 16) .accessibilityElement(children: .contain) .accessibilityIdentifier("maker.workspace.header") + .sheet(isPresented: $portableSkillManagerPresented) { + PortableMakerSkillManagementSheet() + .environmentObject(store) + } } private var newTaskHelp: String { diff --git a/apps/macos/Sources/AgentPetCompanion/Views/OnboardingView.swift b/apps/macos/Sources/AgentPetCompanion/Views/OnboardingView.swift index 7ad7e81..1187d9d 100644 --- a/apps/macos/Sources/AgentPetCompanion/Views/OnboardingView.swift +++ b/apps/macos/Sources/AgentPetCompanion/Views/OnboardingView.swift @@ -858,7 +858,24 @@ private struct OnboardingPetAnimationPreview: View { let assetWarning: PetAssetWarning? let lifecycle: ProductLifecycleState - @State private var contentState = PetPreviewContentState() + @State private var contentState: PetPreviewContentState + + init( + pet: PetSummary, + assetWarning: PetAssetWarning?, + lifecycle: ProductLifecycleState + ) { + self.pet = pet + self.assetWarning = assetWarning + self.lifecycle = lifecycle + _contentState = State(initialValue: PetPreviewContentState( + selectedIdentity: PetPreviewRenderIdentity( + pet: pet, + stateName: lifecycle.rawValue, + assetWarning: assetWarning + ) + )) + } var body: some View { let identity = PetPreviewRenderIdentity( @@ -866,14 +883,8 @@ private struct OnboardingPetAnimationPreview: View { stateName: lifecycle.rawValue, assetWarning: assetWarning ) + let rendererHasContent = contentState.hasPresentedContent(for: identity) ZStack { - PetActionFallbackImage( - pet: pet, - stateName: lifecycle.rawValue, - assetWarning: assetWarning, - fallbackScale: 0.44 - ) - .opacity(contentState.hasPresentedContent(for: identity) ? 0 : 1) if PetLibraryPreviewPolicy.canRender( assetWarning: assetWarning ) { @@ -885,9 +896,27 @@ private struct OnboardingPetAnimationPreview: View { } .id(identity) } + + if !rendererHasContent { + Color(nsColor: .textBackgroundColor) + .overlay { + PetActionFallbackImage( + pet: pet, + stateName: lifecycle.rawValue, + assetWarning: assetWarning, + fallbackScale: 0.44 + ) + } + } + } + .transaction { transaction in + transaction.animation = nil } .clipped() .allowsHitTesting(false) + .onChange(of: identity) { _, nextIdentity in + contentState.select(nextIdentity) + } } } diff --git a/apps/macos/Sources/AgentPetCompanion/Views/PortableMakerSkillManagementSheet.swift b/apps/macos/Sources/AgentPetCompanion/Views/PortableMakerSkillManagementSheet.swift new file mode 100644 index 0000000..bab3c45 --- /dev/null +++ b/apps/macos/Sources/AgentPetCompanion/Views/PortableMakerSkillManagementSheet.swift @@ -0,0 +1,418 @@ +import AppKit +import SwiftUI + +struct PortableMakerSkillManagementSheet: View { + @EnvironmentObject private var store: AppStore + @Environment(\.dismiss) private var dismiss + @State private var confirmingUninstall = false + + var body: some View { + VStack(spacing: 0) { + sheetHeader + Divider() + + ScrollView { + VStack(alignment: .leading, spacing: 18) { + overviewCard + installationCard + statusNotice + if store.portableMakerSkillStatus?.managed == true { + managedContentNotice + } + } + .padding(24) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + + Divider() + actionBar + } + .frame(width: 640) + .frame(minHeight: 520, idealHeight: 570, maxHeight: 680) + .task { + await store.refreshPortableMakerSkillStatus() + } + .confirmationDialog( + APCLocalization.text(.studioPortableSkillUninstallConfirmTitle), + isPresented: $confirmingUninstall, + titleVisibility: .visible + ) { + Button( + APCLocalization.text(.studioPortableSkillActionUninstall), + role: .destructive + ) { + Task { await store.uninstallPortableMakerSkill() } + } + Button(APCLocalization.text(.commonCancel), role: .cancel) {} + } message: { + Text(APCLocalization.text(.studioPortableSkillUninstallConfirmDetail)) + } + .interactiveDismissDisabled(store.portableMakerSkillOperation.isBusy) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("maker.portable-skill.sheet") + } + + private var sheetHeader: some View { + HStack(spacing: 14) { + Image(systemName: "wand.and.stars.inverse") + .font(.title2.weight(.semibold)) + .foregroundStyle(.white) + .frame(width: 42, height: 42) + .background( + LinearGradient( + colors: [.blue, .purple], + startPoint: .topLeading, + endPoint: .bottomTrailing + ), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .accessibilityHidden(true) + + VStack(alignment: .leading, spacing: 3) { + Text(APCLocalization.text(.studioPortableSkillTitle)) + .font(.title3.weight(.semibold)) + Text(APCLocalization.text(.studioPortableSkillSubtitle)) + .font(.callout) + .foregroundStyle(.secondary) + } + + Spacer() + statusPill + + Button { + dismiss() + } label: { + Image(systemName: "xmark") + } + .buttonStyle(.borderless) + .disabled(store.portableMakerSkillOperation.isBusy) + .help(APCLocalization.text(.commonClose)) + .accessibilityLabel(APCLocalization.text(.commonClose)) + .accessibilityIdentifier("maker.portable-skill.close") + } + .padding(.horizontal, 22) + .padding(.vertical, 18) + } + + private var overviewCard: some View { + VStack(alignment: .leading, spacing: 12) { + Label( + APCLocalization.text(.studioPortableSkillAboutTitle), + systemImage: "sparkles" + ) + .font(.headline) + + Text(APCLocalization.text(.studioPortableSkillAboutDetail)) + .font(.callout) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + + Divider() + + Label { + VStack(alignment: .leading, spacing: 3) { + Text(APCLocalization.text(.studioPortableSkillCompatibilityTitle)) + .font(.callout.weight(.semibold)) + Text(APCLocalization.text(.studioPortableSkillCompatibilityDetail)) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } icon: { + Image(systemName: "checklist") + .foregroundStyle(.blue) + } + } + .portableSkillCard() + } + + private var installationCard: some View { + VStack(alignment: .leading, spacing: 12) { + Text(APCLocalization.text(.studioPortableSkillInstallationTitle)) + .font(.headline) + + LabeledContent(APCLocalization.text(.studioPortableSkillLocationLabel)) { + Text(status?.targetDisplayPath ?? "~/agent/skills/agent-pet-maker") + .font(.callout.monospaced()) + .textSelection(.enabled) + } + LabeledContent(APCLocalization.text(.studioPortableSkillBundledVersionLabel)) { + Text(status?.expectedVersion ?? "—") + .font(.callout.monospacedDigit()) + } + LabeledContent(APCLocalization.text(.studioPortableSkillInstalledVersionLabel)) { + Text( + status?.installedVersion + ?? APCLocalization.text(.studioPortableSkillNotInstalled) + ) + .font(.callout.monospacedDigit()) + .foregroundStyle(status?.installedVersion == nil ? .secondary : .primary) + } + } + .portableSkillCard() + } + + @ViewBuilder + private var statusNotice: some View { + if let failure = store.portableMakerSkillFailure { + PortableSkillNotice( + title: APCLocalization.text(.studioPortableSkillErrorTitle), + detail: failureDetail(failure), + systemImage: "exclamationmark.triangle.fill", + color: APCDesign.destructive + ) + } else if store.portableMakerSkillOperation == .checking && status == nil { + PortableSkillNotice( + title: APCLocalization.text(.studioPortableSkillChecking), + detail: APCLocalization.text(.studioPortableSkillCheckingDetail), + systemImage: "hourglass", + color: .secondary + ) + } else if let status { + let presentation = status.state.presentation + PortableSkillNotice( + title: APCLocalization.text(presentation.titleKey), + detail: APCLocalization.text(presentation.detailKey), + systemImage: presentation.systemImage, + color: presentation.color + ) + } + } + + private var managedContentNotice: some View { + PortableSkillNotice( + title: APCLocalization.text(.studioPortableSkillManagedTitle), + detail: APCLocalization.text(.studioPortableSkillManagedDetail), + systemImage: "arrow.triangle.2.circlepath", + color: APCDesign.warning + ) + } + + private var actionBar: some View { + HStack(spacing: 10) { + Button { + Task { await store.refreshPortableMakerSkillStatus() } + } label: { + Label( + APCLocalization.text(.studioPortableSkillActionRefresh), + systemImage: "arrow.clockwise" + ) + } + .disabled(store.portableMakerSkillOperation.isBusy) + .accessibilityIdentifier("maker.portable-skill.refresh") + + if status?.targetExists == true { + Button { + openSkillDirectory() + } label: { + Label( + APCLocalization.text(.studioPortableSkillActionOpenFolder), + systemImage: "folder" + ) + } + .disabled(store.portableMakerSkillOperation.isBusy) + .accessibilityIdentifier("maker.portable-skill.open-folder") + } + + if status?.canUninstall == true { + Button(role: .destructive) { + confirmingUninstall = true + } label: { + Label( + APCLocalization.text(.studioPortableSkillActionUninstall), + systemImage: "trash" + ) + } + .disabled(store.portableMakerSkillOperation.isBusy) + .accessibilityIdentifier("maker.portable-skill.uninstall") + } + + Spacer() + + Button(APCLocalization.text(.commonClose)) { + dismiss() + } + .keyboardShortcut(.cancelAction) + .disabled(store.portableMakerSkillOperation.isBusy) + + if let actionTitle = primaryActionTitle { + Button { + Task { await store.installPortableMakerSkill() } + } label: { + if store.portableMakerSkillOperation == .installing { + ProgressView() + .controlSize(.small) + } else { + Label(actionTitle, systemImage: primaryActionIcon) + } + } + .buttonStyle(.borderedProminent) + .disabled(store.portableMakerSkillOperation.isBusy) + .accessibilityIdentifier("maker.portable-skill.primary-action") + } + } + .padding(.horizontal, 22) + .padding(.vertical, 14) + .background(.bar) + } + + private var status: PortableMakerSkillStatus? { + store.portableMakerSkillStatus + } + + private var statusPill: some View { + HStack(spacing: 6) { + Circle() + .fill(status?.state.presentation.color ?? Color.secondary) + .frame(width: 7, height: 7) + Text( + status.map { APCLocalization.text($0.state.presentation.titleKey) } + ?? APCLocalization.text(.studioPortableSkillChecking) + ) + .font(.caption.weight(.semibold)) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.secondary.opacity(0.1), in: Capsule()) + .accessibilityElement(children: .combine) + } + + private var primaryActionTitle: String? { + guard let key = status?.state.presentation.primaryActionKey else { return nil } + return APCLocalization.text(key) + } + + private var primaryActionIcon: String { + status?.state.presentation.primaryActionIcon ?? "square.and.arrow.down" + } + + private func failureDetail(_ failure: PortableMakerSkillFailure) -> String { + let key: APCLocalizationKey = switch failure { + case .load: .studioPortableSkillErrorLoad + case .install: .studioPortableSkillErrorInstall + case .uninstall: .studioPortableSkillErrorUninstall + } + return APCLocalization.text(key) + } + + private func openSkillDirectory() { + let url = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent("agent", isDirectory: true) + .appendingPathComponent("skills", isDirectory: true) + .appendingPathComponent("agent-pet-maker", isDirectory: true) + NSWorkspace.shared.open(url) + } +} + +private struct PortableSkillStatePresentation { + let titleKey: APCLocalizationKey + let detailKey: APCLocalizationKey + let systemImage: String + let color: Color + let primaryActionKey: APCLocalizationKey? + let primaryActionIcon: String +} + +private extension PortableMakerSkillState { + var presentation: PortableSkillStatePresentation { + switch self { + case .missing: + PortableSkillStatePresentation( + titleKey: .studioPortableSkillStateMissing, + detailKey: .studioPortableSkillStateMissingDetail, + systemImage: "shippingbox", + color: .secondary, + primaryActionKey: .studioPortableSkillActionInstall, + primaryActionIcon: "square.and.arrow.down" + ) + case .current: + PortableSkillStatePresentation( + titleKey: .studioPortableSkillStateCurrent, + detailKey: .studioPortableSkillStateCurrentDetail, + systemImage: "checkmark.circle.fill", + color: APCDesign.success, + primaryActionKey: .studioPortableSkillActionReinstall, + primaryActionIcon: "arrow.clockwise" + ) + case .updateAvailable: + PortableSkillStatePresentation( + titleKey: .studioPortableSkillStateUpdateAvailable, + detailKey: .studioPortableSkillStateUpdateAvailableDetail, + systemImage: "arrow.down.circle.fill", + color: .blue, + primaryActionKey: .studioPortableSkillActionUpdate, + primaryActionIcon: "arrow.down.circle" + ) + case .needsReinstall: + PortableSkillStatePresentation( + titleKey: .studioPortableSkillStateNeedsReinstall, + detailKey: .studioPortableSkillStateNeedsReinstallDetail, + systemImage: "wrench.and.screwdriver.fill", + color: APCDesign.warning, + primaryActionKey: .studioPortableSkillActionReinstall, + primaryActionIcon: "arrow.clockwise" + ) + case .unmanagedCurrent: + PortableSkillStatePresentation( + titleKey: .studioPortableSkillStateUnmanagedCurrent, + detailKey: .studioPortableSkillStateUnmanagedCurrentDetail, + systemImage: "checkmark.shield", + color: .blue, + primaryActionKey: .studioPortableSkillActionManage, + primaryActionIcon: "square.and.arrow.down" + ) + case .conflict: + PortableSkillStatePresentation( + titleKey: .studioPortableSkillStateConflict, + detailKey: .studioPortableSkillStateConflictDetail, + systemImage: "exclamationmark.triangle.fill", + color: APCDesign.destructive, + primaryActionKey: nil, + primaryActionIcon: "square.and.arrow.down" + ) + } + } +} + +private struct PortableSkillNotice: View { + let title: String + let detail: String + let systemImage: String + let color: Color + + var body: some View { + HStack(alignment: .top, spacing: 11) { + Image(systemName: systemImage) + .foregroundStyle(color) + .frame(width: 22) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 3) { + Text(title) + .font(.callout.weight(.semibold)) + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(12) + .background(color.opacity(0.08), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityElement(children: .combine) + } +} + +private extension View { + func portableSkillCard() -> some View { + padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + Color.secondary.opacity(0.055), + in: RoundedRectangle(cornerRadius: 12, style: .continuous) + ) + .overlay { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .stroke(Color.secondary.opacity(0.12), lineWidth: 1) + } + } +} diff --git a/apps/macos/Sources/AgentPetCompanion/Views/SharedProductComponents.swift b/apps/macos/Sources/AgentPetCompanion/Views/SharedProductComponents.swift index d17cd4d..1f2ee4b 100644 --- a/apps/macos/Sources/AgentPetCompanion/Views/SharedProductComponents.swift +++ b/apps/macos/Sources/AgentPetCompanion/Views/SharedProductComponents.swift @@ -747,33 +747,50 @@ struct SessionBubbleRow: View { Spacer(minLength: 8) - if let stateIndicator { - Image(systemName: stateIndicator.systemImage) - .font(OverlayBubbleTypography.font( - .caption1, - weight: .semibold, - scale: fontScale - )) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(stateIndicator.color) - .frame( - width: OverlayBubbleTypography.scaledControlMetric( - 15, - scale: fontScale - ), - height: OverlayBubbleTypography.scaledControlMetric( - 15, - scale: fontScale - ) - ) - .help(session.statusText) - .accessibilityHidden(true) - .accessibilityIdentifier( - "overlay.session.status.\(stateIndicator.rawValue)" - ) + if !session.statusText.isEmpty { + HStack(spacing: 3) { + if let stateIndicator { + Image(systemName: stateIndicator.systemImage) + .symbolRenderingMode(.hierarchical) + .foregroundStyle(stateIndicator.color) + } + + Text(session.statusText) + .foregroundStyle(Color.primary) + } + .font(OverlayBubbleTypography.font( + .caption2, + weight: .semibold, + scale: fontScale + )) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + .padding(.horizontal, 5) + .padding(.vertical, 1) + .background( + Capsule() + .fill((statusColor ?? .clear).opacity(0.24)) + ) + .overlay { + Capsule() + .stroke((statusColor ?? .clear).opacity(0.62), lineWidth: 0.75) + .allowsHitTesting(false) + } + .help(session.statusText) + .accessibilityHidden(true) + .accessibilityIdentifier(standaloneStatusIdentifier) } } - .frame(maxWidth: .infinity, alignment: .leading) + .frame( + maxWidth: .infinity, + minHeight: OverlayGeometry.bubbleStandaloneMetadataHeight( + fontScale: fontScale + ), + maxHeight: OverlayGeometry.bubbleStandaloneMetadataHeight( + fontScale: fontScale + ), + alignment: .leading + ) } private var resolvedStandaloneAgentName: String? { @@ -782,6 +799,10 @@ struct SessionBubbleRow: View { return resolved?.isEmpty == false ? resolved : nil } + private var standaloneStatusIdentifier: String { + "overlay.session.status.\(stateIndicator?.rawValue ?? "running")" + } + private var standaloneSummaryText: Text { let title = Text(session.sessionTitle) .font(OverlayBubbleTypography.font( @@ -871,40 +892,21 @@ struct SessionBubbleRow: View { alignment: .top ) - VStack(alignment: .leading, spacing: 0) { - Text(session.primaryDetailText) - .font(OverlayBubbleTypography.font( - .caption1, - weight: .semibold, - scale: fontScale - )) - .foregroundStyle(Color.primary) - .lineLimit( - session.secondaryDetailText == nil - ? OverlayGeometry.bubbleDetailLineLimit - : 1, - reservesSpace: session.secondaryDetailText == nil - ) - .truncationMode(.tail) - - if let secondaryDetailText = session.secondaryDetailText { - Text(secondaryDetailText) - .font(OverlayBubbleTypography.font( - .caption1, - weight: .medium, - scale: fontScale - )) - .foregroundStyle(Color.primary) - .lineLimit(1) - .truncationMode(.tail) - } - } - .fixedSize(horizontal: false, vertical: true) - .frame( - height: OverlayGeometry.bubbleDetailTextHeight(fontScale: fontScale), - alignment: .topLeading - ) - .frame(maxWidth: .infinity, alignment: .leading) + Text(session.primaryDetailText) + .font(OverlayBubbleTypography.font( + .caption1, + weight: .semibold, + scale: fontScale + )) + .foregroundStyle(Color.primary) + .lineLimit(OverlayGeometry.bubbleDetailLineLimit, reservesSpace: true) + .truncationMode(.tail) + .fixedSize(horizontal: false, vertical: true) + .frame( + height: OverlayGeometry.bubbleDetailTextHeight(fontScale: fontScale), + alignment: .topLeading + ) + .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxWidth: .infinity, alignment: .leading) } diff --git a/apps/macos/Sources/AgentPetCompanionCore/PetCoreClient.swift b/apps/macos/Sources/AgentPetCompanionCore/PetCoreClient.swift index 7967d73..ab19e71 100644 --- a/apps/macos/Sources/AgentPetCompanionCore/PetCoreClient.swift +++ b/apps/macos/Sources/AgentPetCompanionCore/PetCoreClient.swift @@ -102,7 +102,9 @@ public struct PetCoreClient: Sendable { case "connections.check", "connections.repair", "connections.refresh_installed", - "connections.uninstall": + "connections.uninstall", + "portable_skill.install", + "portable_skill.uninstall": .seconds(180) default: .seconds(5) diff --git a/apps/macos/Tests/AgentPetCompanionTests/AgentConnectionsTests.swift b/apps/macos/Tests/AgentPetCompanionTests/AgentConnectionsTests.swift index b4682ca..eb708cb 100644 --- a/apps/macos/Tests/AgentPetCompanionTests/AgentConnectionsTests.swift +++ b/apps/macos/Tests/AgentPetCompanionTests/AgentConnectionsTests.swift @@ -1238,8 +1238,8 @@ struct AgentConnectionsTests { name: "agent-pet-companion.ts", ownership: .appManaged, status: .ok, - expectedVersion: "pi-extension-0.80.10-events-v13", - activeVersion: "pi-extension-0.80.10-events-v13", + expectedVersion: "pi-extension-0.80.10-events-v15", + activeVersion: "pi-extension-0.80.10-events-v15", contentMatches: true ) #expect( diff --git a/apps/macos/Tests/AgentPetCompanionTests/OnboardingViewTests.swift b/apps/macos/Tests/AgentPetCompanionTests/OnboardingViewTests.swift index 78d3730..91f3b38 100644 --- a/apps/macos/Tests/AgentPetCompanionTests/OnboardingViewTests.swift +++ b/apps/macos/Tests/AgentPetCompanionTests/OnboardingViewTests.swift @@ -102,9 +102,19 @@ struct OnboardingViewTests { "PetLibraryPreviewPolicy.canRender(" )) #expect(viewSource.contains("assetWarning: assetWarning")) - #expect(viewSource.contains("@State private var contentState = PetPreviewContentState()")) + #expect(viewSource.contains( + "_contentState = State(initialValue: PetPreviewContentState(" + )) + #expect(viewSource.contains("selectedIdentity:")) + #expect(viewSource.contains("contentState.select(nextIdentity)")) + #expect(viewSource.contains("if !rendererHasContent")) + #expect(viewSource.contains("Color(nsColor: .textBackgroundColor)")) + #expect(viewSource.contains("transaction.animation = nil")) #expect(viewSource.contains("onFrameContentChanged: onRendererContentChanged")) - #expect(!viewSource.contains("rendererHasContent")) + #expect(!viewSource.contains("@State private var rendererHasContent")) + #expect(!viewSource.contains( + ".opacity(contentState.hasPresentedContent(for: identity) ? 0 : 1)" + )) } @Test diff --git a/apps/macos/Tests/AgentPetCompanionTests/OverlayGeometryTests.swift b/apps/macos/Tests/AgentPetCompanionTests/OverlayGeometryTests.swift index 6d7aa79..09c81b0 100644 --- a/apps/macos/Tests/AgentPetCompanionTests/OverlayGeometryTests.swift +++ b/apps/macos/Tests/AgentPetCompanionTests/OverlayGeometryTests.swift @@ -2046,9 +2046,8 @@ struct OverlayGeometryTests { #expect(content.activityText == displayActivity) #expect(content.messageText == displayReply) #expect(content.primaryDetailText == displayReply) - #expect(content.secondaryDetailText == displayActivity) - #expect(content.detailText == [displayReply, displayActivity].joined(separator: "\n")) - #expect(content.detailText.contains(displayActivity)) + #expect(content.detailText == displayReply) + #expect(!content.detailText.contains(displayActivity)) #expect(content.detailText.contains(displayReply)) #expect(content.navigation.sessionOpen == true) #expect(!content.sessionTitle.contains(rawPrompt)) @@ -2069,7 +2068,6 @@ struct OverlayGeometryTests { #expect(content.activityText.isEmpty) #expect(content.messageText == "正在检查构建结果。") #expect(content.primaryDetailText == "正在检查构建结果。") - #expect(content.secondaryDetailText == nil) #expect(!content.detailText.contains("{")) #expect(!content.detailText.contains("RAW_DO_NOT_RENDER")) #expect(content.statusText == APCLocalizedPresentation.overlayEventTitle(.command)) @@ -2085,7 +2083,6 @@ struct OverlayGeometryTests { #expect(content.activityText.isEmpty) #expect(content.messageText.isEmpty) - #expect(content.secondaryDetailText == nil) #expect(content.sessionTitle == APCLocalization.format(.overlaySessionTitleFormat, "Pi")) #expect(content.accessibilityReadingOrder.contains( APCLocalization.text(.overlayDetailCompleted) @@ -2118,7 +2115,6 @@ struct OverlayGeometryTests { ) #expect(session.primaryDetailText.isEmpty) - #expect(session.secondaryDetailText == nil) #expect(session.accessibilityReadingOrder == [ "Codex", "Codex session", @@ -2200,7 +2196,7 @@ struct OverlayGeometryTests { } @Test - func voiceOverReadingOrderKeepsLongEnglishAndChineseSessionCopySemantic() { + func voiceOverReadingOrderUsesOnlyTheVisibleRetainedBodyMessage() { let fixtures = [ ( session: "A longer session title that still identifies the active work", @@ -2239,7 +2235,6 @@ struct OverlayGeometryTests { fixture.session, fixture.status, fixture.message, - fixture.activity, session.actionLabel, ]) #expect( diff --git a/apps/macos/Tests/AgentPetCompanionTests/PortableMakerSkillManagementTests.swift b/apps/macos/Tests/AgentPetCompanionTests/PortableMakerSkillManagementTests.swift new file mode 100644 index 0000000..352c45b --- /dev/null +++ b/apps/macos/Tests/AgentPetCompanionTests/PortableMakerSkillManagementTests.swift @@ -0,0 +1,160 @@ +import Foundation +import Testing +@testable import AgentPetCompanion +@testable import AgentPetCompanionCore + +@Suite("Portable Agent Pet Maker management") +@MainActor +struct PortableMakerSkillManagementTests { + @Test + func statusUsesTheFixedBoundedRPCContract() async throws { + var requests: [(String, Duration?)] = [] + let store = makeStore { method, params, timeout in + requests.append((method, timeout)) + #expect((params as? [String: Any])?.isEmpty == true) + return statusObject(state: "missing") + } + + await store.refreshPortableMakerSkillStatus() + + #expect(requests.count == 1) + #expect(requests.first?.0 == "portable_skill.status") + #expect(requests.first?.1 == .seconds(180)) + #expect(store.portableMakerSkillStatus?.state == .missing) + #expect( + store.portableMakerSkillStatus?.targetDisplayPath + == "~/agent/skills/agent-pet-maker" + ) + #expect(store.portableMakerSkillOperation == .idle) + #expect(store.portableMakerSkillFailure == nil) + } + + @Test + func installAndUninstallPublishReturnedAuthoritativeStatus() async { + var methods: [String] = [] + let store = makeStore { method, _, _ in + methods.append(method) + return switch method { + case "portable_skill.install": + statusObject( + state: "current", + installedVersion: "0.5.6", + managed: true, + targetExists: true, + canReinstall: true, + canUninstall: true + ) + case "portable_skill.uninstall": + statusObject(state: "missing") + default: + throw PortableMakerSkillTestError.unexpectedMethod(method) + } + } + + await store.installPortableMakerSkill() + #expect(store.portableMakerSkillStatus?.state == .current) + #expect(store.portableMakerSkillStatus?.managed == true) + + await store.uninstallPortableMakerSkill() + #expect(store.portableMakerSkillStatus?.state == .missing) + #expect(store.portableMakerSkillStatus?.managed == false) + #expect(methods == ["portable_skill.install", "portable_skill.uninstall"]) + } + + @Test + func invalidStatusFailsClosedWithoutPublishingPaths() async { + let store = makeStore { _, _, _ in + var value = statusObject(state: "current") + value["target_display_path"] = "/Users/someone/private" + return value + } + + await store.refreshPortableMakerSkillStatus() + + #expect(store.portableMakerSkillStatus == nil) + #expect(store.portableMakerSkillFailure == .load) + #expect(store.portableMakerSkillOperation == .idle) + } + + @Test + func makerHeaderPlacesThePortableSkillButtonBeforeNewPet() throws { + let source = try sourceFile("Views/MakerSessionWorkspace.swift") + let skillButton = try #require( + source.range(of: "maker.portable-skill.open")?.lowerBound + ) + let newPetButton = try #require( + source.range(of: "maker.session-list.new")?.lowerBound + ) + #expect(skillButton < newPetButton) + #expect(source.contains("PortableMakerSkillManagementSheet()")) + } + + @Test + func sheetUsesOnlyTheAuthoredPortableSkillLocation() throws { + let source = try sourceFile("Views/PortableMakerSkillManagementSheet.swift") + #expect(source.contains("appendingPathComponent(\"agent\", isDirectory: true)")) + #expect(source.contains("appendingPathComponent(\"skills\", isDirectory: true)")) + #expect(source.contains("appendingPathComponent(\"agent-pet-maker\", isDirectory: true)")) + #expect(!source.contains(".claude")) + #expect(!source.contains(".codex")) + #expect(!source.contains(".config/opencode")) + } + + private func makeStore( + request: @escaping AppStore.PetCoreRequestOverride + ) -> AppStore { + AppStore( + bootstrapHooks: AppStoreBootstrapHooks( + ensureRunning: { .alreadyHealthy }, + recover: { .alreadyHealthy }, + refreshSnapshot: { _ in }, + onReady: { _ in } + ), + petCoreRequestOverride: request, + productConvergenceManifest: nil + ) + } + + private func statusObject( + state: String, + installedVersion: String? = nil, + managed: Bool = false, + targetExists: Bool = false, + canInstall: Bool = true, + canUpdate: Bool = false, + canReinstall: Bool = false, + canUninstall: Bool = false + ) -> [String: Any] { + [ + "schema_version": "apc.portable-skill-status.v1", + "name": "agent-pet-maker", + "state": state, + "target_display_path": "~/agent/skills/agent-pet-maker", + "expected_version": "0.5.6", + "installed_version": installedVersion ?? NSNull(), + "managed": managed, + "target_exists": targetExists, + "can_install": canInstall, + "can_update": canUpdate, + "can_reinstall": canReinstall, + "can_uninstall": canUninstall, + ] + } + + private func sourceFile(_ relativePath: String) throws -> String { + let packageRoot = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .deletingLastPathComponent() + .deletingLastPathComponent() + return try String( + contentsOf: packageRoot + .appendingPathComponent("Sources/AgentPetCompanion") + .appendingPathComponent(relativePath), + encoding: .utf8 + ) + } +} + +private enum PortableMakerSkillTestError: Error { + case unexpectedMethod(String) +} diff --git a/apps/macos/Tests/AgentPetCompanionTests/SharedProductComponentsTests.swift b/apps/macos/Tests/AgentPetCompanionTests/SharedProductComponentsTests.swift index 0a530bf..cb8beb5 100644 --- a/apps/macos/Tests/AgentPetCompanionTests/SharedProductComponentsTests.swift +++ b/apps/macos/Tests/AgentPetCompanionTests/SharedProductComponentsTests.swift @@ -35,7 +35,7 @@ struct SharedProductComponentsTests { } @Test - func standaloneSessionIndicatorsCoverOnlyAttentionAndTerminalStates() { + func standaloneSessionIconsCoverOnlyAttentionAndTerminalStates() { let eventTypes: [AgentEventKind?] = [ nil, .start, .thinking, .plan, .tool, .waiting, .done, .failed, ] @@ -259,10 +259,15 @@ struct SharedProductComponentsTests { #expect(standaloneSource.contains( ".padding(.trailing, reservedTrailingAccessoryWidth)" )) + #expect(standaloneSource.contains("if !session.statusText.isEmpty")) + #expect(standaloneSource.contains("Text(session.statusText)")) #expect(standaloneSource.contains("if let stateIndicator")) #expect(standaloneSource.contains(".help(session.statusText)")) #expect(standaloneSource.contains( - "\"overlay.session.status.\\(stateIndicator.rawValue)\"" + ".accessibilityIdentifier(standaloneStatusIdentifier)" + )) + #expect(sharedSource.contains( + "\"overlay.session.status.\\(stateIndicator?.rawValue ?? \"running\")\"" )) #expect(!sharedSource.contains("if session.canOpen {")) #expect(sharedSource.contains(".focused($focused)")) @@ -279,7 +284,7 @@ struct SharedProductComponentsTests { #expect(sharedSource.contains("return nil")) #expect(!sharedSource.contains("case .start, .tool: .blue")) #expect(sharedSource.contains( - "reservesSpace: session.secondaryDetailText == nil" + ".lineLimit(OverlayGeometry.bubbleDetailLineLimit, reservesSpace: true)" )) #expect(sharedSource.contains( "openLabel: primaryActionLabel ?? session.actionLabel" diff --git a/apps/macos/Tests/AgentPetCompanionTests/UIModelTests.swift b/apps/macos/Tests/AgentPetCompanionTests/UIModelTests.swift index 37c1773..e3dd82e 100644 --- a/apps/macos/Tests/AgentPetCompanionTests/UIModelTests.swift +++ b/apps/macos/Tests/AgentPetCompanionTests/UIModelTests.swift @@ -632,11 +632,7 @@ struct UIModelTests { #expect(session.activityText == "正在验证活动摘要同步") #expect(session.messageText == "已恢复气泡消息内容。") #expect(session.primaryDetailText == "已恢复气泡消息内容。") - #expect(session.secondaryDetailText == "正在验证活动摘要同步") - #expect(session.detailText == [ - "已恢复气泡消息内容。", - "正在验证活动摘要同步", - ].joined(separator: "\n")) + #expect(session.detailText == "已恢复气泡消息内容。") #expect(session.statusText == APCLocalizedPresentation.overlayEventTitle(.thinking)) #expect(content.agentName == "Codex") #expect(session.sessionTitle == "保持会话消息持续显示") @@ -652,6 +648,26 @@ struct UIModelTests { sessionID: session.navigation.routableSessionID )?.absoluteString == "codex://threads/019f5b0f-88ff-7413-8953-29de4ed0951c" ) + + let flat = OverlayBubbleProjection.contents( + states: [state], + omittedCount: 0, + dismissedSessionIDs: [], + groupSessionsByAgent: false, + isExpanded: { _ in true } + ) + let grouped = OverlayBubbleProjection.contents( + states: [state], + omittedCount: 0, + dismissedSessionIDs: [], + groupSessionsByAgent: true, + isExpanded: { _ in true } + ) + let flatSession = try #require(flat.first?.sessions.first) + let groupedSession = try #require(grouped.first?.sessions.first) + #expect(flatSession == groupedSession) + #expect(flatSession.primaryDetailText == "已恢复气泡消息内容。") + #expect(flatSession.standaloneSummaryText == "已恢复气泡消息内容。") } @Test @@ -1240,7 +1256,7 @@ struct UIModelTests { var statusOnly = session statusOnly.messageText = "" - #expect(statusOnly.standaloneSummaryText == "Using Tools") + #expect(statusOnly.standaloneSummaryText.isEmpty) for expanded in [false, true] { let trayCard = OverlayBubbleContent( diff --git a/crates/petcore/src/adapter_contracts.rs b/crates/petcore/src/adapter_contracts.rs index d6234b4..7e0cc9e 100644 --- a/crates/petcore/src/adapter_contracts.rs +++ b/crates/petcore/src/adapter_contracts.rs @@ -14,7 +14,7 @@ use std::path::Path; pub const CODEX_HOOKS_CONTRACT_VERSION: &str = "codex-hooks-2026-08-01-events-v9"; pub const CLAUDE_HOOKS_CONTRACT_VERSION: &str = "claude-hooks-2026-08-01-events-v9"; -pub const PI_EXTENSION_CONTRACT_VERSION: &str = "pi-extension-0.80.10-events-v13"; +pub const PI_EXTENSION_CONTRACT_VERSION: &str = "pi-extension-0.80.10-events-v15"; pub const OPENCODE_CONTRACT_VERSION: &str = "opencode-v1.18.4-events-v16"; const MAX_MESSAGE_BYTES: usize = 4_096; const MAX_IDENTITY_BYTES: usize = 256; @@ -471,6 +471,9 @@ fn parse_claude(source: AgentSource, input: &Value) -> Result Result> { let event = event_type(input)?; let agent_error = bool_at(input, &[&["agent_error"]]); + let model_unavailable = event == "input" + && agent_error + && string_at(input, &[&["reason"]]).as_deref() == Some("model_unavailable"); let normalized_activity_content = activity_content(event, input); let explicit_thinking = event == "message_end" && string_at(input, &[&["activity_kind"]]).as_deref() == Some("thinking") @@ -478,6 +481,7 @@ fn parse_pi(source: AgentSource, input: &Value) -> Result> let (kind, outcome, session_active) = match event { // Opening or resuming a Pi page does not mean the agent is working. "session_start" => return Ok(None), + "input" if model_unavailable => (AgentEventType::Failed, "model_unavailable", false), "input" | "before_agent_start" | "agent_start" | "turn_start" => { (AgentEventType::Start, "started", true) } @@ -2072,6 +2076,25 @@ mod claude_transcript_tests { Some("检查下代码工程质量") ); + let pi_missing_model = parse_contract_event( + AgentSource::Pi, + &json!({ + "type": "input", + "session_id": "pi-no-model", + "text": "检查下代码工程质量", + "agent_error": true, + "reason": "model_unavailable" + }), + ) + .expect("parsed") + .expect("contract"); + assert_eq!(pi_missing_model.kind, AgentEventType::Failed); + assert_eq!( + pi_missing_model.outcome.as_deref(), + Some("model_unavailable") + ); + assert!(!pi_missing_model.session_active); + let pi_agent = parse_contract_event( AgentSource::Pi, &json!({ diff --git a/crates/petcore/src/agent_state.rs b/crates/petcore/src/agent_state.rs index 9d97296..5987db1 100644 --- a/crates/petcore/src/agent_state.rs +++ b/crates/petcore/src/agent_state.rs @@ -81,9 +81,9 @@ pub struct ActiveAgentState { /// Closed structural projection consumed by the desktop overlay. The /// separately hydrated, bounded session title, user/assistant messages, and -/// provider-visible activity summary intentionally carry the two-line context -/// rendered in the local bubble; arbitrary raw event fields are not duplicated -/// into this projection. +/// provider-visible activity summary supply the single retained bubble body +/// message and its independent status. Arbitrary raw event fields are not +/// duplicated into this projection. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct OverlaySessionDisplay { pub summary_kind: OverlaySummaryKind, @@ -609,7 +609,9 @@ fn active_state_from_candidate( session_title: None, session_message: None, session_user_message: None, - session_activity: event_activity(event), + // Display copy is hydrated from persisted session history so a tool + // or lifecycle event cannot evict the latest Agent/thinking message. + session_activity: None, overlay_display, } } @@ -954,6 +956,18 @@ fn event_activity(event: &AgentEvent) -> Option { }) } +pub(crate) fn event_narrative_activity(event: &AgentEvent) -> Option { + if !matches!( + event.event_type, + AgentEventType::Thinking | AgentEventType::Plan + ) { + return None; + } + let activity = event_activity(event)?; + activity.content.as_ref()?; + Some(activity) +} + fn event_lease_seconds_for_behavior(behavior: &BehaviorSettings, event: &AgentEvent) -> i64 { let source_event = event .payload_json diff --git a/crates/petcore/src/app_server.rs b/crates/petcore/src/app_server.rs index 9769d6e..9552a31 100644 --- a/crates/petcore/src/app_server.rs +++ b/crates/petcore/src/app_server.rs @@ -168,6 +168,10 @@ pub struct CodexThreadDisplay { pub title: Option, pub latest_message: Option, pub latest_user_message: Option, + /// The newest displayable reasoning/plan entry when it follows the newest + /// retained assistant message. Tool activity is intentionally excluded so + /// it can update status without replacing bubble copy. + pub latest_narrative_activity: Option, pub latest_activity: Option, /// Safe, display-only marker used to distinguish a newly persisted item /// from an updated thread whose current live item was intentionally omitted @@ -1172,20 +1176,35 @@ fn parse_codex_thread_display(response: &Value) -> Result { .filter_map(|turn| turn.get("items").and_then(Value::as_array)) .flatten() .collect::>(); - let display_messages = items - .iter() - .copied() - .filter_map(codex_display_message) - .collect::>(); - let latest_message = display_messages - .last() - .filter(|message| message.role == "assistant") - .cloned(); - let latest_user_message = display_messages - .iter() - .rev() - .find(|message| message.role == "user") - .cloned(); + let latest_user = items.iter().enumerate().rev().find_map(|(index, item)| { + codex_display_message(item) + .filter(|message| message.role == "user") + .map(|message| (index, message)) + }); + let latest_user_message = latest_user.as_ref().map(|(_, message)| message.clone()); + let latest_assistant = items.iter().enumerate().rev().find_map(|(index, item)| { + codex_display_message(item) + .filter(|message| message.role == "assistant") + .map(|message| (index, message)) + }); + let latest_message = latest_assistant + .as_ref() + .map(|(_, message)| message.clone()); + let latest_narrative_activity = items.iter().enumerate().rev().find_map(|(index, item)| { + let follows_latest_assistant = latest_assistant + .as_ref() + .is_none_or(|(message_index, _)| index > *message_index); + follows_latest_assistant + .then(|| codex_display_activity(item)) + .flatten() + .filter(|activity| { + matches!(activity.kind.as_str(), "thinking" | "plan") + && activity + .content + .as_deref() + .is_some_and(|content| !content.trim().is_empty()) + }) + }); let latest_message_index = items.iter().rposition(|item| { matches!( item.get("type").and_then(Value::as_str), @@ -1206,6 +1225,7 @@ fn parse_codex_thread_display(response: &Value) -> Result { title, latest_message, latest_user_message, + latest_narrative_activity, latest_activity, display_revision, }) @@ -1453,6 +1473,25 @@ mod codex_display_tests { assert_eq!(activity.kind, "file_change"); assert!(!activity.is_current); assert_eq!(activity.content, None); + assert_eq!( + display + .latest_narrative_activity + .and_then(|activity| activity.content), + Some("旧思考信息".to_string()), + "tool status must not evict the latest displayable reasoning" + ); + + let agent_reply = parse_codex_thread_display(&thread_response(vec![ + json!({"id":"reasoning-1","type":"reasoning","summary":["旧思考信息"]}), + json!({"id":"message-1","type":"agentMessage","text":"更新后的 Agent 消息"}), + json!({"id":"patch-1","type":"fileChange","status":"completed","changes":[]}), + ])) + .expect("Agent reply display"); + assert_eq!( + agent_reply.latest_message.map(|message| message.content), + Some("更新后的 Agent 消息".to_string()) + ); + assert_eq!(agent_reply.latest_narrative_activity, None); } #[test] diff --git a/crates/petcore/src/connections.rs b/crates/petcore/src/connections.rs index c0b532d..3fcafaf 100644 --- a/crates/petcore/src/connections.rs +++ b/crates/petcore/src/connections.rs @@ -4376,16 +4376,12 @@ fn check_pi_extension_runtime( ConnectionCheckItem::new( CheckCode::HostRuntime, label, - if event_seen && host_ok { - CheckStatus::Ok - } else { - CheckStatus::NeedsFix - }, + pi_runtime_probe_status(event_seen, host_ok), if event_seen && host_ok { "真实 pi --offline RPC 宿主已加载全局 Extension,并回传 connector.probe(无模型调用)" .to_string() } else if event_seen { - "Pi Extension 已回传当前 connector.probe,但宿主未正常退出;可能存在 project trust、启动参数或配置错误,不能标记为已验证" + "真实 Pi 宿主已加载全局 Extension,并回传当前 connector.probe;宿主随后因独立的模型或运行配置退出,不影响本地连接加载验证" .to_string() } else if host_ok { "Pi 宿主已启动,但未收到 Extension connector.probe;请重启 Pi/检查 Extension 加载" @@ -4407,6 +4403,14 @@ fn check_pi_extension_runtime( ) } +fn pi_runtime_probe_status(event_seen: bool, _host_ok: bool) -> CheckStatus { + if event_seen { + CheckStatus::Ok + } else { + CheckStatus::NeedsFix + } +} + fn pi_native_probe_spec( pi: PathBuf, paths: &AppPaths, @@ -7244,7 +7248,7 @@ mod tests { has_required_ordinary_task_evidence, host_verification_check_is_fresh, install_claude_settings, install_root, is_agent_pet_claude_command, json_config_backup_path, managed_connector_script_ownership, opencode_debug_reports_plugin, - opencode_plugins_dir, pi_extensions_dir, pi_native_probe_spec, + opencode_plugins_dir, pi_extensions_dir, pi_native_probe_spec, pi_runtime_probe_status, remove_claude_settings_hooks, remove_codex_marketplace_entry, remove_owned_connector_script, render_connector_script, rendered_claude_hook, rendered_claude_settings_fragment, rendered_codex_hooks, repair_claude, repair_source_at, @@ -7729,6 +7733,15 @@ mod tests { assert!(!args.iter().any(|arg| arg == "--no-extensions")); } + #[test] + fn pi_native_probe_accepts_current_canary_when_host_later_exits_without_a_model() { + assert_eq!( + pi_runtime_probe_status(true, false), + CheckStatus::Ok, + "the exact current connector canary proves that Pi loaded the managed Extension; a later host exit belongs to model/runtime readiness" + ); + } + #[test] fn codex_local_and_app_server_hook_names_are_distinct_complete_contracts() { assert_eq!(CODEX_LOCAL_HOOK_EVENTS.len(), 10); diff --git a/crates/petcore/src/daemon.rs b/crates/petcore/src/daemon.rs index 9701a5d..6859ebf 100644 --- a/crates/petcore/src/daemon.rs +++ b/crates/petcore/src/daemon.rs @@ -827,7 +827,9 @@ fn client_response_timeout(method: &str) -> Duration { "connections.check" | "connections.repair" | "connections.refresh_installed" - | "connections.uninstall" => UDS_CONNECTIONS_RESPONSE_DEADLINE, + | "connections.uninstall" + | "portable_skill.install" + | "portable_skill.uninstall" => UDS_CONNECTIONS_RESPONSE_DEADLINE, "diagnostics.export" => UDS_DIAGNOSTICS_RESPONSE_DEADLINE, _ => UDS_IO_DEADLINE, } @@ -1035,6 +1037,14 @@ mod tests { client_response_timeout("connections.refresh_installed"), UDS_CONNECTIONS_RESPONSE_DEADLINE ); + assert_eq!( + client_response_timeout("portable_skill.install"), + UDS_CONNECTIONS_RESPONSE_DEADLINE + ); + assert_eq!( + client_response_timeout("portable_skill.uninstall"), + UDS_CONNECTIONS_RESPONSE_DEADLINE + ); assert_eq!( client_response_timeout("diagnostics.export"), UDS_DIAGNOSTICS_RESPONSE_DEADLINE diff --git a/crates/petcore/src/db.rs b/crates/petcore/src/db.rs index d031c34..48f2626 100644 --- a/crates/petcore/src/db.rs +++ b/crates/petcore/src/db.rs @@ -387,6 +387,7 @@ pub(crate) enum OverlayPlacementWriteResult { #[derive(Debug, Clone, Default)] pub(crate) struct SessionMessageProjection { pub(crate) latest_assistant: Option, + pub(crate) latest_narrative_activity: Option, pub(crate) latest_user: Option, pub(crate) first_user: Option, pub(crate) latest_title: Option, @@ -3770,10 +3771,10 @@ impl Database { .map(|sequenced| sequenced.event)) } - /// Atomically projects the three persisted messages needed to hydrate a + /// Atomically projects the persisted display records needed to hydrate a /// session bubble. A single ordered scan supplies the latest assistant, - /// latest user, and first user records when (and only when) the caller's - /// event revision is still current. + /// latest narrative activity, latest user, and first user records when + /// (and only when) the caller's event revision is still current. pub(crate) fn session_message_projection_at_revision( &self, expected_state_revision: u64, @@ -5421,6 +5422,15 @@ fn session_message_projection_in_connection( { projection.latest_title = Some(sequenced.event.clone()); } + if projection.latest_narrative_activity.is_none() + && matches!( + sequenced.event.event_type, + AgentEventType::Thinking | AgentEventType::Plan + ) + && event_has_nonempty_payload_text(&sequenced.event, "activity_content") + { + projection.latest_narrative_activity = Some(sequenced.clone()); + } if !event_has_nonempty_message_content(&sequenced.event) { continue; } diff --git a/crates/petcore/src/diagnostics.rs b/crates/petcore/src/diagnostics.rs index 68db6f1..4cc1ad3 100644 --- a/crates/petcore/src/diagnostics.rs +++ b/crates/petcore/src/diagnostics.rs @@ -721,6 +721,9 @@ fn safe_rpc_method(method: &str) -> &'static str { "connections.refresh_installed" => "connections.refresh_installed", "connections.uninstall" => "connections.uninstall", "connections.test" => "connections.test", + "portable_skill.status" => "portable_skill.status", + "portable_skill.install" => "portable_skill.install", + "portable_skill.uninstall" => "portable_skill.uninstall", "product.convergence.get" => "product.convergence.get", "product.convergence.update" => "product.convergence.update", "product.convergence.preflight" => "product.convergence.preflight", @@ -758,6 +761,8 @@ fn rpc_success_is_diagnostic(method: &str) -> bool { | "connections.refresh_installed" | "connections.uninstall" | "connections.test" + | "portable_skill.install" + | "portable_skill.uninstall" | "product.convergence.update" | "diagnostics.export" ) diff --git a/crates/petcore/src/event_envelope.rs b/crates/petcore/src/event_envelope.rs index 831fc50..ba35792 100644 --- a/crates/petcore/src/event_envelope.rs +++ b/crates/petcore/src/event_envelope.rs @@ -869,6 +869,7 @@ fn normalized_outcome(value: &str) -> String { | "permission_replied_reject" | "started_without_args" | "session_failure" + | "model_unavailable" | "observed" | "auto_denied" | "background_active" diff --git a/crates/petcore/src/lib.rs b/crates/petcore/src/lib.rs index 4e28fc8..615d8ee 100644 --- a/crates/petcore/src/lib.rs +++ b/crates/petcore/src/lib.rs @@ -15,6 +15,7 @@ pub mod metrics; pub mod paths; pub mod pet_revision; pub mod petpack; +pub mod portable_skill; pub mod process_runner; pub mod reference_images; pub mod rollback_checkpoint; diff --git a/crates/petcore/src/portable_skill.rs b/crates/petcore/src/portable_skill.rs new file mode 100644 index 0000000..fdb45eb --- /dev/null +++ b/crates/petcore/src/portable_skill.rs @@ -0,0 +1,1575 @@ +use crate::paths::AppPaths; +use crate::{PetCoreError, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::BTreeSet; +use std::fs::{self, File, OpenOptions}; +use std::io::{ErrorKind, Read, Write}; +use std::os::unix::ffi::OsStrExt; +use std::os::unix::fs::{OpenOptionsExt, PermissionsExt}; +use std::path::{Component, Path, PathBuf}; + +const SKILL_NAME: &str = "agent-pet-maker"; +const SKILL_DISPLAY_PATH: &str = "~/agent/skills/agent-pet-maker"; +const RECEIPT_SCHEMA: &str = "apc.portable-skill-install.v1"; +const STATUS_SCHEMA: &str = "apc.portable-skill-status.v1"; +const RECEIPT_FILE_NAME: &str = "agent-pet-maker.json"; +const MAX_RECEIPT_BYTES: u64 = 64 * 1024; +const MAX_MANAGED_REMOVAL_ENTRIES: usize = 4096; + +#[derive(Clone, Copy)] +struct BundledSkillFile { + relative_path: &'static str, + content: &'static str, + mode: u32, +} + +const BUNDLED_SKILL_FILES: &[BundledSkillFile] = &[ + BundledSkillFile { + relative_path: "SKILL.md", + content: include_str!("../../../skills/agent-pet-maker/SKILL.md"), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "agents/openai.yaml", + content: include_str!("../../../skills/agent-pet-maker/agents/openai.yaml"), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "references/create-modify.md", + content: include_str!("../../../skills/agent-pet-maker/references/create-modify.md"), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "references/dreamina-high-production.md", + content: include_str!( + "../../../skills/agent-pet-maker/references/dreamina-high-production.md" + ), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "references/petpack-v3.md", + content: include_str!("../../../skills/agent-pet-maker/references/petpack-v3.md"), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "references/security.md", + content: include_str!("../../../skills/agent-pet-maker/references/security.md"), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "references/transparent-frame-production.md", + content: include_str!( + "../../../skills/agent-pet-maker/references/transparent-frame-production.md" + ), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "references/visual-production-and-native-resolution.md", + content: include_str!( + "../../../skills/agent-pet-maker/references/visual-production-and-native-resolution.md" + ), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "scripts/petpack_workspace.py", + content: include_str!("../../../skills/agent-pet-maker/scripts/petpack_workspace.py"), + mode: 0o755, + }, + BundledSkillFile { + relative_path: "scripts/prepare_transparent_frames.py", + content: include_str!( + "../../../skills/agent-pet-maker/scripts/prepare_transparent_frames.py" + ), + mode: 0o755, + }, + BundledSkillFile { + relative_path: "tests/test_petpack_workspace.py", + content: include_str!("../../../skills/agent-pet-maker/tests/test_petpack_workspace.py"), + mode: 0o644, + }, + BundledSkillFile { + relative_path: "tests/test_prepare_transparent_frames.py", + content: include_str!( + "../../../skills/agent-pet-maker/tests/test_prepare_transparent_frames.py" + ), + mode: 0o644, + }, +]; + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum PortableSkillState { + Missing, + Current, + UpdateAvailable, + NeedsReinstall, + UnmanagedCurrent, + Conflict, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PortableSkillStatus { + pub schema_version: String, + pub name: String, + pub state: PortableSkillState, + pub target_display_path: String, + pub expected_version: String, + pub installed_version: Option, + pub managed: bool, + pub target_exists: bool, + pub can_install: bool, + pub can_update: bool, + pub can_reinstall: bool, + pub can_uninstall: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +struct ManagedSkillReceipt { + schema_version: String, + name: String, + target_display_path: String, + installed_version: String, + content_sha256: String, +} + +enum ReceiptState { + Missing, + Valid(ManagedSkillReceipt), + Invalid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct EntryIdentity { + dev: u64, + ino: u64, + mode: rustix::fs::RawMode, +} + +struct StatusInspection { + status: PortableSkillStatus, + skills_identity: Option, + target_identity: Option, +} + +enum DirectoryState { + Missing, + Unsafe, + Ready(File), +} + +pub fn status(paths: &AppPaths) -> Result { + let home = user_home()?; + status_at(&paths.home, &home) +} + +pub fn install(paths: &AppPaths) -> Result { + let home = user_home()?; + install_at(&paths.home, &home) +} + +pub fn uninstall(paths: &AppPaths) -> Result { + let home = user_home()?; + uninstall_at(&paths.home, &home) +} + +fn user_home() -> Result { + let home = std::env::var_os("HOME") + .map(PathBuf::from) + .filter(|path| path.is_absolute()) + .ok_or_else(|| PetCoreError::Validation("user home is unavailable".to_string()))?; + Ok(home) +} + +fn status_at(app_home: &Path, home: &Path) -> Result { + Ok(inspect_status_at(app_home, home)?.status) +} + +fn inspect_status_at(app_home: &Path, home: &Path) -> Result { + let receipt_state = read_receipt(&receipt_path(app_home))?; + let expected_version = bundled_version()?; + let (parent_safe, skills_identity, target_identity, expected_matches, target_installed_version) = + match inspect_managed_parent(home)? { + DirectoryState::Missing => (true, None, None, false, None), + DirectoryState::Unsafe => (false, None, None, false, None), + DirectoryState::Ready(skills) => { + let skills_identity = + entry_identity(&rustix::fs::fstat(&skills).map_err(std::io::Error::from)?); + let target_identity = entry_identity_at(&skills, Path::new(SKILL_NAME))?; + let mut expected_matches = false; + let mut observed_version = None; + if let Some(identity) = target_identity { + if rustix::fs::FileType::from_raw_mode(identity.mode).is_dir() { + if let Some(target) = + open_directory_at(&skills, Path::new(SKILL_NAME), Some(identity))? + { + expected_matches = bundle_tree_matches_root(&target).unwrap_or(false); + observed_version = installed_version(&target); + } + } + } + ( + true, + Some(skills_identity), + target_identity, + expected_matches, + observed_version, + ) + } + }; + let target_exists = target_identity.is_some(); + let installed_version = target_installed_version.or_else(|| match &receipt_state { + ReceiptState::Valid(receipt) => Some(receipt.installed_version.clone()), + ReceiptState::Missing | ReceiptState::Invalid => None, + }); + + let (state, managed) = match receipt_state { + ReceiptState::Valid(_) if !parent_safe => (PortableSkillState::Conflict, true), + ReceiptState::Valid(receipt) => { + if expected_matches { + (PortableSkillState::Current, true) + } else if installed_version.as_deref() != Some(expected_version.as_str()) + || receipt.installed_version != expected_version + || receipt.content_sha256 != bundled_content_sha256() + { + (PortableSkillState::UpdateAvailable, true) + } else { + (PortableSkillState::NeedsReinstall, true) + } + } + ReceiptState::Invalid => (PortableSkillState::Conflict, false), + ReceiptState::Missing if !parent_safe => (PortableSkillState::Conflict, false), + ReceiptState::Missing if !target_exists => (PortableSkillState::Missing, false), + ReceiptState::Missing if expected_matches => (PortableSkillState::UnmanagedCurrent, false), + ReceiptState::Missing => (PortableSkillState::Conflict, false), + }; + + Ok(StatusInspection { + status: PortableSkillStatus { + schema_version: STATUS_SCHEMA.to_string(), + name: SKILL_NAME.to_string(), + state, + target_display_path: SKILL_DISPLAY_PATH.to_string(), + expected_version, + installed_version, + managed, + target_exists, + can_install: matches!( + state, + PortableSkillState::Missing | PortableSkillState::UnmanagedCurrent + ), + can_update: state == PortableSkillState::UpdateAvailable, + can_reinstall: matches!( + state, + PortableSkillState::Current | PortableSkillState::NeedsReinstall + ), + can_uninstall: managed && state != PortableSkillState::Conflict, + }, + skills_identity, + target_identity, + }) +} + +fn install_at(app_home: &Path, home: &Path) -> Result { + let inspection = inspect_status_at(app_home, home)?; + if inspection.status.state == PortableSkillState::Conflict { + return Err(PetCoreError::Conflict( + "the portable skill target is not managed by Agent Pet Companion".to_string(), + )); + } + + let skills = ensure_managed_parent(home)?; + let current_skills_identity = + entry_identity(&rustix::fs::fstat(&skills).map_err(std::io::Error::from)?); + if inspection + .skills_identity + .is_some_and(|expected| expected != current_skills_identity) + || entry_identity_at(&skills, Path::new(SKILL_NAME))? != inspection.target_identity + { + return Err(PetCoreError::Conflict( + "the portable skill target changed while it was being inspected".to_string(), + )); + } + + let receipt = ManagedSkillReceipt { + schema_version: RECEIPT_SCHEMA.to_string(), + name: SKILL_NAME.to_string(), + target_display_path: SKILL_DISPLAY_PATH.to_string(), + installed_version: bundled_version()?, + content_sha256: bundled_content_sha256(), + }; + if inspection.status.state == PortableSkillState::UnmanagedCurrent { + let expected = inspection.target_identity.ok_or_else(|| { + PetCoreError::Conflict("the portable skill target disappeared".to_string()) + })?; + let Some(target) = open_directory_at(&skills, Path::new(SKILL_NAME), Some(expected))? + else { + return Err(PetCoreError::Conflict( + "the portable skill target changed before adoption".to_string(), + )); + }; + if !bundle_tree_matches_root(&target)? { + return Err(PetCoreError::Conflict( + "the portable skill target changed before adoption".to_string(), + )); + } + write_receipt(&receipt_path(app_home), &receipt)?; + return status_at(app_home, home); + } + + let staging_name = format!( + ".agent-pet-maker.apc-stage-{}", + uuid::Uuid::now_v7().simple() + ); + rustix::fs::mkdirat( + &skills, + Path::new(&staging_name), + rustix::fs::Mode::from_bits_truncate(0o700), + ) + .map_err(std::io::Error::from)?; + let staging = open_directory_at(&skills, Path::new(&staging_name), None)?.ok_or_else(|| { + PetCoreError::Conflict("portable skill staging directory is unsafe".to_string()) + })?; + if let Err(error) = write_skill_tree_at(&staging) { + let _ = remove_entry_at(&skills, Path::new(&staging_name)); + return Err(error); + } + drop(staging); + if let Err(error) = commit_staged_install( + &skills, + Path::new(&staging_name), + inspection.target_identity, + &receipt_path(app_home), + &receipt, + ) { + let _ = remove_entry_at(&skills, Path::new(&staging_name)); + return Err(error); + } + + status_at(app_home, home) +} + +fn uninstall_at(app_home: &Path, home: &Path) -> Result { + let inspection = inspect_status_at(app_home, home)?; + if !inspection.status.managed || inspection.status.state == PortableSkillState::Conflict { + return Err(PetCoreError::Conflict( + "the portable skill target is not managed by Agent Pet Companion".to_string(), + )); + } + + let DirectoryState::Ready(skills) = inspect_managed_parent(home)? else { + return Err(PetCoreError::Conflict( + "the portable skill parent changed while it was being inspected".to_string(), + )); + }; + let current_skills_identity = + entry_identity(&rustix::fs::fstat(&skills).map_err(std::io::Error::from)?); + if inspection.skills_identity != Some(current_skills_identity) + || entry_identity_at(&skills, Path::new(SKILL_NAME))? != inspection.target_identity + { + return Err(PetCoreError::Conflict( + "the portable skill target changed while it was being inspected".to_string(), + )); + } + if let Some(expected) = inspection.target_identity { + quarantine_and_remove_target_at(&skills, expected)?; + } + let receipt = receipt_path(app_home); + match fs::remove_file(receipt) { + Ok(()) => {} + Err(error) if error.kind() == ErrorKind::NotFound => {} + Err(error) => return Err(error.into()), + } + status_at(app_home, home) +} + +#[cfg(test)] +fn target_path(home: &Path) -> PathBuf { + home.join("agent").join("skills").join(SKILL_NAME) +} + +fn receipt_path(app_home: &Path) -> PathBuf { + app_home.join("managed-skills").join(RECEIPT_FILE_NAME) +} + +fn bundled_version() -> Result { + parse_skill_version(BUNDLED_SKILL_FILES[0].content).ok_or_else(|| { + PetCoreError::Validation("bundled agent-pet-maker version is invalid".to_string()) + }) +} + +fn parse_skill_version(content: &str) -> Option { + let mut delimiters = 0; + for line in content.lines() { + if line.trim() == "---" { + delimiters += 1; + if delimiters == 2 { + break; + } + continue; + } + if delimiters == 1 { + if let Some(value) = line.trim().strip_prefix("version:") { + let version = value.trim().trim_matches(['\'', '"']); + if !version.is_empty() + && version.len() <= 32 + && version + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b'-')) + { + return Some(version.to_string()); + } + } + } + } + None +} + +fn bundled_content_sha256() -> String { + let mut files = BUNDLED_SKILL_FILES.to_vec(); + files.sort_by_key(|file| file.relative_path); + let mut hasher = Sha256::new(); + hasher.update(b"agent-pet-companion/portable-skill/v1\0"); + for file in files { + hasher.update(file.relative_path.as_bytes()); + hasher.update(b"\0"); + hasher.update(file.mode.to_le_bytes()); + hasher.update(b"\0"); + hasher.update(file.content.as_bytes()); + hasher.update(b"\0"); + } + hex::encode(hasher.finalize()) +} + +fn entry_identity(metadata: &rustix::fs::Stat) -> EntryIdentity { + EntryIdentity { + dev: metadata.st_dev as u64, + ino: metadata.st_ino, + mode: metadata.st_mode, + } +} + +fn entry_identity_at(parent: &File, name: &Path) -> Result> { + match rustix::fs::statat(parent, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW) { + Ok(metadata) => Ok(Some(entry_identity(&metadata))), + Err(rustix::io::Errno::NOENT) => Ok(None), + Err(error) => Err(std::io::Error::from(error).into()), + } +} + +fn open_directory_at( + parent: &File, + name: &Path, + expected: Option, +) -> Result> { + let Some(observed) = entry_identity_at(parent, name)? else { + return Ok(None); + }; + if expected.is_some_and(|expected| expected != observed) + || !rustix::fs::FileType::from_raw_mode(observed.mode).is_dir() + { + return Ok(None); + } + let descriptor = match rustix::fs::openat( + parent, + name, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(None), + }; + let opened = entry_identity(&rustix::fs::fstat(&descriptor).map_err(std::io::Error::from)?); + if opened != observed || !rustix::fs::FileType::from_raw_mode(opened.mode).is_dir() { + return Ok(None); + } + Ok(Some(File::from(descriptor))) +} + +fn read_bounded_regular_file_at( + parent: &File, + name: &Path, + maximum_bytes: u64, +) -> Result>> { + let Some(observed) = entry_identity_at(parent, name)? else { + return Ok(None); + }; + let observed_stat = rustix::fs::statat(parent, name, rustix::fs::AtFlags::SYMLINK_NOFOLLOW) + .map_err(std::io::Error::from)?; + if entry_identity(&observed_stat) != observed + || !rustix::fs::FileType::from_raw_mode(observed.mode).is_file() + || observed_stat.st_size < 0 + || u64::try_from(observed_stat.st_size).unwrap_or(u64::MAX) > maximum_bytes + { + return Ok(None); + } + let descriptor = match rustix::fs::openat( + parent, + name, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(None), + }; + let opened_stat = rustix::fs::fstat(&descriptor).map_err(std::io::Error::from)?; + if entry_identity(&opened_stat) != observed + || opened_stat.st_size != observed_stat.st_size + || opened_stat.st_size < 0 + || u64::try_from(opened_stat.st_size).unwrap_or(u64::MAX) > maximum_bytes + { + return Ok(None); + } + let mut handle = File::from(descriptor); + let mut bytes = Vec::with_capacity( + usize::try_from(opened_stat.st_size) + .unwrap_or(0) + .min(maximum_bytes as usize), + ); + (&mut handle) + .take(maximum_bytes.saturating_add(1)) + .read_to_end(&mut bytes)?; + let final_stat = rustix::fs::fstat(&handle).map_err(std::io::Error::from)?; + if bytes.len() as u64 > maximum_bytes + || entry_identity(&final_stat) != observed + || final_stat.st_size != opened_stat.st_size + { + return Ok(None); + } + Ok(Some(bytes)) +} + +fn replace_target_at( + skills: &File, + staging_name: &Path, + expected_target: Option, +) -> Result<()> { + if expected_target.is_none() { + return rustix::fs::renameat_with( + skills, + staging_name, + skills, + Path::new(SKILL_NAME), + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from) + .map_err(Into::into); + } + let expected_target = expected_target.unwrap(); + let backup_name = format!( + ".agent-pet-maker.apc-backup-{}", + uuid::Uuid::now_v7().simple() + ); + rustix::fs::renameat_with( + skills, + Path::new(SKILL_NAME), + skills, + Path::new(&backup_name), + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from)?; + if entry_identity_at(skills, Path::new(&backup_name))? != Some(expected_target) { + let _ = rustix::fs::renameat_with( + skills, + Path::new(&backup_name), + skills, + Path::new(SKILL_NAME), + rustix::fs::RenameFlags::NOREPLACE, + ); + return Err(PetCoreError::Conflict( + "the portable skill target changed before replacement".to_string(), + )); + } + if let Err(error) = rustix::fs::renameat_with( + skills, + staging_name, + skills, + Path::new(SKILL_NAME), + rustix::fs::RenameFlags::NOREPLACE, + ) { + let _ = rustix::fs::renameat_with( + skills, + Path::new(&backup_name), + skills, + Path::new(SKILL_NAME), + rustix::fs::RenameFlags::NOREPLACE, + ); + return Err(std::io::Error::from(error).into()); + } + let _ = remove_entry_at_checked(skills, Path::new(&backup_name), expected_target); + Ok(()) +} + +fn commit_staged_install( + skills: &File, + staging_name: &Path, + expected_target: Option, + receipt_path: &Path, + receipt: &ManagedSkillReceipt, +) -> Result<()> { + replace_target_at(skills, staging_name, expected_target)?; + write_receipt(receipt_path, receipt) +} + +fn quarantine_and_remove_target_at(skills: &File, expected_target: EntryIdentity) -> Result<()> { + let quarantine_name = format!( + ".agent-pet-maker.apc-remove-{}", + uuid::Uuid::now_v7().simple() + ); + rustix::fs::renameat_with( + skills, + Path::new(SKILL_NAME), + skills, + Path::new(&quarantine_name), + rustix::fs::RenameFlags::NOREPLACE, + ) + .map_err(std::io::Error::from)?; + if entry_identity_at(skills, Path::new(&quarantine_name))? != Some(expected_target) { + let _ = rustix::fs::renameat_with( + skills, + Path::new(&quarantine_name), + skills, + Path::new(SKILL_NAME), + rustix::fs::RenameFlags::NOREPLACE, + ); + return Err(PetCoreError::Conflict( + "the portable skill target changed before removal".to_string(), + )); + } + remove_entry_at_checked(skills, Path::new(&quarantine_name), expected_target) +} + +fn installed_version(target: &File) -> Option { + read_bounded_regular_file_at(target, Path::new("SKILL.md"), 64 * 1024) + .ok() + .flatten() + .and_then(|bytes| String::from_utf8(bytes).ok()) + .and_then(|content| parse_skill_version(&content)) +} + +fn inspect_managed_parent(home: &Path) -> Result { + let descriptor = match rustix::fs::open( + home, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(DirectoryState::Unsafe), + }; + let mut directory = File::from(descriptor); + for component in ["agent", "skills"] { + let Some(identity) = entry_identity_at(&directory, Path::new(component))? else { + return Ok(DirectoryState::Missing); + }; + if !rustix::fs::FileType::from_raw_mode(identity.mode).is_dir() { + return Ok(DirectoryState::Unsafe); + } + let Some(child) = open_directory_at(&directory, Path::new(component), Some(identity))? + else { + return Ok(DirectoryState::Unsafe); + }; + directory = child; + } + Ok(DirectoryState::Ready(directory)) +} + +fn ensure_managed_parent(home: &Path) -> Result { + let descriptor = rustix::fs::open( + home, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .map_err(|_| PetCoreError::Conflict("the user home is not a safe directory".to_string()))?; + let mut directory = File::from(descriptor); + for component in ["agent", "skills"] { + let component = Path::new(component); + if entry_identity_at(&directory, component)?.is_none() { + match rustix::fs::mkdirat( + &directory, + component, + rustix::fs::Mode::from_bits_truncate(0o700), + ) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(error) => return Err(std::io::Error::from(error).into()), + } + } + let Some(identity) = entry_identity_at(&directory, component)? else { + return Err(PetCoreError::Conflict( + "the portable skill parent changed while it was being created".to_string(), + )); + }; + if !rustix::fs::FileType::from_raw_mode(identity.mode).is_dir() { + return Err(PetCoreError::Conflict( + "the portable skill parent is not a safe directory".to_string(), + )); + } + let Some(child) = open_directory_at(&directory, component, Some(identity))? else { + return Err(PetCoreError::Conflict( + "the portable skill parent changed while it was being opened".to_string(), + )); + }; + directory = child; + } + Ok(directory) +} + +#[cfg(test)] +fn write_skill_tree(root: &Path) -> Result<()> { + for file in BUNDLED_SKILL_FILES { + let destination = safe_join(root, file.relative_path)?; + if let Some(parent) = destination.parent() { + fs::create_dir_all(parent)?; + fs::set_permissions(parent, fs::Permissions::from_mode(0o700))?; + } + let mut handle = OpenOptions::new() + .write(true) + .create_new(true) + .mode(file.mode) + .open(&destination)?; + handle.write_all(file.content.as_bytes())?; + handle.sync_all()?; + fs::set_permissions(destination, fs::Permissions::from_mode(file.mode))?; + } + Ok(()) +} + +fn write_skill_tree_at(root: &File) -> Result<()> { + for bundled in BUNDLED_SKILL_FILES { + let relative = Path::new(bundled.relative_path); + let leaf = relative.file_name().ok_or_else(|| { + PetCoreError::Validation("portable skill contains an empty managed path".to_string()) + })?; + let mut directory = root.try_clone()?; + if let Some(parent) = relative.parent() { + for component in parent.components() { + let Component::Normal(name) = component else { + return Err(PetCoreError::Validation( + "portable skill contains an invalid managed path".to_string(), + )); + }; + let name = Path::new(name); + if entry_identity_at(&directory, name)?.is_none() { + match rustix::fs::mkdirat( + &directory, + name, + rustix::fs::Mode::from_bits_truncate(0o700), + ) { + Ok(()) | Err(rustix::io::Errno::EXIST) => {} + Err(error) => return Err(std::io::Error::from(error).into()), + } + } + let Some(identity) = entry_identity_at(&directory, name)? else { + return Err(PetCoreError::Conflict( + "portable skill staging directory changed".to_string(), + )); + }; + if !rustix::fs::FileType::from_raw_mode(identity.mode).is_dir() { + return Err(PetCoreError::Conflict( + "portable skill staging path is unsafe".to_string(), + )); + } + let Some(child) = open_directory_at(&directory, name, Some(identity))? else { + return Err(PetCoreError::Conflict( + "portable skill staging directory changed".to_string(), + )); + }; + directory = child; + } + } + let descriptor = rustix::fs::openat( + &directory, + Path::new(leaf), + rustix::fs::OFlags::WRONLY + | rustix::fs::OFlags::CREATE + | rustix::fs::OFlags::EXCL + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::from_bits_truncate(bundled.mode as rustix::fs::RawMode), + ) + .map_err(std::io::Error::from)?; + let mut handle = File::from(descriptor); + handle.write_all(bundled.content.as_bytes())?; + handle.sync_all()?; + rustix::fs::fchmod( + &handle, + rustix::fs::Mode::from_bits_truncate(bundled.mode as rustix::fs::RawMode), + ) + .map_err(std::io::Error::from)?; + } + Ok(()) +} + +#[cfg(test)] +fn safe_join(root: &Path, relative: &str) -> Result { + let relative = Path::new(relative); + if relative.is_absolute() + || relative + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + return Err(PetCoreError::Validation( + "portable skill contains an invalid managed path".to_string(), + )); + } + Ok(root.join(relative)) +} + +#[cfg(test)] +fn bundle_tree_matches(target: &Path) -> Result { + let descriptor = match rustix::fs::open( + target, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(false), + }; + let root = File::from(descriptor); + bundle_tree_matches_root(&root) +} + +fn bundle_tree_matches_root(root: &File) -> Result { + let opened = rustix::fs::fstat(root).map_err(std::io::Error::from)?; + if !rustix::fs::FileType::from_raw_mode(opened.st_mode).is_dir() + || !tree_contains_only_bundle(root)? + { + return Ok(false); + } + tree_matches_bundle(root) +} + +fn tree_matches_bundle(root: &File) -> Result { + for file in BUNDLED_SKILL_FILES { + if !regular_file_matches( + root, + file.relative_path, + file.content.as_bytes(), + file.mode & 0o111 != 0, + )? { + return Ok(false); + } + } + Ok(true) +} + +fn regular_file_matches( + root: &File, + relative: &str, + expected: &[u8], + expected_executable: bool, +) -> Result { + let components = Path::new(relative) + .components() + .map(|component| match component { + Component::Normal(name) => Ok(name.to_os_string()), + _ => Err(PetCoreError::Validation( + "portable skill contains an invalid managed path".to_string(), + )), + }) + .collect::>>()?; + let Some((leaf, parents)) = components.split_last() else { + return Err(PetCoreError::Validation( + "portable skill contains an empty managed path".to_string(), + )); + }; + let mut directory = root.try_clone()?; + for component in parents { + let observed = match rustix::fs::statat( + &directory, + Path::new(component), + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(observed) => observed, + Err(_) => return Ok(false), + }; + if !rustix::fs::FileType::from_raw_mode(observed.st_mode).is_dir() { + return Ok(false); + } + let descriptor = match rustix::fs::openat( + &directory, + Path::new(component), + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(false), + }; + let opened = rustix::fs::fstat(&descriptor).map_err(std::io::Error::from)?; + if !rustix::fs::FileType::from_raw_mode(opened.st_mode).is_dir() + || observed.st_dev != opened.st_dev + || observed.st_ino != opened.st_ino + { + return Ok(false); + } + directory = File::from(descriptor); + } + + let observed = match rustix::fs::statat( + &directory, + Path::new(leaf), + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(observed) => observed, + Err(_) => return Ok(false), + }; + if !rustix::fs::FileType::from_raw_mode(observed.st_mode).is_file() + || u64::try_from(observed.st_size).unwrap_or(u64::MAX) != expected.len() as u64 + || (observed.st_mode & 0o111 != 0) != expected_executable + { + return Ok(false); + } + let descriptor = match rustix::fs::openat( + &directory, + Path::new(leaf), + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(false), + }; + let opened = rustix::fs::fstat(&descriptor).map_err(std::io::Error::from)?; + if !rustix::fs::FileType::from_raw_mode(opened.st_mode).is_file() + || observed.st_dev != opened.st_dev + || observed.st_ino != opened.st_ino + || observed.st_size != opened.st_size + || u64::try_from(opened.st_size).unwrap_or(u64::MAX) != expected.len() as u64 + || (opened.st_mode & 0o111 != 0) != expected_executable + { + return Ok(false); + } + let mut handle = File::from(descriptor); + let mut observed = Vec::with_capacity(expected.len().saturating_add(1)); + (&mut handle) + .take(expected.len() as u64 + 1) + .read_to_end(&mut observed)?; + let final_metadata = rustix::fs::fstat(&handle).map_err(std::io::Error::from)?; + Ok(observed == expected + && final_metadata.st_dev == opened.st_dev + && final_metadata.st_ino == opened.st_ino + && final_metadata.st_size == opened.st_size + && final_metadata.st_mode == opened.st_mode) +} + +fn tree_contains_only_bundle(root: &File) -> Result { + let allowed_files = BUNDLED_SKILL_FILES + .iter() + .map(|file| PathBuf::from(file.relative_path)) + .collect::>(); + let mut allowed_directories = BTreeSet::new(); + for file in BUNDLED_SKILL_FILES { + let mut parent = Path::new(file.relative_path).parent(); + while let Some(path) = parent { + if !path.as_os_str().is_empty() { + allowed_directories.insert(path.to_path_buf()); + } + parent = path.parent(); + } + } + + let entry_limit = allowed_files.len() + allowed_directories.len(); + let mut entry_count = 0usize; + let mut pending = vec![(PathBuf::new(), root.try_clone()?)]; + while let Some((relative_parent, directory)) = pending.pop() { + let mut entries = rustix::fs::Dir::read_from(&directory).map_err(std::io::Error::from)?; + while let Some(entry) = entries.read() { + let entry = entry.map_err(std::io::Error::from)?; + let name = entry.file_name(); + if matches!(name.to_bytes(), b"." | b"..") { + continue; + } + entry_count = entry_count.saturating_add(1); + if entry_count > entry_limit { + return Ok(false); + } + let name_path = Path::new(std::ffi::OsStr::from_bytes(name.to_bytes())); + let relative = relative_parent.join(name_path); + let observed = match rustix::fs::statat( + &directory, + name_path, + rustix::fs::AtFlags::SYMLINK_NOFOLLOW, + ) { + Ok(observed) => observed, + Err(_) => return Ok(false), + }; + let file_type = rustix::fs::FileType::from_raw_mode(observed.st_mode); + if file_type.is_dir() { + if !allowed_directories.contains(&relative) { + return Ok(false); + } + let descriptor = match rustix::fs::openat( + &directory, + name_path, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(_) => return Ok(false), + }; + let opened = rustix::fs::fstat(&descriptor).map_err(std::io::Error::from)?; + if !rustix::fs::FileType::from_raw_mode(opened.st_mode).is_dir() + || observed.st_dev != opened.st_dev + || observed.st_ino != opened.st_ino + { + return Ok(false); + } + pending.push((relative, File::from(descriptor))); + } else if !file_type.is_file() || !allowed_files.contains(&relative) { + return Ok(false); + } + } + } + Ok(true) +} + +fn read_receipt(path: &Path) -> Result { + let descriptor = match rustix::fs::open( + path, + rustix::fs::OFlags::RDONLY | rustix::fs::OFlags::NOFOLLOW | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) { + Ok(descriptor) => descriptor, + Err(rustix::io::Errno::NOENT) => return Ok(ReceiptState::Missing), + Err(_) => return Ok(ReceiptState::Invalid), + }; + let opened = rustix::fs::fstat(&descriptor).map_err(std::io::Error::from)?; + if !rustix::fs::FileType::from_raw_mode(opened.st_mode).is_file() + || opened.st_size <= 0 + || u64::try_from(opened.st_size).unwrap_or(u64::MAX) > MAX_RECEIPT_BYTES + { + return Ok(ReceiptState::Invalid); + } + let mut handle = File::from(descriptor); + let mut bytes = Vec::with_capacity(usize::try_from(opened.st_size).unwrap_or(0)); + (&mut handle) + .take(MAX_RECEIPT_BYTES + 1) + .read_to_end(&mut bytes)?; + let final_metadata = rustix::fs::fstat(&handle).map_err(std::io::Error::from)?; + if bytes.len() as u64 > MAX_RECEIPT_BYTES + || final_metadata.st_dev != opened.st_dev + || final_metadata.st_ino != opened.st_ino + || final_metadata.st_size != opened.st_size + || final_metadata.st_mode != opened.st_mode + { + return Ok(ReceiptState::Invalid); + } + let receipt = match serde_json::from_slice::(&bytes).ok() { + Some(receipt) => receipt, + None => return Ok(ReceiptState::Invalid), + }; + if receipt.schema_version != RECEIPT_SCHEMA + || receipt.name != SKILL_NAME + || receipt.target_display_path != SKILL_DISPLAY_PATH + || receipt.installed_version.is_empty() + || receipt.installed_version.len() > 32 + || receipt.content_sha256.len() != 64 + || !receipt + .content_sha256 + .bytes() + .all(|byte| byte.is_ascii_hexdigit()) + { + return Ok(ReceiptState::Invalid); + } + Ok(ReceiptState::Valid(receipt)) +} + +fn write_receipt(path: &Path, receipt: &ManagedSkillReceipt) -> Result<()> { + let parent = path.parent().ok_or_else(|| { + PetCoreError::Validation("portable skill receipt has no parent".to_string()) + })?; + ensure_private_directory(parent)?; + let bytes = serde_json::to_vec_pretty(receipt)?; + let temporary = parent.join(format!( + ".agent-pet-maker.receipt-{}", + uuid::Uuid::now_v7().simple() + )); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(&bytes)?; + file.write_all(b"\n")?; + file.sync_all()?; + drop(file); + fs::set_permissions(&temporary, fs::Permissions::from_mode(0o600))?; + if let Err(error) = fs::rename(&temporary, path) { + let _ = fs::remove_file(&temporary); + return Err(error.into()); + } + Ok(()) +} + +fn ensure_private_directory(path: &Path) -> Result<()> { + match fs::symlink_metadata(path) { + Ok(metadata) if metadata.is_dir() && !metadata.file_type().is_symlink() => {} + Ok(_) => { + return Err(PetCoreError::Conflict( + "portable skill receipt directory is unsafe".to_string(), + )); + } + Err(error) if error.kind() == ErrorKind::NotFound => fs::create_dir_all(path)?, + Err(error) => return Err(error.into()), + } + fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + Ok(()) +} + +fn remove_entry_at(parent: &File, name: &Path) -> Result<()> { + let Some(expected) = entry_identity_at(parent, name)? else { + return Ok(()); + }; + remove_entry_at_checked(parent, name, expected) +} + +fn remove_entry_at_checked(parent: &File, name: &Path, expected: EntryIdentity) -> Result<()> { + let mut budget = MAX_MANAGED_REMOVAL_ENTRIES; + remove_entry_at_checked_with_budget(parent, name, expected, &mut budget) +} + +fn remove_entry_at_checked_with_budget( + parent: &File, + name: &Path, + expected: EntryIdentity, + budget: &mut usize, +) -> Result<()> { + if *budget == 0 { + return Err(PetCoreError::Conflict( + "portable skill removal exceeded its bounded entry budget".to_string(), + )); + } + *budget -= 1; + if entry_identity_at(parent, name)? != Some(expected) { + return Err(PetCoreError::Conflict( + "portable skill content changed during removal".to_string(), + )); + } + if rustix::fs::FileType::from_raw_mode(expected.mode).is_dir() { + let Some(directory) = open_directory_at(parent, name, Some(expected))? else { + return Err(PetCoreError::Conflict( + "portable skill directory changed during removal".to_string(), + )); + }; + let mut entries = rustix::fs::Dir::read_from(&directory).map_err(std::io::Error::from)?; + while let Some(entry) = entries.read() { + let entry = entry.map_err(std::io::Error::from)?; + let entry_name = entry.file_name(); + if matches!(entry_name.to_bytes(), b"." | b"..") { + continue; + } + let entry_path = Path::new(std::ffi::OsStr::from_bytes(entry_name.to_bytes())); + let Some(child_identity) = entry_identity_at(&directory, entry_path)? else { + continue; + }; + remove_entry_at_checked_with_budget(&directory, entry_path, child_identity, budget)?; + } + if entry_identity_at(parent, name)? != Some(expected) { + return Err(PetCoreError::Conflict( + "portable skill directory changed before final removal".to_string(), + )); + } + rustix::fs::unlinkat(parent, name, rustix::fs::AtFlags::REMOVEDIR) + .map_err(std::io::Error::from)?; + } else { + rustix::fs::unlinkat(parent, name, rustix::fs::AtFlags::empty()) + .map_err(std::io::Error::from)?; + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::os::unix::fs::symlink; + + #[test] + fn install_reinstall_and_uninstall_manage_the_fixed_target() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(&home).unwrap(); + + let initial = status_at(&app_home, &home).unwrap(); + assert_eq!(initial.state, PortableSkillState::Missing); + assert!(!initial.managed); + + let installed = install_at(&app_home, &home).unwrap(); + assert_eq!(installed.state, PortableSkillState::Current); + assert!(installed.managed); + assert!(installed.can_reinstall); + + fs::write(target_path(&home).join("SKILL.md"), "locally changed\n").unwrap(); + fs::write(target_path(&home).join("local-note.txt"), "replace me").unwrap(); + let changed = status_at(&app_home, &home).unwrap(); + assert_eq!(changed.state, PortableSkillState::NeedsReinstall); + assert!(changed.can_reinstall); + + let repaired = install_at(&app_home, &home).unwrap(); + assert_eq!(repaired.state, PortableSkillState::Current); + assert!(bundle_tree_matches(&target_path(&home)).unwrap()); + assert!(!target_path(&home).join("local-note.txt").exists()); + + fs::write( + target_path(&home).join("extra-local-file.txt"), + "replace me", + ) + .unwrap(); + assert_eq!( + status_at(&app_home, &home).unwrap().state, + PortableSkillState::NeedsReinstall + ); + assert_eq!( + install_at(&app_home, &home).unwrap().state, + PortableSkillState::Current + ); + assert!(!target_path(&home).join("extra-local-file.txt").exists()); + + let skill_path = target_path(&home).join("SKILL.md"); + let current_version_line = format!("version: {}", bundled_version().unwrap()); + let older = fs::read_to_string(&skill_path).unwrap().replacen( + current_version_line.as_str(), + "version: 0.0.0", + 1, + ); + fs::write(&skill_path, older).unwrap(); + let outdated = status_at(&app_home, &home).unwrap(); + assert_eq!(outdated.state, PortableSkillState::UpdateAvailable); + assert!(outdated.can_update); + assert_eq!( + install_at(&app_home, &home).unwrap().state, + PortableSkillState::Current + ); + + fs::write(target_path(&home).join("delete-with-root.txt"), "delete me").unwrap(); + + let removed = uninstall_at(&app_home, &home).unwrap(); + assert_eq!(removed.state, PortableSkillState::Missing); + assert!(!target_path(&home).exists()); + } + + #[test] + fn foreign_target_is_preserved_and_rejected() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + let target = target_path(&home); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(&target).unwrap(); + fs::write(target.join("foreign.txt"), "keep me").unwrap(); + + let status = status_at(&app_home, &home).unwrap(); + assert_eq!(status.state, PortableSkillState::Conflict); + assert!(install_at(&app_home, &home).is_err()); + assert_eq!( + fs::read_to_string(target.join("foreign.txt")).unwrap(), + "keep me" + ); + } + + #[test] + fn empty_unmanaged_target_is_preserved_as_a_conflict() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + let target = target_path(&home); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(&target).unwrap(); + + let status = status_at(&app_home, &home).unwrap(); + assert_eq!(status.state, PortableSkillState::Conflict); + assert!(!status.can_install); + assert!(install_at(&app_home, &home).is_err()); + assert!(target.is_dir()); + } + + #[test] + fn unmanaged_root_symlink_is_not_followed_for_version_or_install() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(home.join("agent/skills")).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write( + outside.join("SKILL.md"), + "---\nversion: 99.99.99\n---\nexternal\n", + ) + .unwrap(); + symlink(&outside, target_path(&home)).unwrap(); + + let status = status_at(&app_home, &home).unwrap(); + assert_eq!(status.state, PortableSkillState::Conflict); + assert_eq!(status.installed_version, None); + assert!(install_at(&app_home, &home).is_err()); + assert_eq!( + fs::read_to_string(outside.join("SKILL.md")).unwrap(), + "---\nversion: 99.99.99\n---\nexternal\n" + ); + } + + #[test] + fn unmanaged_adoption_rejects_a_wrong_length_before_reading_file_content() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("agent-pet-maker"); + fs::create_dir_all(&target).unwrap(); + let skill = target.join("SKILL.md"); + fs::write(&skill, "x").unwrap(); + fs::set_permissions(&skill, fs::Permissions::from_mode(0o000)).unwrap(); + + assert!(!bundle_tree_matches(&target).unwrap()); + } + + #[test] + fn unmanaged_adoption_never_follows_a_nested_directory_symlink() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("agent-pet-maker"); + let outside = temp.path().join("outside-references"); + fs::create_dir_all(&target).unwrap(); + fs::create_dir_all(&outside).unwrap(); + let bundled = BUNDLED_SKILL_FILES + .iter() + .find(|file| file.relative_path == "references/security.md") + .unwrap(); + fs::write(outside.join("security.md"), bundled.content).unwrap(); + symlink(&outside, target.join("references")).unwrap(); + + let descriptor = rustix::fs::open( + &target, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let root = File::from(descriptor); + assert!(!regular_file_matches( + &root, + bundled.relative_path, + bundled.content.as_bytes(), + false, + ) + .unwrap()); + } + + #[test] + fn installed_version_never_follows_a_replaced_skill_file() { + let temp = tempfile::tempdir().unwrap(); + let target = temp.path().join("agent-pet-maker"); + let outside = temp.path().join("outside-skill.md"); + fs::create_dir_all(&target).unwrap(); + fs::write(&outside, "---\nversion: 99.99.99\n---\nexternal\n").unwrap(); + symlink(&outside, target.join("SKILL.md")).unwrap(); + let descriptor = rustix::fs::open( + &target, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + + assert_eq!(installed_version(&File::from(descriptor)), None); + } + + #[test] + fn replacement_preserves_a_target_whose_identity_changed_after_inspection() { + let temp = tempfile::tempdir().unwrap(); + let skills_path = temp.path().join("skills"); + fs::create_dir_all(skills_path.join(SKILL_NAME)).unwrap(); + fs::create_dir(skills_path.join("stage")).unwrap(); + let descriptor = rustix::fs::open( + &skills_path, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let skills = File::from(descriptor); + let expected = entry_identity_at(&skills, Path::new(SKILL_NAME)) + .unwrap() + .unwrap(); + fs::rename( + skills_path.join(SKILL_NAME), + skills_path.join("original-target"), + ) + .unwrap(); + fs::create_dir(skills_path.join(SKILL_NAME)).unwrap(); + fs::write(skills_path.join(SKILL_NAME).join("keep.txt"), "foreign").unwrap(); + + assert!(replace_target_at(&skills, Path::new("stage"), Some(expected)).is_err()); + assert_eq!( + fs::read_to_string(skills_path.join(SKILL_NAME).join("keep.txt")).unwrap(), + "foreign" + ); + assert!(skills_path.join("original-target").is_dir()); + assert!(skills_path.join("stage").is_dir()); + } + + #[test] + fn failed_missing_install_never_leaves_an_ownership_receipt() { + let temp = tempfile::tempdir().unwrap(); + let skills_path = temp.path().join("skills"); + fs::create_dir_all(&skills_path).unwrap(); + fs::create_dir(skills_path.join("stage")).unwrap(); + fs::create_dir(skills_path.join(SKILL_NAME)).unwrap(); + fs::write(skills_path.join(SKILL_NAME).join("keep.txt"), "foreign").unwrap(); + let descriptor = rustix::fs::open( + &skills_path, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let receipt = temp.path().join("receipts/agent-pet-maker.json"); + let managed_receipt = ManagedSkillReceipt { + schema_version: RECEIPT_SCHEMA.to_string(), + name: SKILL_NAME.to_string(), + target_display_path: SKILL_DISPLAY_PATH.to_string(), + installed_version: bundled_version().unwrap(), + content_sha256: bundled_content_sha256(), + }; + + assert!(commit_staged_install( + &File::from(descriptor), + Path::new("stage"), + None, + &receipt, + &managed_receipt, + ) + .is_err()); + assert!(!receipt.exists()); + assert_eq!( + fs::read_to_string(skills_path.join(SKILL_NAME).join("keep.txt")).unwrap(), + "foreign" + ); + } + + #[test] + fn removal_preserves_a_target_whose_identity_changed_after_inspection() { + let temp = tempfile::tempdir().unwrap(); + let skills_path = temp.path().join("skills"); + fs::create_dir_all(skills_path.join(SKILL_NAME)).unwrap(); + let descriptor = rustix::fs::open( + &skills_path, + rustix::fs::OFlags::RDONLY + | rustix::fs::OFlags::DIRECTORY + | rustix::fs::OFlags::NOFOLLOW + | rustix::fs::OFlags::CLOEXEC, + rustix::fs::Mode::empty(), + ) + .unwrap(); + let skills = File::from(descriptor); + let expected = entry_identity_at(&skills, Path::new(SKILL_NAME)) + .unwrap() + .unwrap(); + fs::rename( + skills_path.join(SKILL_NAME), + skills_path.join("original-target"), + ) + .unwrap(); + fs::create_dir(skills_path.join(SKILL_NAME)).unwrap(); + fs::write(skills_path.join(SKILL_NAME).join("keep.txt"), "foreign").unwrap(); + + assert!(quarantine_and_remove_target_at(&skills, expected).is_err()); + assert_eq!( + fs::read_to_string(skills_path.join(SKILL_NAME).join("keep.txt")).unwrap(), + "foreign" + ); + assert!(skills_path.join("original-target").is_dir()); + } + + #[test] + fn exact_unmanaged_bundle_can_be_adopted() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + let target = target_path(&home); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(&target).unwrap(); + write_skill_tree(&target).unwrap(); + + let status = status_at(&app_home, &home).unwrap(); + assert_eq!(status.state, PortableSkillState::UnmanagedCurrent); + assert!(status.can_install); + assert_eq!( + install_at(&app_home, &home).unwrap().state, + PortableSkillState::Current + ); + } + + #[test] + fn managed_root_symlink_is_replaced_without_following_it() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + let outside = temp.path().join("outside"); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(&home).unwrap(); + fs::create_dir_all(&outside).unwrap(); + fs::write(outside.join("keep.txt"), "safe").unwrap(); + install_at(&app_home, &home).unwrap(); + + fs::remove_dir_all(target_path(&home)).unwrap(); + symlink(&outside, target_path(&home)).unwrap(); + let status = status_at(&app_home, &home).unwrap(); + assert!(status.managed); + assert_eq!(status.state, PortableSkillState::NeedsReinstall); + + let repaired = install_at(&app_home, &home).unwrap(); + assert_eq!(repaired.state, PortableSkillState::Current); + assert_eq!( + fs::read_to_string(outside.join("keep.txt")).unwrap(), + "safe" + ); + assert!(!fs::symlink_metadata(target_path(&home)) + .unwrap() + .file_type() + .is_symlink()); + } + + #[test] + fn unsafe_parent_is_never_followed_for_managed_uninstall() { + let temp = tempfile::tempdir().unwrap(); + let app_home = temp.path().join("app-home"); + let home = temp.path().join("user-home"); + let moved_agent_root = temp.path().join("moved-agent-root"); + fs::create_dir_all(&app_home).unwrap(); + fs::create_dir_all(&home).unwrap(); + install_at(&app_home, &home).unwrap(); + + fs::rename(home.join("agent"), &moved_agent_root).unwrap(); + symlink(&moved_agent_root, home.join("agent")).unwrap(); + + let status = status_at(&app_home, &home).unwrap(); + assert!(status.managed); + assert_eq!(status.state, PortableSkillState::Conflict); + assert!(uninstall_at(&app_home, &home).is_err()); + assert!(moved_agent_root.join("skills").join(SKILL_NAME).exists()); + } +} diff --git a/crates/petcore/src/rpc.rs b/crates/petcore/src/rpc.rs index 8b58f0a..b84fd32 100644 --- a/crates/petcore/src/rpc.rs +++ b/crates/petcore/src/rpc.rs @@ -22,6 +22,7 @@ use crate::metrics; use crate::paths::AppPaths; use crate::pet_revision; use crate::petpack; +use crate::portable_skill; use crate::runtime_manifest::RuntimeReleaseManifest; use crate::{app_server, enum_from_name, enum_name, new_id, now_rfc3339, PetCoreError, Result}; use petcore_types::{ @@ -191,6 +192,7 @@ struct SessionDisplayCacheKey { #[derive(Debug, Clone, Default)] struct PersistedSessionDisplay { latest_message: Option, + latest_narrative_activity: Option, latest_user_message: Option, first_user_message: Option, latest_title: Option, @@ -266,6 +268,7 @@ impl SnapshotPersistedDisplayCache { value, } if matched_revision == state_revision => PersistedSessionDisplay { latest_message: value.latest_assistant, + latest_narrative_activity: value.latest_narrative_activity, latest_user_message: value.latest_user, first_user_message: value.first_user, latest_title: value.latest_title, @@ -1538,6 +1541,9 @@ fn known_rpc_method(method: &str) -> bool { | "connections.refresh_installed" | "connections.uninstall" | "connections.test" + | "portable_skill.status" + | "portable_skill.install" + | "portable_skill.uninstall" | "product.convergence.get" | "product.convergence.update" | "product.convergence.preflight" @@ -1559,6 +1565,9 @@ fn validate_method_params(method: &str, params: &Value) -> Result<()> { | "codex.app_server.probe" | "connections.receipts" | "connections.refresh_installed" + | "portable_skill.status" + | "portable_skill.install" + | "portable_skill.uninstall" | "product.convergence.get" | "product.convergence.preflight" => &[], "petcore.shutdown" => &["expected_instance_id"], @@ -2622,6 +2631,17 @@ fn handle_request_inner(state: &CoreState, request: RpcRequest) -> Result persisted?; Ok(json!(status)) } + "portable_skill.status" => Ok(json!(portable_skill::status(&state.paths)?)), + "portable_skill.install" => { + let _operation = state.begin_connection_operation()?; + let _host_guard = state.agent_host_process_guard(); + Ok(json!(portable_skill::install(&state.paths)?)) + } + "portable_skill.uninstall" => { + let _operation = state.begin_connection_operation()?; + let _host_guard = state.agent_host_process_guard(); + Ok(json!(portable_skill::uninstall(&state.paths)?)) + } "connections.test" => { let source = required_source(&request.params)?; let _operation = state.begin_connection_operation()?; @@ -3002,28 +3022,34 @@ fn hydrate_agent_session_display( return Ok(false); }; let latest_message = persisted.latest_message; + let latest_narrative_activity = persisted.latest_narrative_activity; let latest_user_message = persisted.latest_user_message; let first_user_message = persisted.first_user_message; let latest_title = persisted.latest_title; - let latest_message = latest_message.filter(|message| { - if let Some(user) = latest_user_message.as_ref() { - sequenced_event_happened_after(message, user) - } else if let Some(cutoff) = active.session_activated_at.as_deref() { - event_happened_after(&message.event.created_at, cutoff) - } else { - true - } - }); - active.latest_message = latest_message.map(|sequenced| sequenced.event); + active.latest_message = latest_message + .as_ref() + .map(|sequenced| sequenced.event.clone()); active.latest_user_message = latest_user_message.map(|sequenced| sequenced.event); active.session_title = latest_title .as_ref() .and_then(projected_session_title) .or_else(|| first_user_message.as_ref().and_then(fallback_session_title)); - active.session_message = active - .latest_message - .as_ref() - .and_then(event_display_message); + active.session_message = None; + active.session_activity = None; + let narrative_activity_is_latest = latest_narrative_activity.as_ref().is_some_and(|activity| { + latest_message + .as_ref() + .is_none_or(|message| sequenced_event_happened_after(activity, message)) + }); + if narrative_activity_is_latest { + active.session_activity = latest_narrative_activity + .as_ref() + .and_then(|activity| agent_state::event_narrative_activity(&activity.event)); + } else { + active.session_message = latest_message + .as_ref() + .and_then(|message| event_display_message(&message.event)); + } active.session_user_message = active .latest_user_message .as_ref() @@ -3068,6 +3094,7 @@ fn hydrate_agent_session_display( role: message.role, content: message.content, }); + active.session_activity = None; } if let Some(message) = display.latest_user_message { active.session_user_message = Some(agent_state::SessionDisplayMessage { @@ -3079,24 +3106,28 @@ fn hydrate_agent_session_display( .latest_activity .filter(|activity| activity.is_current) { - if should_replace_hydrated_codex_activity(active.session_activity.as_ref(), &activity) { - if matches!( - active.event.event_type, - AgentEventType::Start - | AgentEventType::Thinking - | AgentEventType::Plan - | AgentEventType::Tool - ) { - if let Some(summary_kind) = - agent_state::overlay_activity_summary_kind(&activity.kind) - { - active.overlay_display.summary_kind = summary_kind; - } + if matches!( + active.event.event_type, + AgentEventType::Start + | AgentEventType::Thinking + | AgentEventType::Plan + | AgentEventType::Tool + ) { + if let Some(summary_kind) = agent_state::overlay_activity_summary_kind(&activity.kind) { + active.overlay_display.summary_kind = summary_kind; } + } + } + if let Some(activity) = display + .latest_narrative_activity + .filter(|activity| activity.is_current) + { + if should_replace_hydrated_codex_activity(active.session_activity.as_ref(), &activity) { active.session_activity = Some(agent_state::SessionActivity { kind: activity.kind, content: activity.content, }); + active.session_message = None; } } Ok(true) @@ -3178,12 +3209,6 @@ fn fallback_session_title(event: &AgentEvent) -> Option { Some(shortened) } -fn event_happened_after(candidate: &str, cutoff: &str) -> bool { - let candidate = OffsetDateTime::parse(candidate, &Rfc3339); - let cutoff = OffsetDateTime::parse(cutoff, &Rfc3339); - matches!((candidate, cutoff), (Ok(candidate), Ok(cutoff)) if candidate > cutoff) -} - fn sequenced_event_happened_after( candidate: &agent_state::SequencedAgentEvent, cutoff: &agent_state::SequencedAgentEvent, @@ -5249,4 +5274,17 @@ done assert!(events.is_empty()); } + + #[test] + fn portable_skill_methods_are_closed_no_parameter_rpc_contracts() { + for method in [ + "portable_skill.status", + "portable_skill.install", + "portable_skill.uninstall", + ] { + assert!(known_rpc_method(method)); + assert!(validate_method_params(method, &json!({})).is_ok()); + assert!(validate_method_params(method, &json!({ "path": "/tmp/foreign" })).is_err()); + } + } } diff --git a/crates/petcore/tests/agent_state_arbitration.rs b/crates/petcore/tests/agent_state_arbitration.rs index 03b3e9f..67e3c5e 100644 --- a/crates/petcore/tests/agent_state_arbitration.rs +++ b/crates/petcore/tests/agent_state_arbitration.rs @@ -168,6 +168,40 @@ fn blocked_attention_state_keeps_priority_over_newer_running_work() { ); } +#[test] +fn pi_model_unavailable_input_projects_an_immediate_failed_bubble() { + let (_temp, state) = ready(); + ingest_source_payload( + &state, + "pi", + "pi-model-unavailable", + "pi-no-model-session", + "failed", + ×tamp(1), + json!({ + "source_event": "input", + "outcome": "model_unavailable", + "affects_activity": true, + "session_active": false, + "session_open": true, + "message_role": "user", + "message_content": "Prompt without a selected model" + }), + ); + + let current_snapshot = snapshot(&state); + assert_eq!(current_snapshot["active_agent_state"]["source"], "pi"); + assert_eq!(current_snapshot["active_agent_state"]["state"], "failed"); + assert_eq!( + current_snapshot["active_agent_state"]["official_status"], + "blocked" + ); + assert_eq!( + current_snapshot["active_agent_state"]["overlay_display"]["summary_kind"], + "failed" + ); +} + #[test] fn stale_event_does_not_override_current_state() { let (_temp, state) = ready(); @@ -1813,7 +1847,7 @@ fn overlay_projection_allows_display_messages_but_excludes_private_event_fields( assert!(tool["session_id"].as_str().unwrap().starts_with("ses-")); assert_eq!( tool["session_activity"], - json!({"kind": "command", "content": command}), + Value::Null, "tool projection: {tool:#}" ); assert_eq!(tool["overlay_display"]["summary_kind"], "command"); @@ -2188,7 +2222,7 @@ fn waiting_without_session_active_persists_beyond_the_activity_lease() { } #[test] -fn new_user_activation_hides_previous_turn_reply_until_agent_responds() { +fn new_user_activation_retains_previous_reply_until_agent_responds() { let (_temp, state) = ready(); let ingest_message = |id: &str, event_type: &str, role: &str, content: &str, seconds_ago: i64| { @@ -2225,6 +2259,10 @@ fn new_user_activation_hides_previous_turn_reply_until_agent_responds() { .find(|session| session["session_id"] == projected_session_id("turn-boundary")) .unwrap(); assert_eq!(session["overlay_display"]["summary_kind"], "start"); + assert_eq!( + session["session_message"], + json!({"role": "assistant", "content": "上一轮回复"}) + ); ingest_message("new-reply", "done", "assistant", "本轮回复", 0); let responded = snapshot(&state); @@ -2238,6 +2276,188 @@ fn new_user_activation_hides_previous_turn_reply_until_agent_responds() { assert_eq!(session["overlay_display"]["summary_kind"], "done"); } +#[test] +fn every_agent_keeps_only_the_latest_agent_or_thinking_message_across_tool_activity() { + let (_temp, state) = ready(); + + for source in ["codex", "claude_code", "pi", "opencode"] { + let session_id = format!("{source}-single-bubble-message"); + let event_id = |suffix: &str| format!("{session_id}-{suffix}"); + let session = || { + let current = snapshot(&state); + current["active_agent_sessions"] + .as_array() + .unwrap() + .iter() + .find(|candidate| candidate["session_id"] == projected_session_id(&session_id)) + .unwrap() + .clone() + }; + + ingest_source_payload( + &state, + source, + &event_id("prompt"), + &session_id, + "start", + ×tamp(50), + json!({ + "source_event": "user.prompt", + "session_active": true, + "message_role": "user", + "message_content": "检查当前工作" + }), + ); + ingest_source_payload( + &state, + source, + &event_id("agent-message"), + &session_id, + "start", + ×tamp(40), + json!({ + "source_event": "agent.message", + "session_active": true, + "message_role": "assistant", + "message_content": "我先检查工作树。" + }), + ); + let agent_message = session(); + assert_eq!( + agent_message["session_message"], + json!({"role": "assistant", "content": "我先检查工作树。"}), + "source={source}" + ); + assert_eq!( + agent_message["session_activity"], + Value::Null, + "source={source}" + ); + + ingest_source_payload( + &state, + source, + &event_id("tool-after-message"), + &session_id, + "tool", + ×tamp(30), + json!({ + "source_event": "tool.started", + "session_active": true, + "activity_kind": "command", + "activity_content": "git status --short" + }), + ); + let tool_after_message = session(); + assert_eq!(tool_after_message["event"]["event_type"], "tool"); + assert_eq!( + tool_after_message["session_message"], + json!({"role": "assistant", "content": "我先检查工作树。"}), + "source={source}" + ); + assert_eq!( + tool_after_message["session_activity"], + Value::Null, + "tool activity must not become bubble body copy for source={source}" + ); + + ingest_source_payload( + &state, + source, + &event_id("thinking"), + &session_id, + "thinking", + ×tamp(20), + json!({ + "source_event": "agent.thinking", + "session_active": true, + "activity_kind": "thinking", + "activity_content": "工作树干净,接下来核对最近提交。" + }), + ); + let thinking = session(); + assert_eq!( + thinking["session_message"], + Value::Null, + "newer thinking must replace the older Agent message for source={source}" + ); + assert_eq!( + thinking["session_activity"], + json!({"kind": "thinking", "content": "工作树干净,接下来核对最近提交。"}), + "source={source}" + ); + + ingest_source_payload( + &state, + source, + &event_id("tool-after-thinking"), + &session_id, + "tool", + ×tamp(10), + json!({ + "source_event": "tool.completed", + "session_active": true + }), + ); + let tool_after_thinking = session(); + assert_eq!(tool_after_thinking["event"]["event_type"], "tool"); + assert_eq!(tool_after_thinking["session_message"], Value::Null); + assert_eq!( + tool_after_thinking["session_activity"], + json!({"kind": "thinking", "content": "工作树干净,接下来核对最近提交。"}), + "tool churn must retain the latest thinking for source={source}" + ); + + ingest_source_payload( + &state, + source, + &event_id("next-user"), + &session_id, + "start", + ×tamp(5), + json!({ + "source_event": "user.prompt", + "session_active": true, + "message_role": "user", + "message_content": "继续" + }), + ); + let user_after_thinking = session(); + assert_eq!(user_after_thinking["session_message"], Value::Null); + assert_eq!( + user_after_thinking["session_activity"], + json!({"kind": "thinking", "content": "工作树干净,接下来核对最近提交。"}), + "user and lifecycle events must retain the latest thinking for source={source}" + ); + + ingest_source_payload( + &state, + source, + &event_id("new-agent-message"), + &session_id, + "start", + ×tamp(0), + json!({ + "source_event": "agent.message", + "session_active": true, + "message_role": "assistant", + "message_content": "最近提交包含连接器调整。" + }), + ); + let newest_agent_message = session(); + assert_eq!( + newest_agent_message["session_message"], + json!({"role": "assistant", "content": "最近提交包含连接器调整。"}), + "source={source}" + ); + assert_eq!( + newest_agent_message["session_activity"], + Value::Null, + "newer Agent message must replace the older thinking for source={source}" + ); + } +} + #[test] fn equal_timestamp_messages_follow_persisted_arrival_order() { let (_temp, state) = ready(); @@ -2331,7 +2551,10 @@ fn equal_timestamp_messages_follow_persisted_arrival_order() { reactivated_session["session_user_message"], json!({"role": "user", "content": "后到的新问题"}) ); - assert_eq!(reactivated_session["session_message"], Value::Null); + assert_eq!( + reactivated_session["session_message"], + json!({"role": "assistant", "content": "上一轮旧回复"}) + ); } #[test] @@ -3348,7 +3571,11 @@ fn every_agent_refreshes_reply_completion_and_next_user_turn() { "source={source}" ); assert_eq!(session["session_title"], "第一条问题", "source={source}"); - assert_eq!(session["session_message"], Value::Null, "source={source}"); + assert_eq!( + session["session_message"], + json!({"role": "assistant", "content": "第一条完整回复"}), + "source={source}" + ); } } @@ -3578,13 +3805,7 @@ fn legacy_claude_display_projection_sanitizes_attachment_title_and_tool_envelope "content": "以上文档是针对本项目宠物相关调研、优化方案。" }) ); - assert_eq!( - session["session_activity"], - json!({ - "kind": "thinking", - "content": "=== PetAnimationContract / Quality checks passed ===" - }) - ); + assert_eq!(session["session_activity"], Value::Null); let serialized = serde_json::to_string(session).unwrap(); for forbidden in [ @@ -3594,6 +3815,7 @@ fn legacy_claude_display_projection_sanitizes_attachment_title_and_tool_envelope "\"noOutputExpected\"", "\"stderr\"", "\"stdout\"", + "PetAnimationContract", ] { assert!( !serialized.contains(forbidden), @@ -3603,7 +3825,7 @@ fn legacy_claude_display_projection_sanitizes_attachment_title_and_tool_envelope } #[test] -fn legacy_structured_activity_rows_project_only_readable_scalars_for_every_agent() { +fn legacy_structured_tool_activity_rows_never_enter_bubble_copy_for_any_agent() { let (_temp, state) = ready(); let cases = [ ( @@ -3743,9 +3965,8 @@ fn legacy_structured_activity_rows_project_only_readable_scalars_for_every_agent .iter() .find(|session| session["session_id"] == projected_session_id(&session_id)) .unwrap(); - assert_eq!(session["session_activity"]["content"], expected); - let content = session["session_activity"]["content"].as_str().unwrap(); - assert!(!content.contains('{') && !content.contains('}')); + assert_eq!(session["session_activity"], Value::Null); + assert!(!serde_json::to_string(session).unwrap().contains(expected)); } for index in 0..3 { let session_id = format!("legacy-sensitive-{index}"); diff --git a/crates/petcore/tests/app_server_transport.rs b/crates/petcore/tests/app_server_transport.rs index 2ccb0d6..5625699 100644 --- a/crates/petcore/tests/app_server_transport.rs +++ b/crates/petcore/tests/app_server_transport.rs @@ -632,7 +632,7 @@ done } #[test] -fn thread_display_does_not_reuse_reply_from_before_latest_user_message() { +fn thread_display_retains_reply_until_new_agent_or_thinking_content_arrives() { let _lock = ENV_LOCK.lock().unwrap(); let temp = tempfile::tempdir().unwrap(); let script = temp.path().join("thread-display-turn-boundary.sh"); @@ -656,7 +656,7 @@ done let _command = EnvGuard::set("CODEX_APP_SERVER_CMD", script.as_os_str()); let display = read_codex_thread_display("019f5a6f-0c52-75e1-b652-004d4487c4ae").unwrap(); - assert!(display.latest_message.is_none()); + assert_eq!(display.latest_message.unwrap().content, "Previous reply"); assert_eq!(display.latest_user_message.unwrap().content, "New prompt"); } diff --git a/crates/petcore/tests/connector_contracts.rs b/crates/petcore/tests/connector_contracts.rs index 190db37..6778617 100644 --- a/crates/petcore/tests/connector_contracts.rs +++ b/crates/petcore/tests/connector_contracts.rs @@ -1260,7 +1260,7 @@ fn versioned_templates_only_claim_supported_contracts() { } assert!(pi.contains("pi.on(\"agent_settled\"")); assert!(pi.contains("pi.on(\"message_end\"")); - assert!(pi.contains("pi-extension-0.80.10-events-v13")); + assert!(pi.contains("pi-extension-0.80.10-events-v15")); assert!(pi.contains("APC_PI_CONNECTOR_RELEASE_VERSION = \"__APC_CONNECTOR_RELEASE_VERSION__\"")); assert!(pi.contains("APC_PI_EVENT_INVENTORY")); assert!(pi.contains("pi.on(\"project_trust\"")); diff --git a/crates/petcore/tests/daemon_http_security.rs b/crates/petcore/tests/daemon_http_security.rs index b42dd4c..d0e77ef 100644 --- a/crates/petcore/tests/daemon_http_security.rs +++ b/crates/petcore/tests/daemon_http_security.rs @@ -106,7 +106,13 @@ fn send_raw_request(port: u16, request: &[u8]) -> (u16, Value) { .set_read_timeout(Some(Duration::from_secs(5))) .unwrap(); stream.write_all(request).unwrap(); - stream.shutdown(Shutdown::Write).unwrap(); + if let Err(error) = stream.shutdown(Shutdown::Write) { + assert_eq!( + error.kind(), + std::io::ErrorKind::NotConnected, + "unexpected request shutdown error: {error}" + ); + } let mut response = String::new(); if let Err(error) = stream.read_to_string(&mut response) { diff --git a/crates/petcore/tests/event_envelope_security.rs b/crates/petcore/tests/event_envelope_security.rs index b691c33..bb1555f 100644 --- a/crates/petcore/tests/event_envelope_security.rs +++ b/crates/petcore/tests/event_envelope_security.rs @@ -137,6 +137,30 @@ fn pi_input_is_a_first_class_allowlisted_lifecycle_event() { assert_eq!(event.payload_json["message_content"], "下一条用户消息"); } +#[test] +fn pi_model_unavailable_outcome_survives_the_envelope_vocabularies() { + let event = NormalizedAgentEvent::from_external( + AgentSource::Pi, + json!({ + "id": "pi-model-unavailable-outcome", + "session_id": "pi-model-unavailable-session", + "event_type": "failed", + "payload": { + "source_event": "input", + "outcome": "model_unavailable", + "session_active": false, + "message_role": "user", + "message_content": "触发缺少模型错误", + "diagnostic": false + } + }), + received_at(), + ) + .unwrap(); + + assert_eq!(event.payload_json["outcome"], "model_unavailable"); +} + #[test] fn activity_ingress_never_recurses_through_credential_containers() { let sources = [ diff --git a/docs/architecture/data-model.md b/docs/architecture/data-model.md index 4f73f2b..e419642 100644 --- a/docs/architecture/data-model.md +++ b/docs/architecture/data-model.md @@ -18,6 +18,7 @@ AgentPetCompanion/ ├── pets//revisions// ├── generation-jobs// ├── connectors/ +├── managed-skills/ App-private portable-Skill ownership receipts ├── logs/ └── diagnostic-exports/ ``` @@ -100,7 +101,7 @@ The [V3 specification](../specifications/AgentPetCompanion_Petpack_Whitepaper_V3 Supported sources are `codex`, `claude_code`, `pi`, and `opencode`. Persisted events are `start`, `thinking`, `plan`, `tool`, `waiting`, `done`, and `failed`. The event title is canonical audit data; localized UI labels and pet-action mapping are separate. -`apc.agent-event.v1` accepts only bounded fields needed for identity, ordering, navigation, and display. Session title, first/latest user message, current-turn Agent message, and selected scalar activity detail are distinct fields. Activity normalization may retain bounded reasoning, commands, tool input/output, and errors, but never stringifies arbitrary host objects or exports credential-shaped containers, headers, complete environments, or transcripts. String ceilings are enforced as UTF-8 bytes by both schema and PetCore. +`apc.agent-event.v1` accepts only bounded fields needed for identity, ordering, navigation, and display. Session title, first/latest user message, Agent message, and selected scalar activity detail are distinct stored fields. The bubble-body projection chooses the newest Agent message or explicit thinking/plan content and retains it across user, tool, and lifecycle events; those other events continue to own context, semantic state, and the status indicator but never replace body copy. Activity normalization may retain bounded reasoning, commands, tool input/output, and errors, but never stringifies arbitrary host objects or exports credential-shaped containers, headers, complete environments, or transcripts. String ceilings are enforced as UTF-8 bytes by both schema and PetCore. `state.snapshot` exposes opaque domain-separated identities, bounded session fields, closed summary kind, animation identity, completion acknowledgement identity, and validated navigation capability. Only a routable Codex App UUID may cross separately for exact ChatGPT/Codex navigation. Unknown or mismatched targets fail closed. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 56662c0..8909a70 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -39,7 +39,7 @@ The App and overlay share one UI process. PetCore is a separate per-user service - The desktop pet and session bubble are the daily surface. Pet Library, AI Pet Maker, Pet Configuration, Agent Connections, and Service & Diagnostics are the management surface. - First run is a resumable three-scene root presentation. Pet choice and Agent setup reuse normal product operations; the demo remains View-local and never creates Agent events or diagnostics. - Connections and bubbles use `Agent → session`, never project directories as user-facing identity. -- A session title, latest user message, current-turn Agent message, and normalized activity detail are separate bounded fields. PetCore supplies them; Swift does not reconstruct host payloads. +- A session title and latest user message remain separate bounded context. PetCore selects exactly one retained bubble-body message from the session's Agent replies and explicit thinking/plan text, ordered by persisted event sequence; user, tool, and lifecycle activity update their own context or the separate status indicator without entering or replacing that message. Grouped and flat Swift presentations consume the same selected body, render it in at most two lines, and do not reconstruct host payloads. Navigation notices keep their existing priority over ordinary body copy. - Bubbles default to one cross-Agent session-card list; users may opt into grouping sessions per Agent. The flat list keeps stable slots while a turn alternates between thinking, planning, and tools; only a new activation or attention-state transition may move a card. - Clicking the pet toggles the bubble. Only a concrete session row can open a validated exact-session or host-level destination. - On macOS 26, session bubbles use one untinted native Regular Liquid Glass surface plus a paired sub-point light/dark optical rim that preserves the rounded boundary across mixed bright and dark backdrops without adding a fill, tint, shadow, or second material. Compact floating controls may use Clear glass. Older systems use the system material fallback; accessibility settings can replace or strengthen the optical treatment. @@ -88,6 +88,8 @@ Task duration is derived only from PetCore's `started_at` and `ended_at`. Waitin The App accepts bounded, content-verified PNG, JPG/JPEG, and WebP references. User messages and bounded visible Codex `agentMessage` text are written to the private per-job message stream so the creation page can refresh and scroll the live conversation; tool payloads, hidden reasoning, credentials, and arbitrary server objects do not enter that stream. App creation supports `low` and `standard`; portable workflows may produce `high` when the untouched source pixels satisfy the V3 contract. Generated output is untrusted until the shared package and production validators pass. +The AI Pet Maker header also exposes the provider-neutral `agent-pet-maker` Skill manager. PetCore installs the complete bundled Skill only at `~/agent/skills/agent-pet-maker`; the App does not probe whether any Agent discovers that directory. Explicit install establishes an App-private ownership receipt. Once owned, an explicit update, reinstall, or uninstall replaces or removes the complete managed target, including local changes inside it. A same-name external directory without that receipt remains untouched unless its complete contents exactly match the bundled Skill and the user explicitly adopts it. + Imports and successful jobs stage a new immutable revision, atomically update the active revision pointer, and commit the database row. Failure restores the previous pointer. A damaged local runtime projection can be rebuilt from the immutable package. Exact package, timing, production, and privacy requirements live in the [V3 specification](../specifications/AgentPetCompanion_Petpack_Whitepaper_V3.md). ## Repository map @@ -114,6 +116,6 @@ docs/ Durable technical and release documentation - Session projections contain only bounded display context and validated navigation capabilities. Closed sessions leave the active bubble immediately; acknowledged open completions stay hidden until a genuine new activity epoch. - Pet mutations are ID-based, serialized, immutable, and recoverable. - Official V1 distribution is two architecture-specific ad-hoc-signed GitHub Release archives plus one checksum file. The App reports updates but does not download or install them. -- Managed integrations are attributable to Agent Pet Companion. User-managed extensions and Skills are neither listed nor modified. +- Managed integrations are attributable to Agent Pet Companion. User-managed extensions and Skills are neither listed nor modified. The portable Maker Skill is the explicit exception: it has one fixed external target and an App-private ownership receipt, and user-triggered operations may replace or delete that complete App-managed target. Unrelated Agent Skill locations and unowned content are never scanned or modified. When an invariant changes, update its implementation, tests, typed version or schema when required, and the single owning document in the same change. diff --git a/docs/architecture/runtime-and-ipc.md b/docs/architecture/runtime-and-ipc.md index d1a925f..bf8ef9f 100644 --- a/docs/architecture/runtime-and-ipc.md +++ b/docs/architecture/runtime-and-ipc.md @@ -64,6 +64,8 @@ After replacement, the runtime transaction converges PetCore, CLI, missing or up The Codex plugin plus bundled Studio and Maker Skills form one versioned capability bundle. Content changes require a greater plugin version; Studio upgrades additionally preserve the previous shipped Skill digest in the append-only retired ownership history. +The separately exposed portable Maker Skill is managed through closed no-parameter PetCore RPCs for status, install/reinstall/update, and uninstall. Those mutations share the Agent-capability operation gate, target only `~/agent/skills/agent-pet-maker`, and never inspect Agent configuration or discovery behavior. App update convergence does not silently install this target; the management sheet compares its explicit installation with the bundled version and waits for a user action. + ## Local transports ### Unix-socket JSON-RPC diff --git a/docs/development/parallel-development.md b/docs/development/parallel-development.md index afadf19..a46b875 100644 --- a/docs/development/parallel-development.md +++ b/docs/development/parallel-development.md @@ -40,10 +40,39 @@ The coordinator starts one shared train and task worktrees from explicit remote For direct work, pass `--hotfix` or `--small`; for an explicit train, pass `--lane train --train gd-ops/train/`. The tool rejects missing train coordination, branch collisions, dirty PR worktrees, and unmanaged PR heads. / direct 工作使用 `--hotfix` 或 `--small`;显式 train 使用 `--lane train --train ...`。工具会拒绝缺少 train 协调、分支冲突、PR worktree 不干净及非受管 PR head。 +## Commit, push, and PR checklist / 提交、推送与 PR 检查清单 + +Every direct, task-to-train, and final train handoff uses the same explicit lifecycle. A changed file is not delivered merely because it exists in a worktree: it must be intentionally staged, committed, observed in a clean worktree, pushed, and represented by the expected PR head/base. / direct、task→train 与最终 train 的每次交付都必须执行同一套显式生命周期。文件只存在于 worktree 中不等于已经交付;它必须被明确暂存、提交,在干净 worktree 中复核,推送,并由预期 head/base 的 PR 承载。 + +1. Confirm that the current branch, recorded base, owned paths, and changelog mode match the chosen lane. Inspect `git status --short` and the complete diff; do not mix another Agent's paths, generated output, credentials, `.env`, DerivedData, or temporary assets. / 确认当前分支、记录的 base、所属路径与 changelog 模式符合所选通道;检查完整状态与 diff,不混入其他 Agent 路径、生成物、凭据或临时文件。 +2. Run `git diff --check` and `./script/validate_pre_push.sh`. The fast local gate is required; the larger remote matrix remains authoritative. / 运行空白检查与本地快速门禁;本地快速门禁是必选项,远端大矩阵仍是权威结果。 +3. Stage only the intended paths with an explicit `git add -- `. `git add -A` is permitted only after the whole worktree has been deliberately confirmed as one scope. Audit the index with `git diff --cached --check`, `git diff --cached --stat`, and, when needed, the full cached diff. / 使用显式路径只暂存本次范围;仅在已明确确认整个 worktree 都属于同一范围时才可使用 `git add -A`,随后检查 index 的空白、统计与完整 diff。 +4. Commit the staged change, then require `git status --short` to be empty. A successful commit with remaining modified or untracked files is an incomplete handoff and must not be pushed as complete. / 提交后必须确认 `git status --short` 为空;commit 成功但仍有修改或未跟踪文件,仍属于不完整交付,不能按已完成状态推送。 +5. Use `development_flow.py pr-open ... --apply` (or an equivalent explicit push plus PR operation), then verify the remote branch SHA, PR head/base, draft/ready state, and CI run. The helper refuses a dirty PR worktree. / 使用辅助命令(或等价的显式 push + PR 操作),并复核远端 SHA、PR head/base、draft/ready 状态与 CI;辅助命令会拒绝脏 worktree。 + +```bash +git status --short +git diff --check +./script/validate_pre_push.sh +git add -- path/to/owned-file another/owned-file +git diff --cached --check +git diff --cached --stat +git commit -m "Describe the delivered change" +test -z "$(git status --porcelain)" + +./script/development_flow.py pr-open \ + --worktree /absolute/path/to/owned-worktree \ + --title "Describe the delivered change" \ + --ready \ + --apply +``` + ## PR lifecycle and CI / PR 生命周期与 CI Task PRs start as drafts unless the Agent explicitly marks them ready. A ready PR created by the helper is pushed and opened against its recorded base. After its CI workflow succeeds, the trusted post-CI merger independently resolves the exact same-repository PR and head commit, verifies its run/attempt-bound merge ticket, tested merge parents, lane proof, and active PR/`Required CI` rules, then squash-merges. It checks out only the trusted `main` verifier, never pull-request code. / 任务 PR 默认以 draft 创建。Agent 显式标记 ready 后,辅助工具会推送并按记录基线创建 PR。其 CI workflow 成功后,受信任的 CI 后合并器会独立解析精确的同仓库 PR 与 head commit,校验绑定 run/attempt 的 merge ticket、测试合并父提交、通道证明以及有效的 PR/`Required CI` 规则,再执行 squash 合并;它只 checkout 受信任的 `main` 验证器,绝不 checkout 或执行 PR 代码。 +After it verifies the merged PR response, the trusted merger also removes the source ref for direct, task-to-train, and final train PRs. Exact-main validation dispatch happens first so cleanup cannot strand an already-merged commit without a Release proof. Repository automatic deletion remains enabled as the first convenience layer, but the workflow adds bounded cleanup: in the run that transitions the PR from open to merged, an existing ref is deleted with an atomic force-with-lease bound to the verified PR head SHA; an advanced ref fails closed. On a closed-PR replay, only an already-absent ref succeeds—any existing ref, including the same name recreated at the old SHA, is treated as reused and is never deleted. / 受信任合并器确认 PR 已合并后,还会清理 direct、task→train 与最终 train PR 的源 ref。精确 main 验证会先派发,避免清理失败让已合并 commit 永久缺少 Release 证明。仓库自动删除开关仍作为第一层便利机制;workflow 追加有界清理:仅在本次 run 将 PR 从 open 变为 merged 时,才会用绑定已验证 PR head SHA 的原子 force-with-lease 删除仍存在的 ref,ref 已推进则 fail closed。closed PR replay 只接受 ref 已不存在;任何仍存在的 ref(包括用旧 SHA 重建的同名分支)都视为已复用,绝不删除。 + PRs that change the CI/Release/merge/proof/ruleset control-plane inventory are intentionally excluded from privileged auto-merge because their own workflow cannot be allowed to prove itself. They still run PR CI, but require a trusted manual squash merge; that human-authored merge produces the ordinary protected `main` push CI. / 修改 CI、Release、合并器、证明或 ruleset 控制面清单的 PR 会被明确排除在特权自动合并之外,因为不能允许 PR 自己修改并证明自身 workflow。此类 PR 仍运行 PR CI,但需要受信任人工执行 squash 合并;该人工合并会产生普通的受保护 `main` push CI。 ```bash @@ -76,7 +105,37 @@ A train task PR adds one `changes/unreleased/*.json` fragment per user-visible c ./script/validate_pre_push.sh ``` -After the train PR merges, the coordinator clears the active state with `train-clear --train ... --apply` and prunes only its owned worktrees. Branch deletion is performed by GitHub after merge. / train PR 合并后,协调者使用 `train-clear` 清除活动状态,并只清理自己拥有的 worktree;分支由 GitHub 在合并后删除。 +## Post-merge repository audit / 合并后的仓库清场审计 + +Automatic remote-branch deletion does not clean local worktrees or prove that every local change was delivered. After each direct merge, and once more after a train closes, the coordinator performs a repository-wide audit: / 远端自动删分支不会清理本地 worktree,也不能证明全部本地改动已经交付。每次 direct 合并后,以及 train 收口后,协调者都必须执行全仓审计: + +1. Run `git fetch origin --prune`, verify the merged PR and exact `main` SHA, and confirm the remote source ref is absent. / 拉取并 prune,核验已合并 PR 与精确 main SHA,并确认远端源 ref 已不存在。 +2. Enumerate every linked worktree with `git worktree list --porcelain`, then run `git status --short` in each path. Any modified or untracked path is unresolved work: preserve it, assign an owner, and deliver it through a PR; never remove or reset that worktree as cleanup. / 枚举每个 worktree 并逐一检查状态;存在修改或未跟踪文件即代表未解决工作,必须保留、分配 owner 并通过 PR 交付,不能以清理名义删除或 reset。 +3. Inventory every local branch and open PR. For branches with an upstream, inspect unpushed commits; for branches whose remote was deleted after squash, use the merged PR record as the authority because the task commit is not an ancestor of the squash commit. A branch with no matching merged PR remains unresolved even if its worktree is clean. / 盘点全部本地分支与 open PR;有 upstream 的分支检查未推送 commit,squash 后远端已删的分支以 merged PR 记录为权威,因为任务 commit 不会成为 squash commit 的祖先;没有对应 merged PR 的分支即使 worktree 干净也仍未解决。 +4. In one dedicated, clean main worktree only, fast-forward local `main` with `git merge --ff-only origin/main`. Never switch, reset, or overwrite a dirty feature worktree to update main. / 仅在专用且干净的 main worktree 中以 fast-forward 更新本地主分支;不得切换、reset 或覆盖脏的功能 worktree。 +5. Remove only owned, clean worktrees whose PR is verified merged and whose remote source ref is absent; then remove their local ephemeral branches. After a train PR merges, also run `train-clear --train ... --apply`. Finish by repeating the all-worktree status, branch, PR, and remote-ref inventory. / 仅清理由本人拥有、状态干净、PR 已确认合并且远端 ref 已删除的 worktree,再删除对应本地临时分支;train 合并后还需清除活动 train 状态,最后再次盘点所有 worktree、分支、PR 与远端 ref。 + +```bash +git fetch origin --prune +git worktree list --porcelain +git branch -vv +gh pr list --state open --limit 100 +gh pr view PR_NUMBER --json state,mergedAt,headRefName,baseRefName,mergeCommit + +# Repeat for every path reported above. +git -C /absolute/path/to/worktree status --short + +# Run only in a dedicated clean main worktree. +git switch main +git merge --ff-only origin/main + +# Run only after the PR/ref/worktree checks above succeed. +git worktree remove /absolute/path/to/owned-clean-worktree +git branch -D gd-ops/task/verified-squash-merged-branch +./script/development_flow.py train-clear \ + --train gd-ops/train/feature-set \ + --apply +``` ## Protected branch activation / 受保护分支激活 diff --git a/docs/development/validation.md b/docs/development/validation.md index eab2cb2..a23c2a3 100644 --- a/docs/development/validation.md +++ b/docs/development/validation.md @@ -51,7 +51,7 @@ On a trusted `main` validation (`push` or exact-SHA `workflow_dispatch`), CI fir After a successful trusted `main` validation, CI uploads exactly `source-proof.json` and `interaction-attestation.json` as `release-source-proof-FULL_COMMIT`. The v3 proof binds repository, full commit and tree, latest stable baseline, trusted main issuer event/run, gate inventory, toolchain-contract digest, observed toolchains, interaction bytes, and either the full main validation run or the promoted pull-request validation commit/run/ref plus candidate-proof digest. Release accepts only a successful `push` or `workflow_dispatch` run of `.github/workflows/ci.yml` on `main` from the same repository and exact commit; missing, expired, duplicate, malformed, or mismatched proof fails closed. The Release rebinds the proven interaction contract to its final build ID without repeating the complete source suites. / 受信任的 `main` 验证成功后,CI 会以 `release-source-proof-FULL_COMMIT` 上传且仅上传 `source-proof.json` 与 `interaction-attestation.json`。v3 证明绑定仓库、完整 commit/tree、latest stable 基线、受信任 main 签发事件/run、门禁清单、工具链合同摘要、已观察工具链、交互证明字节,以及“完整 main 验证 run”或“被晋升的 PR 验证 commit/run/ref 与候选证明摘要”。Release 只接受同仓库、同 commit、`main` 上成功的 `.github/workflows/ci.yml` `push` 或 `workflow_dispatch` run;缺失、过期、重复、格式错误或不匹配均 fail closed,并只将已证明交互合同重新绑定到最终 build ID,不再重复全量源码测试。 -Protected delivery is owned by the repository's idempotent ruleset applicator. `Protected default branch` requires pull requests, squash-only linear history, resolved conversations, current-base `Required CI`, and blocks force-push/deletion. `Protected integration trains` applies the PR, squash, and `Required CI` contract to `gd-ops/train/*`, but uses non-strict status checks and permits deletion after the final train PR. The trusted post-CI merger accepts only the exact CI workflow path, same repository, managed branch, successful `Required CI`, current merge ticket, and matching lane proof. It checks out only the trusted `main` verifier. PRs touching the enumerated CI/Release/merge/proof/ruleset control plane fail closed out of auto-merge and require a trusted manual squash, preventing a PR-authored workflow from proving itself. Ordinary direct/final-train merges dispatch exact-SHA main validation with bounded retries; reruns safely recover an already-merged PR and skip an existing queued/running/successful dispatch. Task-to-train merges deliberately dispatch nothing. / 受保护交付由幂等 ruleset 配置器负责。默认分支要求 PR、仅 squash 的线性历史、讨论已解决、基于最新基线的 `Required CI`,并禁止强推与删除;train 规则对 `gd-ops/train/*` 要求 PR、squash 与 `Required CI`,但采用 non-strict 检查并允许最终合并后删除。受信任的 CI 后合并器只接受精确 CI workflow 路径、同仓库、受管分支、成功 `Required CI`、当前 merge ticket 与匹配的通道证明,并只 checkout 受信任的 `main` 验证器。触及列举的 CI/Release/合并器/证明/ruleset 控制面的 PR 会 fail closed 退出自动合并并要求受信任人工 squash,防止 PR 自写 workflow 后自证。普通 direct/最终 train 合并会用有界重试派发精确 SHA 的 main 验证;重跑可安全恢复已合并 PR,并跳过已有的 queued/running/successful 派发。task→train 合并则明确不派发任何任务。 +Protected delivery is owned by the repository's idempotent ruleset applicator. `Protected default branch` requires pull requests, squash-only linear history, resolved conversations, current-base `Required CI`, and blocks force-push/deletion. `Protected integration trains` applies the PR, squash, and `Required CI` contract to `gd-ops/train/*`, but uses non-strict status checks and permits deletion after the final train PR. The trusted post-CI merger accepts only the exact CI workflow path, same repository, managed branch, successful `Required CI`, current merge ticket, and matching lane proof. It checks out only the trusted `main` verifier. PRs touching the enumerated CI/Release/merge/proof/ruleset control plane fail closed out of auto-merge and require a trusted manual squash, preventing a PR-authored workflow from proving itself. Ordinary direct/final-train merges dispatch exact-SHA main validation with bounded retries before source cleanup; reruns safely recover an already-merged PR and skip an existing queued/running/successful dispatch. Only the run that transitions an open PR to merged may delete an existing source ref, and only through an atomic force-with-lease bound to the proven head SHA. A closed replay accepts an absent ref but treats every existing ref as reused and fails closed without deleting it. Task-to-train merges deliberately dispatch nothing but use the same fresh-merge-only cleanup rule. / 受保护交付由幂等 ruleset 配置器负责。默认分支要求 PR、仅 squash 的线性历史、讨论已解决、基于最新基线的 `Required CI`,并禁止强推与删除;train 规则对 `gd-ops/train/*` 要求 PR、squash 与 `Required CI`,但采用 non-strict 检查并允许最终合并后删除。受信任的 CI 后合并器只接受精确 CI workflow 路径、同仓库、受管分支、成功 `Required CI`、当前 merge ticket 与匹配的通道证明,并只 checkout 受信任的 `main` 验证器。触及列举的 CI/Release/合并器/证明/ruleset 控制面的 PR 会 fail closed 退出自动合并并要求受信任人工 squash,防止 PR 自写 workflow 后自证。普通 direct/最终 train 合并会在源分支清理前用有界重试派发精确 SHA 的 main 验证;重跑可安全恢复已合并 PR,并跳过已有的 queued/running/successful 派发。只有将 open PR 转为 merged 的本次 run 才能删除仍存在的源 ref,且必须使用绑定已证明 head SHA 的原子 force-with-lease;closed replay 接受 ref 已不存在,但会把任何仍存在的 ref 视为复用并 fail closed,绝不删除。task→train 不派发验证,但采用同一套“仅新鲜合并可清理”规则。 During the initial migration, reversible repository settings may be applied first. Push the workflows and wait for the new exact `main` `Required CI` to succeed before applying either ruleset. Full apply fails closed until that trusted check exists. / 首次迁移可先应用可逆仓库设置;必须先推送 workflow 并等待新的精确 `main` `Required CI` 成功,才能应用两个 ruleset。完整 apply 在该受信任检查存在前会 fail closed。 diff --git a/docs/integrations/agent-connectors.md b/docs/integrations/agent-connectors.md index a4565a3..34d715b 100644 --- a/docs/integrations/agent-connectors.md +++ b/docs/integrations/agent-connectors.md @@ -44,9 +44,9 @@ Rows expose `exact_session`, `agent_host`, or `unavailable`. The App acknowledge ## Session display fields -Each projected session carries separate bounded fields for title, latest user message, current-turn Agent message, and normalized activity. The title is the latest explicit title, falling back to the first user message only until one exists. Later prompts update context without renaming the session. +Each projected session carries separate bounded fields for title, latest user message, Agent message, and normalized activity. The title is the latest explicit title, falling back to the first user message only until one exists. Later prompts update context without renaming the session. The bubble body selects only the newest Agent message or explicit thinking/plan text and retains it across user, tool, and lifecycle activity. Tool and other activity still update the closed status summary but do not enter or clear the body. Navigation notices remain a separate higher-priority presentation. -Only root Agent sessions enter the session projection. Connectors use explicit host lineage—not titles or display order—to suppress child sessions: OpenCode `parentID`, Codex App Server sub-Agent source kinds and `parentThreadId`, Pi session-header `parentSession`, and Claude sidechain markers. The suppression marker contains only the child session identity; it deletes any earlier projection for that identity, blocks later events, and never persists the parent identity. Sub-Agent lifecycle may still appear as bounded activity on the owning root session, but it never creates another desktop card. +Only root Agent sessions enter the session projection. Connectors use explicit host lineage—not user-facing titles or display order—to suppress child sessions: OpenCode `parentID`, Codex App Server sub-Agent source kinds and `parentThreadId`, Pi session-header `parentSession`, and Claude sidechain markers. Pi parallel subagent runners that omit `parentSession` are additionally recognized only through Pi's closed, host-reserved `subagent-*` session-name namespace; arbitrary session names are not classified. The suppression marker contains only the child session identity; it deletes any earlier projection for that identity, blocks later events, and never persists the parent identity or reserved name. Sub-Agent lifecycle may still update the owning root session's status, but it never creates another desktop card. OpenCode queues bounded existing-session discovery for the first task after Plugin activation instead of entering its own in-process Server API while that activation is still being constructed. Every session-bearing live hook waits behind the same lineage gate, so activation can complete without deadlocking while an already-running child is still classified before any of its activity can enter the projection. Cleanup-marker delivery is queued asynchronously after classification and does not carry the parent identity. @@ -54,7 +54,7 @@ Codex, Pi, and OpenCode publish available display fields in their managed event Claude Desktop hooks omit the message-bearing prompt/stop events needed for a useful bubble. When a Claude hook supplies `transcript_path`, the adapter may read a fixed tail of that one file to recover only title, latest user prompt, and latest Agent text. The path must be absolute, current-user-owned, regular, and not a symlink; the read is size bounded. Tool results, structured message envelopes, sub-Agent turns, and transcript history are excluded. Failure leaves fields absent and never changes connector health. -Activity normalization selects only bounded semantic scalars such as reasoning, command, tool input/output, error, compaction, or sub-Agent detail. Generic lifecycle copy is not duplicated into the two detail lines. Host-specific availability is summarized below: +Activity normalization selects only bounded semantic scalars such as reasoning, command, tool input/output, error, compaction, or sub-Agent detail. Only explicit reasoning/plan text is eligible to become retained bubble-body copy; commands, tools, errors, compaction, sub-Agent activity, and generic lifecycle copy remain status-only. Host-specific availability is summarized below: | Source | Concrete activity available | |---|---| @@ -81,6 +81,8 @@ Local package actions `acknowledge`, `drag_left`, and `drag_right` never emit ev Ordinary `session_active` is a bounded observation, not a permanent heartbeat. `waiting` and `failed` remain until a newer event resolves them. Repeated terminal notifications in one activity epoch share one completion acknowledgement identity; a later true activation creates a new scope. +Pi emits `input` before it validates the interactive session's selected model. When the public Extension context has no model at that boundary, the managed adapter closes that admitted input directly as `failed` with the closed `model_unavailable` outcome; it never inspects provider credentials or auth storage. Other Pi failures continue to settle only on a stable terminal host boundary. + ## Managed connection operations The App offers Agent-scoped operations: @@ -107,7 +109,7 @@ Agent Connections distinguishes local integration health from evidence of a real Codex Hook configuration uses only the host-supported top-level `description` and `hooks` fields; Codex rejects unknown metadata fields. The plugin manifest owns the bundle version, each separately parsed Skill repeats that version in front matter, and an installed Hook is bound to the manifest version only after its complete rendered content matches the current Hook template. Active-cache convergence computes and compares the current App, managed-source, and active-cache digests in the same check; it does not look up the Hook in a historical digest registry. A Hook load/configuration failure is action-required or repairable connector state, never evidence that a restart alone will fix the connection. -Native diagnostic canaries prove only that the host loaded the expected managed adapter and that a bounded event reached the local channel; they never claim that an ordinary provider task ran. Codex additionally requires the authoritative `hooks/list` result to report every managed hook trusted, so modified hooks remain an explicit user-authorization and restart action. OpenCode accepts its native probe only when `debug info` reports the exact expected plugin file (including an equivalent percent-encoded file URL) and the matching `connector.probe` arrives; another or disabled load path is repairable, not verified. Claude Code, Pi, and OpenCode remain `unverified` until a current-contract ordinary task event exists, even when their no-model host probes pass. +Native diagnostic canaries prove only that the host loaded the expected managed adapter and that a bounded event reached the local channel; they never claim that an ordinary provider task ran. Pi sends its current `connector.probe` as soon as the real host executes the Extension factory, before a missing model can end a non-interactive probe and before the normal `session_start` fallback. That current canary is authoritative Extension-load evidence even when the probe host subsequently exits because its separate model/runtime configuration is unavailable. Codex additionally requires the authoritative `hooks/list` result to report every managed hook trusted, so modified hooks remain an explicit user-authorization and restart action. OpenCode accepts its native probe only when `debug info` reports the exact expected plugin file (including an equivalent percent-encoded file URL) and the matching `connector.probe` arrives; another or disabled load path is repairable, not verified. Claude Code, Pi, and OpenCode remain `unverified` until a current-contract ordinary task event exists, even when their no-model host probes pass. The ordinary Agent card preserves local connector health as a separate internal fact, but projects a healthy connector without ordinary-task evidence as a neutral **Awaiting verification** state. It must not show a green **Connected** badge until that real-task evidence is current, and it must not offer repair merely because verification is pending. diff --git a/docs/release/macos-release.md b/docs/release/macos-release.md index a5a18dd..4c3d110 100644 --- a/docs/release/macos-release.md +++ b/docs/release/macos-release.md @@ -36,7 +36,7 @@ rustup target add aarch64-apple-darwin x86_64-apple-darwin No Apple account, certificate, private key, notarization profile, release Variable, or release Secret is used. -Merge the exact candidate commit to protected `main` and wait for the post-merge required CI check. The main-bound PR has already passed the complete gate before auto-merge; the trusted `main` push binds the same release-grade inventory to the exact immutable main commit and uploads its source proof. A manual dispatch may create the release tag only after validating that proof. The one-time ruleset migration procedure is owned by [Validation profiles](../development/validation.md). / 将精确候选 commit 合并到受保护的 `main` 并等待合并后的必选 CI。面向 main 的 PR 已在自动合并前通过完整门禁;受信任的 `main` push 会将同一 Release 级清单绑定到精确且不可变的 main commit 并上传源码证明。手动 dispatch 只有在验证该证明后才能创建发布 tag。一次性 ruleset 迁移步骤由验证文档维护。 +Merge the exact candidate commit to protected `main` and wait for the post-merge required CI check. The main-bound PR has already passed the complete gate before auto-merge; a successful same-repository `.github/workflows/ci.yml` run on the exact immutable `main` commit—either a protected `push` or an exact-SHA `workflow_dispatch`—binds the release-grade inventory and uploads its source proof. The trusted post-CI merger uses the dispatch form because GitHub suppresses ordinary follow-on workflow events created with `GITHUB_TOKEN`. A manual Release dispatch may create the release tag only after validating that proof. The one-time ruleset migration procedure is owned by [Validation profiles](../development/validation.md). / 将精确候选 commit 合并到受保护的 `main` 并等待合并后的必选 CI。面向 main 的 PR 已在自动合并前通过完整门禁;同仓库 `.github/workflows/ci.yml` 在精确且不可变的 main commit 上成功执行的受信任验证——受保护 `push` 或精确 SHA 的 `workflow_dispatch`——会绑定 Release 级清单并上传源码证明。CI 后合并器使用显式派发,因为 GitHub 会抑制由 `GITHUB_TOKEN` 产生的普通后续 workflow 事件。Release 手动派发只有在验证该证明后才能创建发布 tag。一次性 ruleset 迁移步骤由验证文档维护。 The complete local command remains available only for diagnosis; it is not a release prerequisite when the exact remote proof exists: @@ -49,7 +49,7 @@ APC_VALIDATE_REAL_APP_SERVER=0 \ Then run the environment-dependent connector, App Server, visible-UI, renderer, and profiling gates required by [Validation profiles](../development/validation.md). The executing Agent selects a suitable live-App inspection method. A gate not run is skipped, never passed. / 随后按发布范围运行环境门禁;可见 App 的验收方法由执行 Agent 选择,未运行的门禁只能标记为 skipped。 -The complete Swift CI shard generates a source-build interaction attestation. `source-proof.json` binds its bytes to the repository, full commit/tree, latest stable baseline, trusted `main` push run, complete gate inventory, and toolchain contract. Release downloads it only from the successful exact-commit CI run, validates it, and rebinds the interaction attestation to the final release build ID before supplying it to both architecture builds. / 完整 Swift CI 分片会生成源码 build 的交互证明;`source-proof.json` 将其字节绑定到仓库、完整 commit/tree、latest stable 基线、受信任 `main` push run、完整门禁清单与工具链合同。Release 只从成功的同 commit CI run 下载并校验,再将交互证明重新绑定到最终发布 build ID,供两个架构构建复用。 +The complete Swift CI shard generates a source-build interaction attestation. `source-proof.json` binds its bytes to the repository, full commit/tree, latest stable baseline, trusted exact-commit `main` issuer run (`push` or `workflow_dispatch`), complete gate inventory, and toolchain contract. Release downloads it only from that successful exact-commit CI run, validates it, and rebinds the interaction attestation to the final release build ID before supplying it to both architecture builds. / 完整 Swift CI 分片会生成源码 build 的交互证明;`source-proof.json` 将其字节绑定到仓库、完整 commit/tree、latest stable 基线、受信任的同 commit `main` 签发 run(`push` 或 `workflow_dispatch`)、完整门禁清单与工具链合同。Release 只从该成功的同 commit CI run 下载并校验,再将交互证明重新绑定到最终发布 build ID,供两个架构构建复用。 ## Development artifacts / 开发产物 @@ -112,7 +112,7 @@ Omit `commit` to use current `main`. Dispatch verifies source version, changelog In order the workflow: 1. verifies source version, changelog, full main commit, latest stable baseline, and Codex plugin/Skill version discipline; -2. resolves the successful trusted `main` push CI run for that exact commit, rejects PR/fork/failed or ambiguous runs and artifacts, validates the two-file source proof, then rebinds its interaction attestation to the final release build ID; +2. resolves the successful same-repository `main` CI `push` or exact-SHA `workflow_dispatch` run for that exact commit, rejects PR/fork/failed or ambiguous runs and artifacts, validates the two-file source proof, then rebinds its interaction attestation to the final release build ID; 3. creates a missing tag only after proof validation, or verifies the existing tag, then rechecks its remote commit; 4. restores dependency/build caches and builds `arm64` and `x86_64` ZIP components in parallel on macOS 26 from the proven commit and rebound proof; 5. assembles the exact three-file candidate once and records trusted digests; diff --git a/plugins/connector-privacy.test.mjs b/plugins/connector-privacy.test.mjs index 425674a..c7ca49e 100644 --- a/plugins/connector-privacy.test.mjs +++ b/plugins/connector-privacy.test.mjs @@ -55,7 +55,7 @@ test("Pi and OpenCode expose bounded local activity without leaking host-private try { const pi = await importTemplate("./pi/agent-pet-companion.ts.tpl"); - assert.equal(pi.APC_PI_CONTRACT_VERSION, "pi-extension-0.80.10-events-v13"); + assert.equal(pi.APC_PI_CONTRACT_VERSION, "pi-extension-0.80.10-events-v15"); assert.equal(pi.APC_PI_EVENT_INVENTORY.length, 33); const piActivity = await importTemplate( "./pi/agent-pet-companion.ts.tpl", @@ -323,6 +323,7 @@ test("Pi and OpenCode expose bounded local activity without leaking host-private getSessionId: () => "pi-session", getSessionName: () => "Visible Pi session", }, + model: { provider: "fixture", id: "fixture-model" }, cwd: "/secret/project", }; await piHandlers.get("context")( @@ -470,6 +471,26 @@ test("Pi and OpenCode expose bounded local activity without leaking host-private && payload.message_content === undefined ))); + const piNoModelStart = captured.length; + const piNoModelContext = { + sessionManager: { + getSessionId: () => "pi-no-model-session", + getSessionName: () => "No model session", + }, + model: undefined, + }; + await piHandlers.get("input")( + { type: "input", text: "Prompt without a selected model" }, + piNoModelContext, + ); + const piNoModelPayloads = captured + .slice(piNoModelStart) + .filter((item) => item.source === "pi") + .map((item) => item.payload); + assert.deepEqual(piNoModelPayloads.map((payload) => payload.type), ["input"]); + assert.equal(piNoModelPayloads[0].agent_error, true); + assert.equal(piNoModelPayloads[0].reason, "model_unavailable"); + const piChildStart = captured.length; const piChildContext = { sessionManager: { @@ -496,6 +517,33 @@ test("Pi and OpenCode expose bounded local activity without leaking host-private assert.equal(JSON.stringify(piChildPayloads).includes("/private/root-session.jsonl"), false); assert.equal(JSON.stringify(piChildPayloads).includes("must-not-cross"), false); + const piReservedChildStart = captured.length; + const piReservedChildContext = { + sessionManager: { + getSessionId: () => "pi-reserved-child-session", + getSessionName: () => "subagent-reviewer-security", + getHeader: () => ({}), + }, + model: { provider: "fixture", id: "fixture-model" }, + }; + await piHandlers.get("input")( + { type: "input", text: "must-not-cross-reserved-child-prompt" }, + piReservedChildContext, + ); + await piHandlers.get("tool_call")( + { type: "tool_call", toolName: "bash", input: { command: "must-not-cross-reserved-child-tool" } }, + piReservedChildContext, + ); + const piReservedChildPayloads = captured + .slice(piReservedChildStart) + .filter((item) => item.source === "pi") + .map((item) => item.payload); + assert.equal(piReservedChildPayloads.length, 1); + assert.equal(piReservedChildPayloads[0].type, "session.child"); + assert.equal(piReservedChildPayloads[0].session_id, "pi-reserved-child-session"); + assert.equal(JSON.stringify(piReservedChildPayloads).includes("subagent-reviewer"), false); + assert.equal(JSON.stringify(piReservedChildPayloads).includes("must-not-cross"), false); + const productionOpenCode = await importTemplate( "./opencode/agent-pet-companion.js.tpl", "production-exports", @@ -1286,6 +1334,13 @@ test("Pi and OpenCode expose bounded local activity without leaking host-private const piProbe = await importTemplate("./pi/agent-pet-companion.ts.tpl", "probe"); const piProbeHandlers = new Map(); piProbe.default({ on: (name, handler) => piProbeHandlers.set(name, handler) }); + await new Promise((resolve) => setImmediate(resolve)); + assert.ok(captured.some((item) => ( + item.source === "pi" + && item.payload.type === "connector.probe" + && item.payload.diagnostic === true + && item.payload.session_id === process.env.APC_CONNECTOR_PROBE_ID + )), "Pi must emit its load canary before model validation can end the host"); await piProbeHandlers.get("session_start")( { type: "session_start" }, { sessionManager: { getSessionId: () => "pi-probe-session" } }, diff --git a/plugins/pi/agent-pet-companion.ts.tpl b/plugins/pi/agent-pet-companion.ts.tpl index 4257f9f..e222cb5 100644 --- a/plugins/pi/agent-pet-companion.ts.tpl +++ b/plugins/pi/agent-pet-companion.ts.tpl @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; const CLI_PATH = __APC_CLI_JSON__; export const APC_PI_CONNECTOR_RELEASE_VERSION = "__APC_CONNECTOR_RELEASE_VERSION__"; -export const APC_PI_CONTRACT_VERSION = "pi-extension-0.80.10-events-v13"; +export const APC_PI_CONTRACT_VERSION = "pi-extension-0.80.10-events-v15"; export const APC_PI_WAITING_CAPABILITY = "structured-extension-events"; // Pi 0.80.10 ExtensionAPI event inventory. Every official event is registered @@ -87,9 +87,20 @@ function sessionTitle(ctx, event) { return title ?? ctx?.sessionManager?.getSessionName?.() ?? ctx?.sessionManager?.sessionName; } -function isChildSession(ctx) { - const parentSession = ctx?.sessionManager?.getHeader?.()?.parentSession; - return typeof parentSession === "string" && parentSession.trim().length > 0; +function isReservedSubagentSessionName(value) { + if (typeof value !== "string") return false; + const name = value.trim(); + return name.length <= 96 && /^subagent-[a-z0-9][a-z0-9._-]*$/i.test(name); +} + +function isChildSession(ctx, event) { + const header = ctx?.sessionManager?.getHeader?.(); + const parentSession = header?.parentSession ?? header?.parentSessionId; + if (typeof parentSession === "string" && parentSession.trim().length > 0) return true; + // Pi's parallel subagent runner currently creates sibling session files + // without populating header.parentSession. Its reserved `subagent-*` session + // name is the only stable lineage marker exposed through ExtensionContext. + return isReservedSubagentSessionName(sessionTitle(ctx, event)); } function messageText(message) { @@ -396,7 +407,7 @@ function sendEvent(payload) { async function forward(event, ctx) { const id = sessionId(ctx); - if (id && isChildSession(ctx)) { + if (id && isChildSession(ctx, event)) { if (!childSessionMarkers.has(id)) { remember(childSessionMarkers, id, true); await sendEvent({ @@ -442,7 +453,14 @@ async function forward(event, ctx) { // its final message/error only; agent_settled is the sole terminal event. if (event?.type === "agent_end") return; - const agentError = event?.type === "agent_settled" && finalAgentErrors.get(id) === true; + // Pi publishes input before validating that an interactive session has a + // selected model. That validation failure happens before the Agent loop, so + // no later agent_settled event exists for the connector to observe. The + // public ExtensionContext model field is enough to close this exact failure + // without inspecting provider credentials or auth storage. + const modelUnavailable = event?.type === "input" && ctx?.model == null; + const agentError = modelUnavailable + || (event?.type === "agent_settled" && finalAgentErrors.get(id) === true); const message = displayMessage(event, id, includeBeforeAgentPrompt); const activity = activityText(event); const parse_warnings = parseWarnings(event, message, activity); @@ -457,7 +475,9 @@ async function forward(event, ctx) { tool_call_id: event?.toolCallId, is_error: event?.isError === true, agent_error: agentError, - reason: typeof event?.reason === "string" ? event.reason : undefined, + reason: modelUnavailable + ? "model_unavailable" + : typeof event?.reason === "string" ? event.reason : undefined, diagnostic: connectorDiagnostic || event?.diagnostic === true, message_role: message?.role, message_content: message?.content, @@ -468,7 +488,9 @@ async function forward(event, ctx) { try { await sendEvent(allowlisted); } finally { - if (event?.type === "agent_settled" || event?.type === "session_shutdown") { + if (modelUnavailable + || event?.type === "agent_settled" + || event?.type === "session_shutdown") { forgetSession(id); } } @@ -493,6 +515,13 @@ async function sendConnectorProbeOnce() { export default function agentPetCompanion(pi) { if (!pi?.on) return; + // Pi may reject a non-interactive host for missing model configuration + // before session_start is emitted. The factory itself is the earliest + // authoritative proof that the real host executed this exact Extension. + // sendEvent starts its bounded child synchronously, and the session_start + // handler below remains an idempotent fallback for newer host lifecycles. + void sendConnectorProbeOnce(); + // Returning undecided observes project-trust capability without changing the // host's own trust decision. pi.on("project_trust", async () => ({ trusted: "undecided" })); diff --git a/schemas/agent-event.schema.json b/schemas/agent-event.schema.json index d043725..b1f5def 100644 --- a/schemas/agent-event.schema.json +++ b/schemas/agent-event.schema.json @@ -85,7 +85,7 @@ "api_failure", "settled", "created", "idle", "busy", "retry", "permission_replied", "permission_replied_once", "permission_replied_always", "permission_replied_allow", "permission_replied_deny", "permission_replied_reject", - "started_without_args", "session_failure", "session_closed", "input_requested", "observed", "auto_denied", + "started_without_args", "session_failure", "model_unavailable", "session_closed", "input_requested", "observed", "auto_denied", "background_active", "agent_completed", "message", "continued", "prompt_admitted", "unknown", null ] }, diff --git a/script/ci_proof_promotion.py b/script/ci_proof_promotion.py index 08d6b39..9c42ded 100755 --- a/script/ci_proof_promotion.py +++ b/script/ci_proof_promotion.py @@ -1,5 +1,5 @@ #!/usr/bin/env python3 -"""Create and promote trusted PR CI proofs to an identical main source tree.""" +"""Manage trusted PR merge proofs and exact merged-head cleanup.""" from __future__ import annotations @@ -141,6 +141,92 @@ def require_identity(repository: str, commits: dict[str, str]) -> None: require_commit(value, name) +def require_origin_repository(root: pathlib.Path, repository: str) -> None: + remote = run(root, "git", "remote", "get-url", "origin") + accepted = { + f"https://github.com/{repository}", + f"https://github.com/{repository}.git", + f"git@github.com:{repository}.git", + f"ssh://git@github.com/{repository}.git", + } + if remote not in accepted: + raise ValueError("origin does not match the verified pull-request repository") + + +def remote_ref_sha(root: pathlib.Path, head_ref: str) -> str | None: + full_ref = f"refs/heads/{head_ref}" + response = run(root, "git", "ls-remote", "--refs", "origin", full_ref) + if not response: + return None + lines = response.splitlines() + if len(lines) != 1: + raise ValueError("remote head lookup is ambiguous") + fields = lines[0].split("\t") + if len(fields) != 2 or fields[1] != full_ref: + raise ValueError("remote head lookup returned an invalid ref") + require_commit(fields[0], "remote head commit") + return fields[0] + + +def delete_merged_head(args: Namespace) -> dict[str, bool | str]: + root = args.root.resolve() + require_identity(args.repository, {"head commit": args.head_commit}) + if args.pull_request_number <= 0: + raise ValueError("merged-head PR identity must be positive") + if HEAD_PATTERN.fullmatch(args.head_ref) is None: + raise ValueError("merged head is not a managed development branch") + pull = api_json( + f"repos/{args.repository}/pulls/{args.pull_request_number}" + ) + head = pull.get("head") if isinstance(pull, dict) else None + head_repository = head.get("repo") if isinstance(head, dict) else None + if not ( + isinstance(pull, dict) + and pull.get("number") == args.pull_request_number + and pull.get("merged") is True + and isinstance(head, dict) + and head.get("ref") == args.head_ref + and head.get("sha") == args.head_commit + and isinstance(head_repository, dict) + and head_repository.get("full_name") == args.repository + ): + raise ValueError("merged-head cleanup is not bound to the exact merged pull request") + require_origin_repository(root, args.repository) + if not isinstance(args.allow_delete, bool): + raise ValueError("merged-head cleanup mode is invalid") + subprocess.run(["gh", "auth", "setup-git"], check=True) + observed = remote_ref_sha(root, args.head_ref) + result: dict[str, bool | str] = { + "deleted": False, + "head_ref": args.head_ref, + "head_commit": args.head_commit, + } + if observed is None: + return result + if not args.allow_delete: + raise ValueError( + "merged-head replay found an existing source ref; refusing deletion" + ) + if observed != args.head_commit: + raise ValueError("merged source branch advanced or was reused; refusing deletion") + full_ref = f"refs/heads/{args.head_ref}" + subprocess.run( + [ + "git", + "push", + f"--force-with-lease={full_ref}:{args.head_commit}", + "origin", + f":{full_ref}", + ], + cwd=root, + check=True, + ) + if remote_ref_sha(root, args.head_ref) is not None: + raise ValueError("merged source branch still exists after deletion") + result["deleted"] = True + return result + + def require_unchanged_control_plane( root: pathlib.Path, base_commit: str, main_commit: str ) -> None: @@ -1065,6 +1151,12 @@ def main() -> int: verify_parser.add_argument("--head-commit", required=True) verify_parser.add_argument("--run-id", required=True, type=int) verify_parser.add_argument("--run-attempt", required=True, type=int) + cleanup_parser = subparsers.add_parser("delete-merged-head") + cleanup_parser.add_argument("--repository", required=True) + cleanup_parser.add_argument("--pull-request-number", required=True, type=int) + cleanup_parser.add_argument("--head-ref", required=True) + cleanup_parser.add_argument("--head-commit", required=True) + cleanup_parser.add_argument("--allow-delete", required=True, choices=("0", "1")) promote_parser = subparsers.add_parser("promote") promote_parser.add_argument("--repository", required=True) promote_parser.add_argument("--main-commit", required=True) @@ -1080,6 +1172,9 @@ def main() -> int: create_merge_ticket(args) elif args.command == "verify-merge-source": print(json.dumps(verify_merge_source(args), sort_keys=True)) + elif args.command == "delete-merged-head": + args.allow_delete = args.allow_delete == "1" + print(json.dumps(delete_merged_head(args), sort_keys=True)) else: promote(args) except ( diff --git a/script/tests/test_ci_proof_promotion.py b/script/tests/test_ci_proof_promotion.py index 8903b31..06fc862 100755 --- a/script/tests/test_ci_proof_promotion.py +++ b/script/tests/test_ci_proof_promotion.py @@ -239,6 +239,110 @@ class PromotionSelectionTests(unittest.TestCase): ci_proof.require_no_control_plane_change("owner/repo", 17) +class MergedHeadCleanupTests(unittest.TestCase): + def args(self, *, allow_delete: bool = True) -> Namespace: + return Namespace( + root=ROOT, + repository="owner/repo", + pull_request_number=17, + head_ref="gd-ops/task/17-small", + head_commit="b" * 40, + allow_delete=allow_delete, + ) + + def merged_pull(self) -> dict[str, object]: + return { + "number": 17, + "merged": True, + "head": { + "ref": "gd-ops/task/17-small", + "sha": "b" * 40, + "repo": {"full_name": "owner/repo"}, + }, + } + + def test_deletes_only_the_exact_verified_merged_head_with_an_atomic_lease(self) -> None: + observed = iter(["b" * 40, None]) + + def observe_remote(*_args): + self.assertEqual( + run_process.call_args_list[0], + mock.call(["gh", "auth", "setup-git"], check=True), + ) + return next(observed) + + with mock.patch.object( + ci_proof, "api_json", return_value=self.merged_pull() + ), mock.patch.object( + ci_proof, "require_origin_repository" + ), mock.patch.object( + ci_proof, + "remote_ref_sha", + side_effect=observe_remote, + ), mock.patch.object(ci_proof.subprocess, "run") as run_process: + result = ci_proof.delete_merged_head(self.args()) + + self.assertEqual( + result, + { + "deleted": True, + "head_ref": "gd-ops/task/17-small", + "head_commit": "b" * 40, + }, + ) + run_process.assert_has_calls( + [ + mock.call(["gh", "auth", "setup-git"], check=True), + mock.call( + [ + "git", + "push", + "--force-with-lease=refs/heads/gd-ops/task/17-small:" + + "b" * 40, + "origin", + ":refs/heads/gd-ops/task/17-small", + ], + cwd=ROOT, + check=True, + ), + ] + ) + + def test_replay_accepts_an_absent_head_but_rejects_a_reused_branch(self) -> None: + with mock.patch.object( + ci_proof, "api_json", return_value=self.merged_pull() + ), mock.patch.object( + ci_proof, "require_origin_repository" + ), mock.patch.object( + ci_proof, "remote_ref_sha", return_value=None + ), mock.patch.object(ci_proof.subprocess, "run") as run_process: + result = ci_proof.delete_merged_head(self.args(allow_delete=False)) + self.assertFalse(result["deleted"]) + run_process.assert_called_once_with(["gh", "auth", "setup-git"], check=True) + + with mock.patch.object( + ci_proof, "api_json", return_value=self.merged_pull() + ), mock.patch.object( + ci_proof, "require_origin_repository" + ), mock.patch.object( + ci_proof, "remote_ref_sha", return_value="b" * 40 + ), mock.patch.object( + ci_proof.subprocess, "run" + ), self.assertRaisesRegex(ValueError, "replay.*refusing deletion"): + ci_proof.delete_merged_head(self.args(allow_delete=False)) + + with mock.patch.object( + ci_proof, "api_json", return_value=self.merged_pull() + ), mock.patch.object( + ci_proof, "require_origin_repository" + ), mock.patch.object( + ci_proof, "remote_ref_sha", return_value="c" * 40 + ), mock.patch.object( + ci_proof.subprocess, "run" + ), self.assertRaisesRegex(ValueError, "advanced or was reused"): + ci_proof.delete_merged_head(self.args()) + + class CandidateProofTests(unittest.TestCase): def git(self, root: pathlib.Path, *args: str) -> str: return subprocess.check_output(["git", *args], cwd=root, text=True).strip() diff --git a/script/tests/test_release_distribution_contracts.py b/script/tests/test_release_distribution_contracts.py index 7f0bb26..2262f73 100755 --- a/script/tests/test_release_distribution_contracts.py +++ b/script/tests/test_release_distribution_contracts.py @@ -1054,6 +1054,12 @@ class CIWorkflowContractTests(unittest.TestCase): self.assertIn("gh pr merge", source) self.assertIn('--squash --match-head-commit "$HEAD_SHA"', source) self.assertIn("./script/ci_proof_promotion.py verify-merge-source", source) + self.assertIn("./script/ci_proof_promotion.py delete-merged-head", source) + self.assertIn("newly_merged=$newly_merged", source) + self.assertIn('--allow-delete "$ALLOW_DELETE"', source) + cleanup = source.index("./script/ci_proof_promotion.py delete-merged-head") + self.assertGreater(cleanup, source.index("Squash or recover only the PR proven")) + self.assertGreater(cleanup, source.index("Dispatch exact merged main commit validation")) self.assertIn('gh workflow run ci.yml --ref main -f "expected_sha=$merge_commit"', source) development_flow = (ROOT / "script/development_flow.py").read_text(encoding="utf-8") self.assertNotIn('"--auto"', development_flow) diff --git a/script/validate_build_scripts_safety.sh b/script/validate_build_scripts_safety.sh index 7ce5b63..5392e7a 100755 --- a/script/validate_build_scripts_safety.sh +++ b/script/validate_build_scripts_safety.sh @@ -165,6 +165,11 @@ rg -Fq 'repos/$GITHUB_REPOSITORY/rules/branches/$encoded_base' \ rg -Fq '.context == "Required CI"' "$AUTO_MERGE_WORKFLOW" rg -Fq -- '--squash --match-head-commit "$HEAD_SHA"' "$AUTO_MERGE_WORKFLOW" rg -Fq './script/ci_proof_promotion.py verify-merge-source' "$AUTO_MERGE_WORKFLOW" +rg -Fq './script/ci_proof_promotion.py delete-merged-head' "$AUTO_MERGE_WORKFLOW" +rg -Fq -- '--allow-delete "$ALLOW_DELETE"' "$AUTO_MERGE_WORKFLOW" +rg -Fq 'newly_merged=$newly_merged' "$AUTO_MERGE_WORKFLOW" +rg -Fq 'f"--force-with-lease={full_ref}:{args.head_commit}"' \ + "$ROOT_DIR/script/ci_proof_promotion.py" rg -Fq 'ref: main' "$AUTO_MERGE_WORKFLOW" rg -Fq 'persist-credentials: false' "$AUTO_MERGE_WORKFLOW" rg -Fq 'gh workflow run ci.yml --ref main -f "expected_sha=$merge_commit"' \ diff --git a/script/validate_real_agent_connectors.sh b/script/validate_real_agent_connectors.sh index ffdcb1b..01fa890 100755 --- a/script/validate_real_agent_connectors.sh +++ b/script/validate_real_agent_connectors.sh @@ -220,7 +220,7 @@ if opencode_server.get("status") != "not_required": expected_contracts = { "codex": "codex-hooks-2026-08-01-events-v9", "claude_code": "claude-hooks-2026-08-01-events-v9", - "pi": "pi-extension-0.80.10-events-v13", + "pi": "pi-extension-0.80.10-events-v15", "opencode": "opencode-v1.18.4-events-v16", } expected_capability_counts = { @@ -291,7 +291,7 @@ entries = json.loads(os.environ["RECEIPTS"]) contracts = { "codex": "codex-hooks-2026-08-01-events-v9", "claude_code": "claude-hooks-2026-08-01-events-v9", - "pi": "pi-extension-0.80.10-events-v13", + "pi": "pi-extension-0.80.10-events-v15", "opencode": "opencode-v1.18.4-events-v16", } task_events = { diff --git a/script/validate_test_isolation.sh b/script/validate_test_isolation.sh index 45cd1ca..8055869 100755 --- a/script/validate_test_isolation.sh +++ b/script/validate_test_isolation.sh @@ -43,7 +43,10 @@ done write_executable "$SHIM_DIR/uname" \ '#!/usr/bin/env bash' \ - 'printf '\''Darwin\n'\''' + 'case "${1:-}" in' \ + ' -m) printf '\''arm64\n'\'' ;;' \ + ' *) printf '\''Darwin\n'\'' ;;' \ + 'esac' write_executable "$SHIM_DIR/cargo" \ '#!/usr/bin/env bash' \ 'if [[ -n "${APC_BUILD_ENV_LOG:-}" ]]; then' \ @@ -59,11 +62,19 @@ write_executable "$SHIM_DIR/swift" \ ' printf '\''%s\n'\'' "$APC_FAKE_SWIFT_BIN"' \ 'fi' \ 'exit 0' +write_executable "$SHIM_DIR/rustc" \ + '#!/usr/bin/env bash' \ + 'printf '\''%s\n'\'' "$APC_FAKE_RUST_LIBDIR"' \ + 'exit 0' +write_executable "$SHIM_DIR/rustup" \ + '#!/usr/bin/env bash' \ + 'exit 0' # Exercise build-only against a complete throwaway workspace. This catches # commands executed before argument parsing without compiling the real project. BUILD_ROOT="$TMP_DIR/build-workspace" FAKE_SWIFT_BIN="$TMP_DIR/fake-swift-bin" +FAKE_RUST_LIBDIR="$TMP_DIR/fake-rust-libdir" mkdir -p \ "$BUILD_ROOT/script" \ "$BUILD_ROOT/apps/macos" \ @@ -73,7 +84,8 @@ mkdir -p \ "$BUILD_ROOT/target/aarch64-apple-darwin/debug" \ "$BUILD_ROOT/skills/agent-pet-maker/scripts/__pycache__" \ "$BUILD_ROOT/skills/agent-pet-studio" \ - "$FAKE_SWIFT_BIN" + "$FAKE_SWIFT_BIN" \ + "$FAKE_RUST_LIBDIR" cp "$ROOT_DIR/script/build_and_run.sh" "$BUILD_ROOT/script/build_and_run.sh" cp "$ROOT_DIR/script/validation_helpers.sh" "$BUILD_ROOT/script/validation_helpers.sh" if [[ -f "$ROOT_DIR/script/build_app_bundle.sh" ]]; then @@ -446,7 +458,7 @@ R13_TEST_FILES=( ) R13_TEST_NAMES=( "overlayNavigationCopyAndAccessibilityMatchTheValidatedDestination" - "voiceOverReadingOrderKeepsLongEnglishAndChineseSessionCopySemantic" + "voiceOverReadingOrderUsesOnlyTheVisibleRetainedBodyMessage" "appStoreFocusActionsGuardDisabledRoutesAndDispatchTypedEnabledActions" "bubblePanelRestoresPassiveFocusStateWhenKeyboardNavigationEnds" "reducedMotionPinsPlaybackToRepresentativeFrameAndStopsScheduling" @@ -839,12 +851,15 @@ else "$BUILD_ROOT/script/build_app_bundle.sh" --archive --unsigned >/dev/null 2>&1; then record_failure 'build_app_bundle.sh accepted incompatible --archive and --unsigned options' fi + ARCH_BUILD_LOG="$TMP_DIR/isolated-arm64-build.log" if ! PATH="$SHIM_DIR:$PATH" \ APC_ISOLATION_FORBIDDEN_LOG="$FORBIDDEN_LOG" \ APC_FAKE_SWIFT_BIN="$FAKE_SWIFT_BIN" \ + APC_FAKE_RUST_LIBDIR="$FAKE_RUST_LIBDIR" \ "$BUILD_ROOT/script/build_app_bundle.sh" \ --arch arm64 \ - --output "$BUILD_ROOT/dist/AgentPetCompanion-arm64.app" >/dev/null 2>&1; then + --output "$BUILD_ROOT/dist/AgentPetCompanion-arm64.app" >"$ARCH_BUILD_LOG" 2>&1; then + cat "$ARCH_BUILD_LOG" >&2 record_failure 'build_app_bundle.sh --arch arm64 did not complete in the isolated fixture' elif [[ ! -d "$BUILD_ROOT/dist/AgentPetCompanion-arm64.app" ]]; then record_failure 'architecture-specific app bundle build did not create its App'