From 7e1b235c88afe5ed8bbb18741173db8f32cd5791 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:44:55 -0400 Subject: [PATCH 1/9] docs(pi): design persistent goal mode --- .../specs/2026-07-22-pi-goal-mode-design.md | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md diff --git a/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md b/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md new file mode 100644 index 000000000..90f2100b8 --- /dev/null +++ b/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md @@ -0,0 +1,138 @@ +# Pi 原生 `/goal` 持续模式设计 + +- 日期:2026-07-22 +- 状态:设计已获用户批准,待实现 +- 范围:Proma Electron 的 Pi Agent Runtime + +## 1. 背景与问题 + +Proma 已经通过 `PiAgentAdapter` 使用 `@earendil-works/pi-coding-agent`,但当前 `DefaultResourceLoader` 没有加载 Goal extension。因此,用户在 Agent 输入框中输入 `/goal <任务>` 时,命令不会被 Pi 的 extension command 路径处理。 + +Pi SDK 原生提供 `extensionFactories`、`registerCommand`、`before_agent_start`、`agent_end`、`session_start`、`session_tree`、`sendUserMessage` 和 `appendEntry`,足以实现一个不依赖新数据库、不绕过 Pi 生命周期的持续 Goal 模式。 + +## 2. 目标 + +实现以下行为: + +1. 用户输入 `/goal <任务>` 后,当前 Pi session 建立一个活动 Goal。 +2. Goal 状态持久化到 Pi session transcript,session resume 或 tree 切换后可以恢复。 +3. 每一轮 Agent 开始时,活动 Goal 自动注入上下文。 +4. 当前一轮结束但 Goal 尚未完成时,Pi 自动排队下一轮继续执行。 +5. Agent 通过内部 `goal_complete` 工具明确报告完成后,停止自动续跑。 +6. 用户输入 `/goal stop` 后,清除活动 Goal,后续不再自动续跑。 +7. Goal 循环有固定安全上限,避免异常模型或错误状态造成无限执行。 + +## 3. 非目标 + +本次不做: + +- Claude Runtime 的 `/goal` 实现。 +- 跨 session、跨 workspace 或跨设备的后台 Goal 调度。 +- Proactive Scheduler、Cron、Monitor 或云端常驻 Agent。 +- 新增数据库或新的持久化服务。 +- Goal 状态 UI、独立 Goal 面板或复杂命令补全。 +- 改动 Proma 现有 Stop 按钮、权限系统和 Pi retry/compaction 语义。 + +## 4. 架构方案 + +### 4.1 Inline Extension + +新增一个内联 Pi extension factory,放在 `apps/electron/src/main/lib/adapters/pi-goal-extension.ts`。`PiAgentAdapter` 创建 `DefaultResourceLoader` 时,将该 factory 与现有 Codex request settings extension 合并传入 `extensionFactories`。 + +extension 内部维护本 session 的短状态: + +```ts +interface PromaGoalState { + id: string + task: string + status: 'active' | 'completed' | 'stopped' | 'max_turns' + turnCount: number + createdAt: string + updatedAt: string +} +``` + +状态变化使用 `pi.appendEntry('proma-goal-state', state)` 追加到 Pi transcript。通过 `session_start` 和 `session_tree` 扫描当前 branch 的最新状态恢复内存状态;不写额外 JSON 文件。 + +### 4.2 命令 + +- `/goal <任务>`:创建新 Goal。若已有活动 Goal,则原子替换为新 Goal;命令本身触发首轮用户消息。 +- `/goal stop`:将活动 Goal 标为 `stopped` 并持久化;不再触发 follow-up。 +- 空参数或未知子命令:返回可见的用法错误,不启动模型调用。 + +命令通过 Pi `registerCommand('goal', ...)` 注册,避免在 Proma 外层复制 Pi 的 slash-command 解析逻辑。 + +### 4.3 每轮上下文与续跑 + +`before_agent_start` 在活动状态下追加隐藏的 Goal context,内容包括:当前任务、Goal ID、当前轮次、完成条件和停止条件。首轮命令消息也会要求 Agent: + +- 继续执行直到任务真正完成; +- 需要结束时调用 `goal_complete`; +- 遇到无法安全继续的情况说明阻塞原因,不伪造完成。 + +`agent_end` 在 Goal 仍为 `active` 且未达到安全上限时,使用 `pi.sendUserMessage(..., { deliverAs: 'followUp' })` 触发下一轮。每次续跑前递增并持久化 `turnCount`,确保 resume 后不会丢失预算。 + +达到安全上限时,将状态设为 `max_turns`,追加一条可见结果说明并停止续跑。默认上限为 50 轮,并集中定义为常量,后续可在社区反馈后调整。 + +### 4.4 完成工具 + +extension 注册一个仅供 Agent 使用的 `goal_complete` custom tool。工具执行时: + +1. 若没有活动 Goal,返回明确错误; +2. 将状态更新为 `completed` 并追加 transcript entry; +3. 返回完成确认,允许当前 turn 正常结束; +4. `agent_end` 发现状态已完成后不再排队下一轮。 + +工具参数只包含完成摘要,避免让 Agent 通过任意参数修改 Goal 状态。 + +## 5. 数据流 + +```text +用户 /goal <任务> + → Pi session.prompt + → registerCommand('goal') + → appendEntry(active state) + → sendUserMessage(initial goal instruction) + → Agent turn + → goal_complete 或未完成 + → agent_end + ├─ completed/stopped/max_turns:结束 + └─ active:followUp → 下一轮 Agent turn +``` + +Session resume/tree switch 时: + +```text +session_start/session_tree + → scan current branch + → restore latest proma-goal-state + → before_agent_start 注入活动 Goal +``` + +## 6. 错误与安全边界 + +- Goal 命令在没有任务文本时不得触发模型调用。 +- `/goal stop` 必须是幂等操作。 +- `goal_complete` 在没有活动 Goal 时不得改变状态。 +- 续跑只在 `ctx.isIdle()` 对应的 agent lifecycle 已结束后触发,使用 Pi 原生 follow-up 机制,不创建第二个 AgentSession。 +- 不修改 Pi 的 abort、retry、compaction 处理;若用户通过 Proma Stop 中止当前 turn,Goal 状态保留为 active,用户可以继续发送普通消息让 Goal 在后续 turn 中恢复,或输入 `/goal stop` 清除。 +- 固定 50 轮上限是无限循环的最后防线;任何未完成 Goal 都必须以可见状态结束。 +- 不把用户输入、API key 或完整模型输出复制到额外日志中。 + +## 7. 测试策略 + +采用 Bun test 与 BDD 风格,先写失败测试,再实现: + +1. Goal 状态 reducer/恢复:最新 transcript entry 正确决定状态。 +2. `/goal <任务>`:创建 active state、首轮指令和 turn count。 +3. `/goal stop`:停止并持久化,重复执行安全。 +4. `goal_complete`:完成活动 Goal;无活动 Goal 返回错误。 +5. `agent_end`:active Goal 触发 follow-up;completed/stopped/max_turns 不触发。 +6. 达到 50 轮上限后停止并写入 `max_turns`。 +7. `PiAgentAdapter` 将 Goal extension 与已有 extension factory 一起加载,不覆盖 Codex request settings extension。 + +验证顺序:相关 Bun 测试 → `bun run typecheck` → Pi adapter 构建 → 必要时 Electron 主进程构建。 + +## 8. 社区 PR 范围 + +PR 只包含 Pi Goal extension、adapter 接线、测试和必要的文档/版本 patch,不修改 Claude Runtime、不引入依赖、不包含 Proactive Center 或 Scheduler。PR 描述将明确说明:这是 session-scoped persistent goal,不是跨 session 的后台自动化。 From e869b4e3f6c69076ca41d5ea4dbf75d37a544868 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:47:16 -0400 Subject: [PATCH 2/9] docs(pi): add persistent goal implementation plan --- .../plans/2026-07-22-pi-goal-mode.md | 271 ++++++++++++++++++ 1 file changed, 271 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-22-pi-goal-mode.md diff --git a/docs/superpowers/plans/2026-07-22-pi-goal-mode.md b/docs/superpowers/plans/2026-07-22-pi-goal-mode.md new file mode 100644 index 000000000..5310bd472 --- /dev/null +++ b/docs/superpowers/plans/2026-07-22-pi-goal-mode.md @@ -0,0 +1,271 @@ +# Pi Persistent Goal Mode Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add a Pi-native, session-scoped `/goal ` mode that persists the active goal across turns, automatically continues unfinished work, and stops on completion, explicit stop, or a safety limit. + +**Architecture:** Register a small inline Pi extension factory from `PiAgentAdapter`. The extension owns goal state reconstruction, `/goal` command handling, the `goal_complete` tool, hidden per-turn context injection, and bounded follow-up turns. State is persisted as `proma-goal-state` entries in the existing Pi session transcript; no new database, IPC channel, or runtime dependency is introduced. + +**Tech Stack:** TypeScript, `@earendil-works/pi-coding-agent` 0.80.9, Bun test, esbuild. + +## Global Constraints + +- Use the existing Pi lifecycle; do not duplicate slash-command parsing in Proma. +- Keep the feature session-scoped; do not implement Scheduler, Cron, Monitor, or cross-session execution. +- Do not modify Claude Runtime behavior. +- Do not add dependencies or a new persistence service. +- Write comments and logs in Chinese where needed. +- Use BDD/TDD: write a failing test before production implementation. +- Preserve the existing Codex request-settings extension and existing retry/compaction behavior. +- Cap automatic continuation at 50 turns. + +--- + +### Task 1: Add pure Goal state model and failing tests + +**Files:** +- Create: `apps/electron/src/main/lib/adapters/pi-goal-extension.ts` +- Test: `apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts` + +**Interfaces:** +- Produces `PromaGoalState`, `GOAL_STATE_ENTRY_TYPE`, `GOAL_MAX_TURNS`, and an exported `createPromaGoalExtension()` factory. +- The state helpers must be testable without starting an actual provider session. + +- [ ] **Step 1: Write the failing test** + +Create Bun tests covering the pure state contract: + +```ts +import { describe, expect, test } from 'bun:test' +import { + GOAL_MAX_TURNS, + GOAL_STATE_ENTRY_TYPE, + getLatestGoalState, + type PromaGoalState, +} from './pi-goal-extension' + +describe('Proma goal state', () => { + test('uses the latest state entry on the current branch', () => { + const active: PromaGoalState = { + id: 'goal-1', task: 'ship feature', status: 'active', turnCount: 2, + createdAt: '2026-07-22T00:00:00.000Z', updatedAt: '2026-07-22T00:02:00.000Z', + } + const stopped = { ...active, status: 'stopped' as const, updatedAt: '2026-07-22T00:03:00.000Z' } + const branch = [ + { type: 'custom' as const, customType: 'other', data: {} }, + { type: 'custom' as const, customType: GOAL_STATE_ENTRY_TYPE, data: active }, + { type: 'custom' as const, customType: GOAL_STATE_ENTRY_TYPE, data: stopped }, + ] + expect(getLatestGoalState(branch)).toEqual(stopped) + }) + + test('defines a finite continuation limit', () => { + expect(GOAL_MAX_TURNS).toBe(50) + }) +}) +``` + +- [ ] **Step 2: Run the focused test to verify it fails** + +Run: + +```bash +bun test apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +``` + +Expected: FAIL because the new module and exports do not exist. + +- [ ] **Step 3: Implement the minimal pure state model** + +Define the state interface, entry type, limit, and `getLatestGoalState(branch)` by scanning the current branch from oldest to newest and accepting only valid `proma-goal-state` payloads. Keep malformed entries ignored rather than throwing during session resume. + +- [ ] **Step 4: Run the focused test to verify it passes** + +Run the same Bun test. Expected: PASS with zero failures. + +- [ ] **Step 5: Commit the focused state model** + +```bash +git add apps/electron/src/main/lib/adapters/pi-goal-extension.ts apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +git commit -m "feat(pi): add persistent goal state model" +``` + +### Task 2: Implement the Pi Goal extension behavior + +**Files:** +- Modify: `apps/electron/src/main/lib/adapters/pi-goal-extension.ts` +- Test: `apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts` + +**Interfaces:** +- `createPromaGoalExtension(): import('@earendil-works/pi-coding-agent').ExtensionFactory` +- Registers command `goal` and custom tool `goal_complete`. +- Persists state using `pi.appendEntry(GOAL_STATE_ENTRY_TYPE, state)`. + +- [ ] **Step 1: Add failing command/tool behavior tests** + +Use a small fake `ExtensionAPI`/session context harness that captures registered commands, tools, lifecycle handlers, appended entries, and sent messages. Cover: + +```ts +test('/goal creates an active goal and triggers the initial turn') +test('/goal stop is idempotent and prevents follow-up turns') +test('goal_complete marks the active goal completed') +test('agent_end queues follow-up only for an active goal below the limit') +test('the limit transitions an active goal to max_turns without queuing another turn') +``` + +Each test must fail before the extension behavior exists. + +- [ ] **Step 2: Run the focused tests and verify the expected failures** + +```bash +bun test apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +``` + +Expected: the behavior tests fail while the Task 1 state tests remain passing. + +- [ ] **Step 3: Implement command, tool, and lifecycle handlers** + +Implement the following behavior: + +- `/goal ` creates a new active state, persists it, and calls `pi.sendUserMessage()` with an explicit execution contract. +- `/goal stop` persists `stopped`; repeated stop does not throw or start a turn. +- Empty/unknown arguments use `ctx.ui.notify()` when available and otherwise emit a safe command error without calling the model. +- `session_start` and `session_tree` reconstruct the latest state from `ctx.sessionManager.getBranch()`. +- `before_agent_start` returns a hidden context message containing the task, ID, current turn, completion requirement, and stop condition. +- `goal_complete` accepts only a completion summary, persists `completed`, and returns a text result with state details. +- `agent_end` increments and persists the turn count, then sends one `followUp` with `triggerTurn: true` while active and below 50; it does nothing for completed, stopped, or max-turn states. +- At the limit, persist `max_turns` and emit a visible bounded-stop message through the Pi message API. + +Use a per-extension `continuationQueued` guard so one `agent_end` cannot enqueue duplicate follow-ups. + +- [ ] **Step 4: Run the focused tests to verify they pass** + +```bash +bun test apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +``` + +Expected: PASS with zero failures. + +- [ ] **Step 5: Commit the extension behavior** + +```bash +git add apps/electron/src/main/lib/adapters/pi-goal-extension.ts apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +git commit -m "feat(pi): add persistent goal extension" +``` + +### Task 3: Load the extension from PiAgentAdapter + +**Files:** +- Modify: `apps/electron/src/main/lib/adapters/pi-agent-adapter.ts` +- Test: `apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts` (create if no suitable existing adapter test seam exists) + +**Interfaces:** +- `DefaultResourceLoader.extensionFactories` receives the Goal factory plus the existing Codex request settings factory when applicable. +- No change to `createAgentSession` custom tools, retry, compaction, or message conversion. + +- [ ] **Step 1: Add a failing wiring test or deterministic source-level seam test** + +Refactor the extension factory list into a small exported/internal helper that accepts the provider and Codex fast-mode inputs. Test that Pi sessions always include the Goal factory and Codex sessions retain the request-settings factory without replacing it. + +- [ ] **Step 2: Run the focused adapter test and verify it fails** + +```bash +bun test apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts +``` + +Expected: FAIL because the Goal factory is not included. + +- [ ] **Step 3: Wire the Goal factory into `DefaultResourceLoader`** + +Import `createPromaGoalExtension` and set `extensionFactories` to an array containing it plus the conditional existing Codex settings extension. Preserve current `noSkills`, `skillsOverride`, `systemPromptOverride`, and remote connection settings unchanged. + +- [ ] **Step 4: Run focused tests and typecheck** + +```bash +bun test apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +bun run typecheck +``` + +Expected: both test commands and typecheck exit 0. + +- [ ] **Step 5: Commit the adapter wiring** + +```bash +git add apps/electron/src/main/lib/adapters/pi-agent-adapter.ts apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts + git commit -m "feat(pi): load goal extension in Proma sessions" +``` + +### Task 4: Build verification and documentation/version alignment + +**Files:** +- Modify: `apps/electron/package.json` only if the affected package patch version must be incremented by repository policy. +- Modify: `README.md` only if the current feature list documents Pi capabilities and needs the new session-scoped Goal behavior. +- Modify: `CLAUDE.md`/`AGENTS.md` only with explicit user approval; otherwise do not touch them. + +- [ ] **Step 1: Inspect the final diff and package version policy** + +```bash +git diff --stat HEAD~3..HEAD +git diff --check +git status --short +``` + +Confirm no unrelated files or generated artifacts are included. + +- [ ] **Step 2: Run affected checks** + +```bash +bun test apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts +bun run typecheck +bun run --filter='@proma/electron' build:main +``` + +Expected: all commands exit 0. If the repository has unrelated baseline failures, record them separately instead of masking them. + +- [ ] **Step 3: Review behavior against the approved spec** + +Check explicitly: command parsing, transcript persistence, resume/tree reconstruction, hidden context, completion, stop, max-turn guard, adapter wiring, no Claude changes, and no new dependency. + +- [ ] **Step 4: Commit any narrowly required docs/version change** + +Use a package-specific patch commit only if repository policy requires it for the actual changed package; do not modify instruction files without separate approval. + +### Task 5: Independent review and PR preparation + +**Files:** +- No production file changes unless review identifies a critical or important issue. +- Create/update: PR description outside the repository or via GitHub CLI/browser as authorized. + +- [ ] **Step 1: Capture the final revision and request an independent code review** + +```bash +BASE_SHA=$(git merge-base HEAD origin/main) +HEAD_SHA=$(git rev-parse HEAD) +printf '%s\n%s\n' "$BASE_SHA" "$HEAD_SHA" +``` + +Dispatch a fresh reviewer with the approved spec, changed paths, test commands, and these two SHAs. Resolve all critical/important findings before pushing. + +- [ ] **Step 2: Re-run fresh verification after review fixes** + +Run the full affected test, typecheck, and main build commands again on the final revision. + +- [ ] **Step 3: Push to the authorized fork and create the PR** + +```bash +git push -u fork feat/pi-goal-mode +``` + +Create a PR from `rongyulin3:feat/pi-goal-mode` into `proma-ai/Proma:main` with: + +- Problem: `/goal` is not registered in Proma's Pi runtime. +- Solution: session-scoped Pi inline extension with transcript persistence and bounded follow-up turns. +- Scope: Pi runtime only; no Scheduler/Cron/Claude changes. +- Verification: exact passing commands and results. +- Safety: `/goal stop` and 50-turn cap. + +Do not claim the feature is released; report the PR URL and review/CI state. + +## Execution stop conditions + +Stop and report `blocked` if the source branch changes unexpectedly, the fork credentials are unavailable, the PR target is ambiguous, or tests/build reveal a failure that needs a design change. Do not broaden scope silently. From 058ac02feb7b16a056eda6d8f6230878657fe71f Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:47:54 -0400 Subject: [PATCH 3/9] feat(pi): add persistent goal state model --- .../lib/adapters/pi-goal-extension.test.ts | 51 +++++++++++++++ .../main/lib/adapters/pi-goal-extension.ts | 65 +++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts create mode 100644 apps/electron/src/main/lib/adapters/pi-goal-extension.ts diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts new file mode 100644 index 000000000..02ca5aba0 --- /dev/null +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'bun:test' +import { + GOAL_MAX_TURNS, + GOAL_STATE_ENTRY_TYPE, + getLatestGoalState, + type PromaGoalState, +} from './pi-goal-extension' + +interface GoalBranchEntry { + type: 'custom' + customType: string + data: unknown +} + +describe('Proma goal state', () => { + test('uses the latest state entry on the current branch', () => { + const active: PromaGoalState = { + id: 'goal-1', + task: 'ship feature', + status: 'active', + turnCount: 2, + createdAt: '2026-07-22T00:00:00.000Z', + updatedAt: '2026-07-22T00:02:00.000Z', + } + const stopped: PromaGoalState = { + ...active, + status: 'stopped', + updatedAt: '2026-07-22T00:03:00.000Z', + } + const branch: GoalBranchEntry[] = [ + { type: 'custom', customType: 'other', data: {} }, + { type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: active }, + { type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: stopped }, + ] + + expect(getLatestGoalState(branch)).toEqual(stopped) + }) + + test('ignores malformed goal state entries', () => { + const branch: GoalBranchEntry[] = [ + { type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: { status: 'active' } }, + { type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: null }, + ] + + expect(getLatestGoalState(branch)).toBeUndefined() + }) + + test('defines a finite continuation limit', () => { + expect(GOAL_MAX_TURNS).toBe(50) + }) +}) diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts new file mode 100644 index 000000000..0373ddf06 --- /dev/null +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts @@ -0,0 +1,65 @@ +import type { ExtensionFactory } from '@earendil-works/pi-coding-agent' + +export const GOAL_STATE_ENTRY_TYPE = 'proma-goal-state' +export const GOAL_MAX_TURNS = 50 + +export type PromaGoalStatus = 'active' | 'completed' | 'stopped' | 'max_turns' + +export interface PromaGoalState { + id: string + task: string + status: PromaGoalStatus + turnCount: number + createdAt: string + updatedAt: string +} + +interface GoalStateEntry { + type: 'custom' + customType: string + data: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +function isGoalStatus(value: unknown): value is PromaGoalStatus { + return value === 'active' + || value === 'completed' + || value === 'stopped' + || value === 'max_turns' +} + +function isPromaGoalState(value: unknown): value is PromaGoalState { + if (!isRecord(value)) return false + + return typeof value.id === 'string' + && value.id.length > 0 + && typeof value.task === 'string' + && value.task.length > 0 + && isGoalStatus(value.status) + && typeof value.turnCount === 'number' + && Number.isInteger(value.turnCount) + && value.turnCount >= 0 + && typeof value.createdAt === 'string' + && typeof value.updatedAt === 'string' +} + +export function getLatestGoalState(branch: readonly unknown[]): PromaGoalState | undefined { + let latest: PromaGoalState | undefined + + for (const entry of branch) { + if (!isRecord(entry)) continue + if (entry.type !== 'custom' || entry.customType !== GOAL_STATE_ENTRY_TYPE) continue + + const data = (entry as GoalStateEntry).data + if (isPromaGoalState(data)) latest = data + } + + return latest +} + +export const createPromaGoalExtension: () => ExtensionFactory = () => { + throw new Error('Proma Goal extension is not implemented yet') +} From e369cfc714e61ebe1d7752502280208ad1eefac6 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:52:48 -0400 Subject: [PATCH 4/9] feat(pi): add persistent goal extension --- .../lib/adapters/pi-goal-extension.test.ts | 186 +++++++++++++++ .../main/lib/adapters/pi-goal-extension.ts | 212 +++++++++++++++++- 2 files changed, 388 insertions(+), 10 deletions(-) diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts index 02ca5aba0..ff65175a8 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts @@ -1,7 +1,14 @@ import { describe, expect, test } from 'bun:test' +import type { + AgentEndEvent, + ExtensionAPI, + ExtensionContext, + ToolDefinition, +} from '@earendil-works/pi-coding-agent' import { GOAL_MAX_TURNS, GOAL_STATE_ENTRY_TYPE, + createPromaGoalExtension, getLatestGoalState, type PromaGoalState, } from './pi-goal-extension' @@ -12,6 +19,91 @@ interface GoalBranchEntry { data: unknown } +interface SentUserMessage { + content: string + options?: { deliverAs?: 'steer' | 'followUp' } +} + +interface SentMessage { + message: { customType: string; content: string; display: boolean } + options?: { triggerTurn?: boolean; deliverAs?: 'steer' | 'followUp' | 'nextTurn' } +} + +interface GoalHarness { + branch: unknown[] + commands: Map Promise | void }> + tools: Map + handlers: Map Promise | unknown> + appended: Array<{ customType: string; data: unknown }> + sentUserMessages: SentUserMessage[] + sentMessages: SentMessage[] + notifications: string[] + context: ExtensionContext +} + +function createHarness(): GoalHarness { + const harness = { + branch: [], + commands: new Map(), + tools: new Map(), + handlers: new Map(), + appended: [], + sentUserMessages: [], + sentMessages: [], + notifications: [], + } as Omit + + const context = { + hasUI: false, + isIdle: () => true, + sessionManager: { + getBranch: () => harness.branch, + }, + ui: { + notify: (message: string) => harness.notifications.push(message), + }, + } as unknown as ExtensionContext + + const api = { + on: (event: string, handler: (event: unknown, ctx: ExtensionContext) => Promise | unknown) => { + harness.handlers.set(event, handler) + }, + registerCommand: (name: string, options: { handler: (args: string, ctx: ExtensionContext) => Promise | void }) => { + harness.commands.set(name, options) + }, + registerTool: (tool: ToolDefinition) => { + harness.tools.set(tool.name, tool) + }, + appendEntry: (customType: string, data: unknown) => { + harness.appended.push({ customType, data }) + harness.branch.push({ type: 'custom', customType, data }) + }, + sendUserMessage: (content: string, options?: SentUserMessage['options']) => { + harness.sentUserMessages.push({ content, options }) + }, + sendMessage: ( + message: SentMessage['message'], + options?: SentMessage['options'], + ) => { + harness.sentMessages.push({ message, options }) + }, + } as unknown as ExtensionAPI + + createPromaGoalExtension()(api) + + return { ...harness, context } +} + +function getHandler(harness: GoalHarness, event: string) { + const handler = harness.handlers.get(event) + if (!handler) throw new Error(`Missing handler: ${event}`) + return handler +} + +function getState(harness: GoalHarness): PromaGoalState | undefined { + return getLatestGoalState(harness.branch) +} + describe('Proma goal state', () => { test('uses the latest state entry on the current branch', () => { const active: PromaGoalState = { @@ -49,3 +141,97 @@ describe('Proma goal state', () => { expect(GOAL_MAX_TURNS).toBe(50) }) }) + +describe('Proma goal extension', () => { + test('/goal creates an active goal and triggers the initial turn', async () => { + const harness = createHarness() + const command = harness.commands.get('goal') + if (!command) throw new Error('Missing /goal command') + + await command.handler('ship the feature', harness.context) + + expect(getState(harness)).toMatchObject({ task: 'ship the feature', status: 'active', turnCount: 0 }) + expect(harness.appended.at(-1)?.customType).toBe(GOAL_STATE_ENTRY_TYPE) + expect(harness.sentUserMessages).toHaveLength(1) + expect(harness.sentUserMessages[0]?.content).toContain('ship the feature') + }) + + test('/goal stop is idempotent and prevents follow-up turns', async () => { + const harness = createHarness() + const command = harness.commands.get('goal') + if (!command) throw new Error('Missing /goal command') + + await command.handler('ship the feature', harness.context) + await command.handler('stop', harness.context) + await command.handler('stop', harness.context) + + expect(getState(harness)?.status).toBe('stopped') + expect(harness.sentUserMessages).toHaveLength(1) + expect(harness.notifications).toHaveLength(0) + expect(harness.sentMessages).toHaveLength(2) + }) + + test('goal_complete marks the active goal completed', async () => { + const harness = createHarness() + const command = harness.commands.get('goal') + const tool = harness.tools.get('goal_complete') + if (!command || !tool) throw new Error('Missing goal command or tool') + + await command.handler('ship the feature', harness.context) + await tool.execute( + 'tool-1', + { summary: 'verified the feature' } as never, + undefined, + undefined, + harness.context, + ) + + expect(getState(harness)?.status).toBe('completed') + }) + + test('before_agent_start injects the active goal context', async () => { + const harness = createHarness() + const command = harness.commands.get('goal') + if (!command) throw new Error('Missing /goal command') + + await command.handler('ship the feature', harness.context) + const result = await getHandler(harness, 'before_agent_start')({}, harness.context) as { + message?: { content?: string; display?: boolean } + } + + expect(result.message?.content).toContain('ship the feature') + expect(result.message?.display).toBe(false) + }) + + test('agent_end queues follow-up only for an active goal below the limit', async () => { + const harness = createHarness() + const command = harness.commands.get('goal') + if (!command) throw new Error('Missing /goal command') + + await command.handler('ship the feature', harness.context) + await getHandler(harness, 'agent_end')({} as AgentEndEvent, harness.context) + + expect(getState(harness)).toMatchObject({ status: 'active', turnCount: 1 }) + expect(harness.sentUserMessages).toHaveLength(2) + expect(harness.sentUserMessages[1]?.options).toEqual({ deliverAs: 'followUp' }) + }) + + test('the limit transitions an active goal to max_turns without queuing another turn', async () => { + const harness = createHarness() + const state: PromaGoalState = { + id: 'goal-1', + task: 'ship the feature', + status: 'active', + turnCount: GOAL_MAX_TURNS - 1, + createdAt: '2026-07-22T00:00:00.000Z', + updatedAt: '2026-07-22T00:00:00.000Z', + } + harness.branch.push({ type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: state }) + await getHandler(harness, 'session_start')({}, harness.context) + await getHandler(harness, 'agent_end')({} as AgentEndEvent, harness.context) + + expect(getState(harness)).toMatchObject({ status: 'max_turns', turnCount: GOAL_MAX_TURNS }) + expect(harness.sentUserMessages).toHaveLength(0) + expect(harness.sentMessages).toHaveLength(1) + }) +}) diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts index 0373ddf06..8923a583d 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts @@ -1,4 +1,12 @@ -import type { ExtensionFactory } from '@earendil-works/pi-coding-agent' +import { randomUUID } from 'node:crypto' +import type { + AgentEndEvent, + ExtensionAPI, + ExtensionContext, + ExtensionFactory, +} from '@earendil-works/pi-coding-agent' +import { Type } from 'typebox' +import type { AgentToolResult } from '@earendil-works/pi-agent-core' export const GOAL_STATE_ENTRY_TYPE = 'proma-goal-state' export const GOAL_MAX_TURNS = 50 @@ -14,12 +22,6 @@ export interface PromaGoalState { updatedAt: string } -interface GoalStateEntry { - type: 'custom' - customType: string - data: unknown -} - function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null } @@ -53,13 +55,203 @@ export function getLatestGoalState(branch: readonly unknown[]): PromaGoalState | if (!isRecord(entry)) continue if (entry.type !== 'custom' || entry.customType !== GOAL_STATE_ENTRY_TYPE) continue - const data = (entry as GoalStateEntry).data + const data = entry.data if (isPromaGoalState(data)) latest = data } return latest } -export const createPromaGoalExtension: () => ExtensionFactory = () => { - throw new Error('Proma Goal extension is not implemented yet') +const GOAL_CONTEXT_TYPE = 'proma-goal-context' +const GOAL_STATUS_MESSAGE_TYPE = 'proma-goal-status' + +function now(): string { + return new Date().toISOString() +} + +function createState(task: string): PromaGoalState { + const timestamp = now() + return { + id: `goal-${randomUUID()}`, + task, + status: 'active', + turnCount: 0, + createdAt: timestamp, + updatedAt: timestamp, + } +} + +function withState(state: PromaGoalState, updates: Partial): PromaGoalState { + return { ...state, ...updates, updatedAt: now() } +} + +function appendState(pi: ExtensionAPI, state: PromaGoalState): void { + pi.appendEntry(GOAL_STATE_ENTRY_TYPE, state) +} + +function notifyUsage(pi: ExtensionAPI, ctx: ExtensionContext, message: string): void { + if (ctx.hasUI) { + ctx.ui.notify(message, 'warning') + return + } + + pi.sendMessage({ + customType: GOAL_STATUS_MESSAGE_TYPE, + content: message, + display: true, + }) +} + +function goalStartMessage(task: string): string { + return [ + '[Proma Goal 开始]', + `目标:${task}`, + '请持续执行这个目标,不要只给计划。完成全部可验证工作后,必须调用 goal_complete,并在 summary 中说明完成证据。', + '如果遇到无法安全解决的阻塞,请停止并明确说明阻塞原因,不要伪造完成。', + ].join('\n') +} + +function goalFollowUpMessage(task: string): string { + return [ + '[Proma Goal 继续]', + `目标:${task}`, + '检查上一轮实际完成的工作,继续执行剩余任务。只有全部目标完成并有证据时才调用 goal_complete。', + ].join('\n') +} + +function goalContextMessage(state: PromaGoalState): string { + return [ + '[Proma Goal 上下文]', + `Goal ID:${state.id}`, + `目标:${state.task}`, + `当前续跑轮次:${state.turnCount}/${GOAL_MAX_TURNS}`, + '完成条件:完成目标中的全部可验证工作,并调用 goal_complete(summary)。', + '停止条件:用户输入 /goal stop、目标完成,或达到续跑上限。', + ].join('\n') +} + +function completeGoal( + pi: ExtensionAPI, + state: PromaGoalState | undefined, + summary: string, +): AgentToolResult { + if (!state || state.status !== 'active') { + return { + content: [{ type: 'text', text: '当前没有活动 Goal,不能标记完成。' }], + details: null, + } + } + + const completed = withState(state, { status: 'completed' }) + appendState(pi, completed) + return { + content: [{ type: 'text', text: `Goal 已完成:${summary}` }], + details: completed, + } +} + +export function createPromaGoalExtension(): ExtensionFactory { + return (pi) => { + let goalState: PromaGoalState | undefined + let continuationQueued = false + + const restoreState = (ctx: ExtensionContext): void => { + goalState = getLatestGoalState(ctx.sessionManager.getBranch()) + continuationQueued = false + } + + pi.on('session_start', (_event, ctx) => { + restoreState(ctx) + }) + + pi.on('session_tree', (_event, ctx) => { + restoreState(ctx) + }) + + pi.on('agent_start', () => { + continuationQueued = false + }) + + pi.on('before_agent_start', () => { + if (!goalState || goalState.status !== 'active') return + + return { + message: { + customType: GOAL_CONTEXT_TYPE, + content: goalContextMessage(goalState), + display: false, + }, + } + }) + + pi.registerCommand('goal', { + description: 'Start or stop a persistent Pi Goal', + handler: async (args, ctx) => { + const input = args.trim() + if (!input) { + notifyUsage(pi, ctx, '用法:/goal <任务> 或 /goal stop') + return + } + + if (input === 'stop') { + if (goalState?.status === 'active') { + goalState = withState(goalState, { status: 'stopped' }) + appendState(pi, goalState) + } + continuationQueued = false + notifyUsage(pi, ctx, '当前 Goal 已停止。') + return + } + + if (input.startsWith('stop ')) { + notifyUsage(pi, ctx, '用法:/goal <任务> 或 /goal stop') + return + } + + goalState = createState(input) + continuationQueued = false + appendState(pi, goalState) + pi.sendUserMessage(goalStartMessage(input)) + }, + }) + + pi.registerTool({ + name: 'goal_complete', + label: 'Goal 完成', + description: 'Mark the active Proma Goal as completed after verifying all requested work.', + promptSnippet: 'Mark the active Proma Goal complete only after all work is verified.', + parameters: Type.Object({ + summary: Type.String({ description: '简要说明完成的工作和验证证据' }), + }), + async execute(_toolCallId, params) { + const result = completeGoal(pi, goalState, params.summary) + if (result.details) { + goalState = result.details + continuationQueued = false + } + return result + }, + }) + + pi.on('agent_end', (_event: AgentEndEvent) => { + if (!goalState || goalState.status !== 'active' || continuationQueued) return + + const nextTurnCount = goalState.turnCount + 1 + if (nextTurnCount >= GOAL_MAX_TURNS) { + goalState = withState(goalState, { status: 'max_turns', turnCount: nextTurnCount }) + appendState(pi, goalState) + pi.sendMessage({ + customType: GOAL_STATUS_MESSAGE_TYPE, + content: `Goal 已达到 ${GOAL_MAX_TURNS} 轮安全上限,已停止自动续跑。请检查当前结果后重新发起 Goal。`, + display: true, + }) + return + } + + goalState = withState(goalState, { turnCount: nextTurnCount }) + appendState(pi, goalState) + continuationQueued = true + pi.sendUserMessage(goalFollowUpMessage(goalState.task), { deliverAs: 'followUp' }) + }) + } } From 63c2ffb908a02bd4ae06ab0c756bfe270b18b557 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:54:05 -0400 Subject: [PATCH 5/9] feat(pi): load goal extension in Proma sessions --- .../lib/adapters/pi-agent-adapter.test.ts | 26 +++++++++++++ .../src/main/lib/adapters/pi-agent-adapter.ts | 37 +++++++++++++++---- 2 files changed, 56 insertions(+), 7 deletions(-) create mode 100644 apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts diff --git a/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts b/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts new file mode 100644 index 000000000..da084b417 --- /dev/null +++ b/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, test } from 'bun:test' +import { buildPiExtensionFactories } from './pi-agent-adapter' + +describe('Pi extension factories', () => { + test('always loads the Proma Goal extension', () => { + const factories = buildPiExtensionFactories({ + provider: 'openai', + modelReasoning: false, + modelId: 'gpt-4o', + }) + + expect(factories).toHaveLength(1) + }) + + test('preserves Codex request settings alongside the Goal extension', () => { + const factories = buildPiExtensionFactories({ + provider: 'openai-codex', + modelReasoning: true, + modelId: 'gpt-5.6-sol', + codexFastMode: true, + openAIThinkingLevel: 'high', + }) + + expect(factories).toHaveLength(2) + }) +}) diff --git a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts index 1d83a0452..9ec93c42e 100644 --- a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts +++ b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts @@ -37,6 +37,7 @@ import { TRANSIENT_NETWORK_PATTERN, isMalformedResponseError } from '../error-pa import type { AgentSession, AgentSessionEvent, + InlineExtension, ResourceLoader, Skill, ToolDefinition, @@ -52,6 +53,7 @@ import { } from '../agent-runtime-guards' import { createPromaAgentsFilesOverride } from './pi-resource-loader-overrides' import { createCodexRequestSettingsExtension, withCodexFastModeServiceTier } from './pi-codex-request-settings' +import { createPromaGoalExtension } from './pi-goal-extension' import { mergeRuntimeEnv, type AgentRuntimeEnv } from '../agent-runtime-env' import { convertPiMessage, @@ -155,6 +157,28 @@ interface PendingInterruptPrompt { rejectAccepted: (error: unknown) => void } +export interface PiExtensionFactoryOptions { + provider: ProviderType + modelReasoning: boolean + modelId?: string + codexFastMode?: boolean + openAIThinkingLevel?: AgentThinkingLevel +} + +export function buildPiExtensionFactories(options: PiExtensionFactoryOptions): InlineExtension[] { + const extensionFactories: InlineExtension[] = [createPromaGoalExtension()] + const usesCodexResponses = options.provider === 'openai-codex' || options.provider === 'openai-responses' + + if (usesCodexResponses && options.modelReasoning && isOpenAIReasoningSupportedModel(options.modelId)) { + extensionFactories.push(createCodexRequestSettingsExtension({ + fastMode: options.codexFastMode, + thinkingLevel: options.openAIThinkingLevel, + })) + } + + return extensionFactories +} + interface PromaTaskItem { id: string subject: string @@ -1348,13 +1372,12 @@ export class PiAgentAdapter implements AgentProviderAdapter { additionalSkillPaths: input.additionalSkillPaths ?? [], skillsOverride: createPromaSkillsOverride(input.additionalSkillPaths), agentsFilesOverride: createPromaAgentsFilesOverride(), - ...((input.provider === 'openai-codex' || input.provider === 'openai-responses') - && model.reasoning - && isOpenAIReasoningSupportedModel(input.model) && { - extensionFactories: [createCodexRequestSettingsExtension({ - fastMode: input.codexFastMode, - thinkingLevel: input.openAIThinkingLevel, - })], + extensionFactories: buildPiExtensionFactories({ + provider: input.provider, + modelReasoning: model.reasoning, + modelId: input.model, + codexFastMode: input.codexFastMode, + openAIThinkingLevel: input.openAIThinkingLevel, }), systemPromptOverride: () => input.systemPrompt, }) From 0315c1d06ad1dde6db88a9a00610ee20a78e38a6 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 14:55:48 -0400 Subject: [PATCH 6/9] docs(pi): document persistent goal mode --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 8ad9ac16f..92790889b 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ Proma 的 Agent 模式提供两套可切换的内核: - **Claude Agent Runtime(默认)**:基于 `@anthropic-ai/claude-agent-sdk`,使用 Anthropic Messages API 或兼容端点。 - **Pi Agent Runtime**:基于 `@earendil-works/pi-coding-agent`、`pi-agent-core` 和 `pi-ai`,将 Proma 的已启用渠道动态注册为 Pi provider;支持 OpenAI Chat Completions / Responses、Google Generative AI、Anthropic Messages 及其兼容端点。 +- **Pi Goal 模式**:在 Pi Agent 会话中使用 `/goal <任务>` 持久保存目标并跨 turn 自动继续;可用 `/goal stop` 停止,完成后由 Agent 调用 `goal_complete`,并受 50 轮安全上限保护。 | 渠道类型 | Chat | Claude Agent | Pi Agent | | --- | --- | --- | --- | From 7cf69ad891cc745c9ca87e3b3931282d7c6384a8 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:01:02 -0400 Subject: [PATCH 7/9] fix(pi): restore goals in direct SDK sessions --- .../lib/adapters/pi-agent-adapter.test.ts | 2 +- .../src/main/lib/adapters/pi-agent-adapter.ts | 27 ++------------- .../lib/adapters/pi-extension-factories.ts | 29 ++++++++++++++++ .../lib/adapters/pi-goal-extension.test.ts | 20 +++++++++++ .../main/lib/adapters/pi-goal-extension.ts | 34 ++++++++++++------- 5 files changed, 73 insertions(+), 39 deletions(-) create mode 100644 apps/electron/src/main/lib/adapters/pi-extension-factories.ts diff --git a/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts b/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts index da084b417..b83285fcf 100644 --- a/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-agent-adapter.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { buildPiExtensionFactories } from './pi-agent-adapter' +import { buildPiExtensionFactories } from './pi-extension-factories' describe('Pi extension factories', () => { test('always loads the Proma Goal extension', () => { diff --git a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts index 9ec93c42e..c4e25f5d5 100644 --- a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts +++ b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts @@ -37,7 +37,6 @@ import { TRANSIENT_NETWORK_PATTERN, isMalformedResponseError } from '../error-pa import type { AgentSession, AgentSessionEvent, - InlineExtension, ResourceLoader, Skill, ToolDefinition, @@ -52,8 +51,8 @@ import { type AgentRuntimeGuard, } from '../agent-runtime-guards' import { createPromaAgentsFilesOverride } from './pi-resource-loader-overrides' -import { createCodexRequestSettingsExtension, withCodexFastModeServiceTier } from './pi-codex-request-settings' -import { createPromaGoalExtension } from './pi-goal-extension' +import { withCodexFastModeServiceTier } from './pi-codex-request-settings' +import { buildPiExtensionFactories } from './pi-extension-factories' import { mergeRuntimeEnv, type AgentRuntimeEnv } from '../agent-runtime-env' import { convertPiMessage, @@ -157,28 +156,6 @@ interface PendingInterruptPrompt { rejectAccepted: (error: unknown) => void } -export interface PiExtensionFactoryOptions { - provider: ProviderType - modelReasoning: boolean - modelId?: string - codexFastMode?: boolean - openAIThinkingLevel?: AgentThinkingLevel -} - -export function buildPiExtensionFactories(options: PiExtensionFactoryOptions): InlineExtension[] { - const extensionFactories: InlineExtension[] = [createPromaGoalExtension()] - const usesCodexResponses = options.provider === 'openai-codex' || options.provider === 'openai-responses' - - if (usesCodexResponses && options.modelReasoning && isOpenAIReasoningSupportedModel(options.modelId)) { - extensionFactories.push(createCodexRequestSettingsExtension({ - fastMode: options.codexFastMode, - thinkingLevel: options.openAIThinkingLevel, - })) - } - - return extensionFactories -} - interface PromaTaskItem { id: string subject: string diff --git a/apps/electron/src/main/lib/adapters/pi-extension-factories.ts b/apps/electron/src/main/lib/adapters/pi-extension-factories.ts new file mode 100644 index 000000000..02836c4c8 --- /dev/null +++ b/apps/electron/src/main/lib/adapters/pi-extension-factories.ts @@ -0,0 +1,29 @@ +import type { + InlineExtension, +} from '@earendil-works/pi-coding-agent' +import type { AgentThinkingLevel, ProviderType } from '@proma/shared' +import { isOpenAIReasoningSupportedModel } from '@proma/shared' +import { createCodexRequestSettingsExtension } from './pi-codex-request-settings' +import { createPromaGoalExtension } from './pi-goal-extension' + +export interface PiExtensionFactoryOptions { + provider: ProviderType + modelReasoning: boolean + modelId?: string + codexFastMode?: boolean + openAIThinkingLevel?: AgentThinkingLevel +} + +export function buildPiExtensionFactories(options: PiExtensionFactoryOptions): InlineExtension[] { + const extensionFactories: InlineExtension[] = [createPromaGoalExtension()] + const usesCodexResponses = options.provider === 'openai-codex' || options.provider === 'openai-responses' + + if (usesCodexResponses && options.modelReasoning && isOpenAIReasoningSupportedModel(options.modelId)) { + extensionFactories.push(createCodexRequestSettingsExtension({ + fastMode: options.codexFastMode, + thinkingLevel: options.openAIThinkingLevel, + })) + } + + return extensionFactories +} diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts index ff65175a8..ab0792838 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts @@ -203,6 +203,26 @@ describe('Proma goal extension', () => { expect(result.message?.display).toBe(false) }) + test('before_agent_start lazily restores a goal when the host does not emit session_start', async () => { + const harness = createHarness() + const state: PromaGoalState = { + id: 'goal-1', + task: 'resume the feature', + status: 'active', + turnCount: 3, + createdAt: '2026-07-22T00:00:00.000Z', + updatedAt: '2026-07-22T00:00:00.000Z', + } + harness.branch.push({ type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: state }) + + const result = await getHandler(harness, 'before_agent_start')({}, harness.context) as { + message?: { content?: string; display?: boolean } + } + + expect(result.message?.content).toContain('resume the feature') + expect(result.message?.display).toBe(false) + }) + test('agent_end queues follow-up only for an active goal below the limit', async () => { const harness = createHarness() const command = harness.commands.get('goal') diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts index 8923a583d..5fc69d1b1 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts @@ -160,6 +160,11 @@ export function createPromaGoalExtension(): ExtensionFactory { continuationQueued = false } + const getState = (ctx: ExtensionContext): PromaGoalState | undefined => { + if (!goalState) restoreState(ctx) + return goalState + } + pi.on('session_start', (_event, ctx) => { restoreState(ctx) }) @@ -172,13 +177,14 @@ export function createPromaGoalExtension(): ExtensionFactory { continuationQueued = false }) - pi.on('before_agent_start', () => { - if (!goalState || goalState.status !== 'active') return + pi.on('before_agent_start', (_event, ctx) => { + const currentGoal = getState(ctx) + if (!currentGoal || currentGoal.status !== 'active') return return { message: { customType: GOAL_CONTEXT_TYPE, - content: goalContextMessage(goalState), + content: goalContextMessage(currentGoal), display: false, }, } @@ -194,8 +200,9 @@ export function createPromaGoalExtension(): ExtensionFactory { } if (input === 'stop') { - if (goalState?.status === 'active') { - goalState = withState(goalState, { status: 'stopped' }) + const currentGoal = getState(ctx) + if (currentGoal?.status === 'active') { + goalState = withState(currentGoal, { status: 'stopped' }) appendState(pi, goalState) } continuationQueued = false @@ -223,8 +230,8 @@ export function createPromaGoalExtension(): ExtensionFactory { parameters: Type.Object({ summary: Type.String({ description: '简要说明完成的工作和验证证据' }), }), - async execute(_toolCallId, params) { - const result = completeGoal(pi, goalState, params.summary) + async execute(_toolCallId, params, _signal, _onUpdate, ctx) { + const result = completeGoal(pi, getState(ctx), params.summary) if (result.details) { goalState = result.details continuationQueued = false @@ -233,12 +240,13 @@ export function createPromaGoalExtension(): ExtensionFactory { }, }) - pi.on('agent_end', (_event: AgentEndEvent) => { - if (!goalState || goalState.status !== 'active' || continuationQueued) return + pi.on('agent_end', (_event: AgentEndEvent, ctx) => { + const currentGoal = getState(ctx) + if (!currentGoal || currentGoal.status !== 'active' || continuationQueued) return - const nextTurnCount = goalState.turnCount + 1 + const nextTurnCount = currentGoal.turnCount + 1 if (nextTurnCount >= GOAL_MAX_TURNS) { - goalState = withState(goalState, { status: 'max_turns', turnCount: nextTurnCount }) + goalState = withState(currentGoal, { status: 'max_turns', turnCount: nextTurnCount }) appendState(pi, goalState) pi.sendMessage({ customType: GOAL_STATUS_MESSAGE_TYPE, @@ -248,10 +256,10 @@ export function createPromaGoalExtension(): ExtensionFactory { return } - goalState = withState(goalState, { turnCount: nextTurnCount }) + goalState = withState(currentGoal, { turnCount: nextTurnCount }) appendState(pi, goalState) continuationQueued = true - pi.sendUserMessage(goalFollowUpMessage(goalState.task), { deliverAs: 'followUp' }) + pi.sendUserMessage(goalFollowUpMessage(currentGoal.task), { deliverAs: 'followUp' }) }) } } From e28872c0f03340849e3cccab6e88b08c1206ead0 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:11:37 -0400 Subject: [PATCH 8/9] fix(pi): bound persistent goal lifecycle --- .../src/main/lib/adapters/pi-agent-adapter.ts | 2 + .../lib/adapters/pi-command-lifecycle.test.ts | 46 +++++++++++++++++++ .../main/lib/adapters/pi-command-lifecycle.ts | 22 +++++++++ .../lib/adapters/pi-goal-extension.test.ts | 36 ++++++++++++++- .../main/lib/adapters/pi-goal-extension.ts | 18 +++++--- .../lib/adapters/pi-message-adapter.test.ts | 25 ++++++++++ .../main/lib/adapters/pi-message-adapter.ts | 23 ++++++++++ .../plans/2026-07-22-pi-goal-mode.md | 5 +- .../specs/2026-07-22-pi-goal-mode-design.md | 6 ++- 9 files changed, 171 insertions(+), 12 deletions(-) create mode 100644 apps/electron/src/main/lib/adapters/pi-command-lifecycle.test.ts create mode 100644 apps/electron/src/main/lib/adapters/pi-command-lifecycle.ts diff --git a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts index c4e25f5d5..edcca00e3 100644 --- a/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts +++ b/apps/electron/src/main/lib/adapters/pi-agent-adapter.ts @@ -52,6 +52,7 @@ import { } from '../agent-runtime-guards' import { createPromaAgentsFilesOverride } from './pi-resource-loader-overrides' import { withCodexFastModeServiceTier } from './pi-codex-request-settings' +import { waitForGoalCommandTurn } from './pi-command-lifecycle' import { buildPiExtensionFactories } from './pi-extension-factories' import { mergeRuntimeEnv, type AgentRuntimeEnv } from '../agent-runtime-env' import { @@ -1667,6 +1668,7 @@ export class PiAgentAdapter implements AgentProviderAdapter { } currentInterrupt?.resolveAccepted() await session.prompt(prompt, { source: 'rpc' }) + await waitForGoalCommandTurn(session, prompt) persistPiEntryBindings() if (compactContextRequested) { await compactCurrentSessionAfterTurn(session, (message) => queue.push(message)) diff --git a/apps/electron/src/main/lib/adapters/pi-command-lifecycle.test.ts b/apps/electron/src/main/lib/adapters/pi-command-lifecycle.test.ts new file mode 100644 index 000000000..5da0b9fa9 --- /dev/null +++ b/apps/electron/src/main/lib/adapters/pi-command-lifecycle.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from 'bun:test' +import { + isGoalStartCommand, + waitForGoalCommandTurn, + type PiSessionLifecycle, +} from './pi-command-lifecycle' + +describe('Pi Goal command lifecycle', () => { + test('recognizes goal starts but reserves exact stop command', () => { + expect(isGoalStartCommand('/goal ship the feature')).toBe(true) + expect(isGoalStartCommand('/goal stop procrastinating')).toBe(true) + expect(isGoalStartCommand('/goal stop')).toBe(false) + expect(isGoalStartCommand('/goal')).toBe(false) + }) + + test('waits for the nested goal turn after the command handler returns', async () => { + let waited = false + const session: PiSessionLifecycle = { + isStreaming: true, + pendingMessageCount: 0, + waitForIdle: async () => { + waited = true + }, + } + + await waitForGoalCommandTurn(session, '/goal ship the feature') + + expect(waited).toBe(true) + }) + + test('does not wait for stop or ordinary prompts', async () => { + let waited = false + const session: PiSessionLifecycle = { + isStreaming: true, + pendingMessageCount: 1, + waitForIdle: async () => { + waited = true + }, + } + + await waitForGoalCommandTurn(session, '/goal stop') + await waitForGoalCommandTurn(session, 'continue the task') + + expect(waited).toBe(false) + }) +}) diff --git a/apps/electron/src/main/lib/adapters/pi-command-lifecycle.ts b/apps/electron/src/main/lib/adapters/pi-command-lifecycle.ts new file mode 100644 index 000000000..e671b38a4 --- /dev/null +++ b/apps/electron/src/main/lib/adapters/pi-command-lifecycle.ts @@ -0,0 +1,22 @@ +import type { AgentSession } from '@earendil-works/pi-coding-agent' + +export type PiSessionLifecycle = Pick + +export function isGoalStartCommand(prompt: string): boolean { + const normalized = prompt.trim() + return normalized.startsWith('/goal ') && normalized !== '/goal stop' +} + +export async function waitForGoalCommandTurn( + session: PiSessionLifecycle, + prompt: string, +): Promise { + if (!isGoalStartCommand(prompt)) return + + // ExtensionAPI.sendUserMessage() intentionally does not expose its promise. + // 等一个事件循环,让 Pi 先把内嵌的 Goal turn 标记为 active,再等待它收束。 + await new Promise((resolve) => setImmediate(resolve)) + if (session.isStreaming || session.pendingMessageCount > 0) { + await session.waitForIdle() + } +} diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts index ab0792838..4c1f70b20 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts @@ -156,6 +156,17 @@ describe('Proma goal extension', () => { expect(harness.sentUserMessages[0]?.content).toContain('ship the feature') }) + test('accepts a task that starts with the word stop', async () => { + const harness = createHarness() + const command = harness.commands.get('goal') + if (!command) throw new Error('Missing /goal command') + + await command.handler('stop procrastinating', harness.context) + + expect(getState(harness)).toMatchObject({ task: 'stop procrastinating', status: 'active' }) + expect(harness.sentUserMessages).toHaveLength(1) + }) + test('/goal stop is idempotent and prevents follow-up turns', async () => { const harness = createHarness() const command = harness.commands.get('goal') @@ -229,13 +240,31 @@ describe('Proma goal extension', () => { if (!command) throw new Error('Missing /goal command') await command.handler('ship the feature', harness.context) - await getHandler(harness, 'agent_end')({} as AgentEndEvent, harness.context) + await getHandler(harness, 'agent_end')({ + type: 'agent_end', + messages: [{ role: 'assistant', content: [], stopReason: 'stop' }], + } as unknown as AgentEndEvent, harness.context) expect(getState(harness)).toMatchObject({ status: 'active', turnCount: 1 }) expect(harness.sentUserMessages).toHaveLength(2) expect(harness.sentUserMessages[1]?.options).toEqual({ deliverAs: 'followUp' }) }) + test.each(['aborted', 'error'] as const)('does not continue after an %s assistant ending', async (stopReason) => { + const harness = createHarness() + const command = harness.commands.get('goal') + if (!command) throw new Error('Missing /goal command') + + await command.handler('ship the feature', harness.context) + await getHandler(harness, 'agent_end')({ + type: 'agent_end', + messages: [{ role: 'assistant', content: [], stopReason }], + } as unknown as AgentEndEvent, harness.context) + + expect(getState(harness)).toMatchObject({ status: 'active', turnCount: 0 }) + expect(harness.sentUserMessages).toHaveLength(1) + }) + test('the limit transitions an active goal to max_turns without queuing another turn', async () => { const harness = createHarness() const state: PromaGoalState = { @@ -248,7 +277,10 @@ describe('Proma goal extension', () => { } harness.branch.push({ type: 'custom', customType: GOAL_STATE_ENTRY_TYPE, data: state }) await getHandler(harness, 'session_start')({}, harness.context) - await getHandler(harness, 'agent_end')({} as AgentEndEvent, harness.context) + await getHandler(harness, 'agent_end')({ + type: 'agent_end', + messages: [{ role: 'assistant', content: [], stopReason: 'stop' }], + } as unknown as AgentEndEvent, harness.context) expect(getState(harness)).toMatchObject({ status: 'max_turns', turnCount: GOAL_MAX_TURNS }) expect(harness.sentUserMessages).toHaveLength(0) diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts index 5fc69d1b1..c4f9df688 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts @@ -130,6 +130,16 @@ function goalContextMessage(state: PromaGoalState): string { ].join('\n') } +function canContinueAfterAgentEnd(event: AgentEndEvent): boolean { + const lastAssistant = [...event.messages].reverse().find((message) => { + return isRecord(message) && message.role === 'assistant' + }) + if (!lastAssistant) return false + + const stopReason = (lastAssistant as unknown as Record).stopReason + return stopReason !== 'aborted' && stopReason !== 'error' +} + function completeGoal( pi: ExtensionAPI, state: PromaGoalState | undefined, @@ -210,11 +220,6 @@ export function createPromaGoalExtension(): ExtensionFactory { return } - if (input.startsWith('stop ')) { - notifyUsage(pi, ctx, '用法:/goal <任务> 或 /goal stop') - return - } - goalState = createState(input) continuationQueued = false appendState(pi, goalState) @@ -240,9 +245,10 @@ export function createPromaGoalExtension(): ExtensionFactory { }, }) - pi.on('agent_end', (_event: AgentEndEvent, ctx) => { + pi.on('agent_end', (event: AgentEndEvent, ctx) => { const currentGoal = getState(ctx) if (!currentGoal || currentGoal.status !== 'active' || continuationQueued) return + if (!canContinueAfterAgentEnd(event)) return const nextTurnCount = currentGoal.turnCount + 1 if (nextTurnCount >= GOAL_MAX_TURNS) { diff --git a/apps/electron/src/main/lib/adapters/pi-message-adapter.test.ts b/apps/electron/src/main/lib/adapters/pi-message-adapter.test.ts index f7b5b2a30..a822c61f2 100644 --- a/apps/electron/src/main/lib/adapters/pi-message-adapter.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-message-adapter.test.ts @@ -44,6 +44,31 @@ describe('convertPiMessage', () => { expect(JSON.stringify(message).length).toBeGreaterThan(content.length) }) + test('converts visible Pi custom messages for Proma rendering', () => { + const message = convertPiMessage({ + role: 'custom', + customType: 'proma-goal-status', + content: 'Goal 已停止。', + display: true, + timestamp: Date.now(), + } as never, 'session-1') as { type?: string; message?: { content?: Array<{ text?: string }> } } + + expect(message.type).toBe('assistant') + expect(message.message?.content?.[0]?.text).toBe('Goal 已停止。') + }) + + test('keeps hidden Pi custom context out of Proma messages', () => { + const message = convertPiMessage({ + role: 'custom', + customType: 'proma-goal-context', + content: 'internal goal context', + display: false, + timestamp: Date.now(), + } as never, 'session-1') + + expect(message).toBeNull() + }) + test('only persists errors for terminal Pi failures', () => { const providerError = 'stream ended before a terminal response event' const partialStop = convertPiMessage({ diff --git a/apps/electron/src/main/lib/adapters/pi-message-adapter.ts b/apps/electron/src/main/lib/adapters/pi-message-adapter.ts index 092fff875..cf9dc77f6 100644 --- a/apps/electron/src/main/lib/adapters/pi-message-adapter.ts +++ b/apps/electron/src/main/lib/adapters/pi-message-adapter.ts @@ -194,6 +194,29 @@ export function convertPiMessage( const final = options.final ?? true if (!message || typeof message !== 'object' || !('role' in message)) return null + if (message.role === 'custom') { + const custom = message as unknown as { content: unknown; display: boolean } + if (!custom.display) return null + + return { + type: 'assistant', + message: { + content: [{ type: 'text', text: contentToText(custom.content) }], + usage: { + input_tokens: 0, + output_tokens: 0, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + stop_reason: 'stop', + }, + parent_tool_use_id: null, + session_id: sessionId, + uuid: options.uuid ?? randomUUID(), + ...(channelModelId && { _channelModelId: channelModelId }), + } as unknown as SDKMessage + } + if (message.role === 'user') { const user = message as UserMessage return { diff --git a/docs/superpowers/plans/2026-07-22-pi-goal-mode.md b/docs/superpowers/plans/2026-07-22-pi-goal-mode.md index 5310bd472..4e0e179cf 100644 --- a/docs/superpowers/plans/2026-07-22-pi-goal-mode.md +++ b/docs/superpowers/plans/2026-07-22-pi-goal-mode.md @@ -133,8 +133,9 @@ Implement the following behavior: - `session_start` and `session_tree` reconstruct the latest state from `ctx.sessionManager.getBranch()`. - `before_agent_start` returns a hidden context message containing the task, ID, current turn, completion requirement, and stop condition. - `goal_complete` accepts only a completion summary, persists `completed`, and returns a text result with state details. -- `agent_end` increments and persists the turn count, then sends one `followUp` with `triggerTurn: true` while active and below 50; it does nothing for completed, stopped, or max-turn states. -- At the limit, persist `max_turns` and emit a visible bounded-stop message through the Pi message API. +- `agent_end` checks the final assistant stop reason, skips continuation for `aborted`/`error`, increments and persists the turn count, then sends one `followUp` while active and below 50; it does nothing for completed, stopped, or max-turn states. +- At the limit, persist `max_turns` and emit a visible bounded-stop message through the Pi message API; the Proma Pi message adapter converts visible custom messages for renderer display. +- Because Proma directly hosts `AgentSession` instead of Pi's TUI/RPC mode, wait for a command-triggered Goal turn to become idle before adapter cleanup. Use a per-extension `continuationQueued` guard so one `agent_end` cannot enqueue duplicate follow-ups. diff --git a/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md b/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md index 90f2100b8..c7d58468d 100644 --- a/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md +++ b/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md @@ -70,9 +70,11 @@ interface PromaGoalState { - 需要结束时调用 `goal_complete`; - 遇到无法安全继续的情况说明阻塞原因,不伪造完成。 -`agent_end` 在 Goal 仍为 `active` 且未达到安全上限时,使用 `pi.sendUserMessage(..., { deliverAs: 'followUp' })` 触发下一轮。每次续跑前递增并持久化 `turnCount`,确保 resume 后不会丢失预算。 +`agent_end` 在 Goal 仍为 `active` 且未达到安全上限时,使用 `pi.sendUserMessage(..., { deliverAs: 'followUp' })` 触发下一轮。每次续跑前递增并持久化 `turnCount`,确保 resume 后不会丢失预算。若上一轮的最终 assistant message 是 `aborted` 或 `error`,不自动续跑,避免中止、重试和失败路径被 Goal 循环放大。 -达到安全上限时,将状态设为 `max_turns`,追加一条可见结果说明并停止续跑。默认上限为 50 轮,并集中定义为常量,后续可在社区反馈后调整。 +Proma 直接调用 Pi `AgentSession`,没有 Pi TUI/RPC mode 替 extension command 等待嵌套 turn。因此 adapter 在完成 `/goal <任务>` command prompt 后等待该嵌套 Goal turn 的 `waitForIdle()`,保证 session 不会在首轮刚排队时被清理。 + +达到安全上限时,将状态设为 `max_turns`,追加一条可见结果说明并停止续跑。默认上限为 50 轮,并集中定义为常量,后续可在社区反馈后调整。Pi custom message 中 `display: true` 的 Goal 状态消息会在兼容层转换为 Proma 可渲染的 assistant 状态消息;隐藏 Goal context 不进入 UI 消息流。 ### 4.4 完成工具 From bbb46f1494ffe6c269ec84a62995e42e60ddadb7 Mon Sep 17 00:00:00 2001 From: "Ron (Rongyu) Lin" <177554575+rongyulin3@users.noreply.github.com> Date: Wed, 22 Jul 2026 15:17:27 -0400 Subject: [PATCH 9/9] fix(pi): prevent extra goal turn at limit --- apps/electron/package.json | 2 +- .../lib/adapters/pi-goal-extension.test.ts | 4 ++++ .../src/main/lib/adapters/pi-goal-extension.ts | 18 +++++++++++++----- .../plans/2026-07-22-pi-goal-mode.md | 2 +- .../specs/2026-07-22-pi-goal-mode-design.md | 2 +- 5 files changed, 20 insertions(+), 8 deletions(-) diff --git a/apps/electron/package.json b/apps/electron/package.json index 922b79e89..8fa6d141e 100644 --- a/apps/electron/package.json +++ b/apps/electron/package.json @@ -1,6 +1,6 @@ { "name": "@proma/electron", - "version": "0.15.10", + "version": "0.15.11", "description": "Proma next gen ai software with general agents - Electron App", "main": "dist/main.cjs", "author": { diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts index 4c1f70b20..7dc59a629 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.test.ts @@ -284,6 +284,10 @@ describe('Proma goal extension', () => { expect(getState(harness)).toMatchObject({ status: 'max_turns', turnCount: GOAL_MAX_TURNS }) expect(harness.sentUserMessages).toHaveLength(0) + expect(harness.sentMessages).toHaveLength(0) + + await getHandler(harness, 'agent_settled')({}, harness.context) + expect(harness.sentMessages).toHaveLength(1) }) }) diff --git a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts index c4f9df688..bed57c48a 100644 --- a/apps/electron/src/main/lib/adapters/pi-goal-extension.ts +++ b/apps/electron/src/main/lib/adapters/pi-goal-extension.ts @@ -164,6 +164,7 @@ export function createPromaGoalExtension(): ExtensionFactory { return (pi) => { let goalState: PromaGoalState | undefined let continuationQueued = false + let maxTurnsNoticePending = false const restoreState = (ctx: ExtensionContext): void => { goalState = getLatestGoalState(ctx.sessionManager.getBranch()) @@ -187,6 +188,17 @@ export function createPromaGoalExtension(): ExtensionFactory { continuationQueued = false }) + pi.on('agent_settled', () => { + if (!maxTurnsNoticePending) return + + maxTurnsNoticePending = false + pi.sendMessage({ + customType: GOAL_STATUS_MESSAGE_TYPE, + content: `Goal 已达到 ${GOAL_MAX_TURNS} 轮安全上限,已停止自动续跑。请检查当前结果后重新发起 Goal。`, + display: true, + }) + }) + pi.on('before_agent_start', (_event, ctx) => { const currentGoal = getState(ctx) if (!currentGoal || currentGoal.status !== 'active') return @@ -254,11 +266,7 @@ export function createPromaGoalExtension(): ExtensionFactory { if (nextTurnCount >= GOAL_MAX_TURNS) { goalState = withState(currentGoal, { status: 'max_turns', turnCount: nextTurnCount }) appendState(pi, goalState) - pi.sendMessage({ - customType: GOAL_STATUS_MESSAGE_TYPE, - content: `Goal 已达到 ${GOAL_MAX_TURNS} 轮安全上限,已停止自动续跑。请检查当前结果后重新发起 Goal。`, - display: true, - }) + maxTurnsNoticePending = true return } diff --git a/docs/superpowers/plans/2026-07-22-pi-goal-mode.md b/docs/superpowers/plans/2026-07-22-pi-goal-mode.md index 4e0e179cf..e9f00029a 100644 --- a/docs/superpowers/plans/2026-07-22-pi-goal-mode.md +++ b/docs/superpowers/plans/2026-07-22-pi-goal-mode.md @@ -134,7 +134,7 @@ Implement the following behavior: - `before_agent_start` returns a hidden context message containing the task, ID, current turn, completion requirement, and stop condition. - `goal_complete` accepts only a completion summary, persists `completed`, and returns a text result with state details. - `agent_end` checks the final assistant stop reason, skips continuation for `aborted`/`error`, increments and persists the turn count, then sends one `followUp` while active and below 50; it does nothing for completed, stopped, or max-turn states. -- At the limit, persist `max_turns` and emit a visible bounded-stop message through the Pi message API; the Proma Pi message adapter converts visible custom messages for renderer display. +- At the limit, persist `max_turns`; emit the visible bounded-stop message only from `agent_settled` so the status message cannot enqueue an extra model turn; the Proma Pi message adapter converts visible custom messages for renderer display. - Because Proma directly hosts `AgentSession` instead of Pi's TUI/RPC mode, wait for a command-triggered Goal turn to become idle before adapter cleanup. Use a per-extension `continuationQueued` guard so one `agent_end` cannot enqueue duplicate follow-ups. diff --git a/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md b/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md index c7d58468d..864ed4244 100644 --- a/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md +++ b/docs/superpowers/specs/2026-07-22-pi-goal-mode-design.md @@ -74,7 +74,7 @@ interface PromaGoalState { Proma 直接调用 Pi `AgentSession`,没有 Pi TUI/RPC mode 替 extension command 等待嵌套 turn。因此 adapter 在完成 `/goal <任务>` command prompt 后等待该嵌套 Goal turn 的 `waitForIdle()`,保证 session 不会在首轮刚排队时被清理。 -达到安全上限时,将状态设为 `max_turns`,追加一条可见结果说明并停止续跑。默认上限为 50 轮,并集中定义为常量,后续可在社区反馈后调整。Pi custom message 中 `display: true` 的 Goal 状态消息会在兼容层转换为 Proma 可渲染的 assistant 状态消息;隐藏 Goal context 不进入 UI 消息流。 +达到安全上限时,将状态设为 `max_turns`,停止续跑,并在 `agent_settled` 后追加一条可见结果说明。延迟到 settled 是为了避免在 `agent_end` 期间把状态消息当成 follow-up 队列内容,导致额外模型调用。默认上限为 50 轮,并集中定义为常量,后续可在社区反馈后调整。Pi custom message 中 `display: true` 的 Goal 状态消息会在兼容层转换为 Proma 可渲染的 assistant 状态消息;隐藏 Goal context 不进入 UI 消息流。 ### 4.4 完成工具