Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 29 additions & 4 deletions Sources/AgentPetCore/Integration/ExtensionTemplate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import Foundation
/// events the pet needs; those events, the agent's id, its ownership marker and
/// the cost note are what vary between the two generated files:
///
/// | what it is for | Pi 0.85 | Oh My Pi 18 |
/// | what it is for | Pi 0.87 | Oh My Pi 18 |
/// |---|---|---|
/// | "blocked on you" | `ui_prompt_start` (its own prompt is on screen) | `tool_approval_requested` / the `ask` tool |
/// | a finished turn | `agent_settled` | `agent_end`, minus the ones carrying `willContinue` |
Expand Down Expand Up @@ -141,7 +141,32 @@ public enum ExtensionTemplate {
return "pid-" + process.pid;
}

function send(event, payload) {
// A session only belongs on the panel while a user is driving it. That
// excludes two things at once, and one rule should say both:
//
// - `omp -p` / `--mode json`: no one is watching, and the run is over in
// seconds — the row it drew was noise that vanished again.
// - a subagent session: omp builds its extension runner with
// `initialize(actions, contextActions)` and no mode, so `ctx.mode`
// falls back to the runner's default `"print"` even when the parent
// is an interactive TUI (measured on 18.3.0). A subagent is therefore
// indistinguishable from `-p` by mode, which is what lets one test
// cover both.
//
// The non-interactive modes are named and everything else reports, so a
// mode this file has never heard of still shows up: an extra row is
// visible, while a silent session is impossible to notice.
function isReportable(ctx) {
try {
const mode = ctx && ctx.mode;
return mode !== "print" && mode !== "json";
} catch {
return true;
}
}

function send(event, payload, ctx) {
if (!isReportable(ctx)) return;
try {
// The payload goes through the environment, not stdin. This code
// runs inside the agent's own runtime, where a write to a pipe is
Expand Down Expand Up @@ -169,7 +194,7 @@ public enum ExtensionTemplate {
try {
const payload = { session_id: sessionIdFor(ctx), cwd: (ctx && ctx.cwd) || "" };
if (toolName) payload.toolName = String(toolName);
send(event, payload);
send(event, payload, ctx);
} catch {}
}

Expand Down Expand Up @@ -199,7 +224,7 @@ public enum ExtensionTemplate {

function reportContext(ctx) {
const context = contextPayload(ctx);
if (context) send("context_update", context);
if (context) send("context_update", context, ctx);
}

export default function (pi) {
Expand Down
65 changes: 56 additions & 9 deletions Tests/AgentPetCoreTests/OhMyPiTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -221,14 +221,61 @@ struct OhMyPiRegistryTests {
#expect(profile.detection.configFiles.allSatisfy { $0.path.contains(".omp/agent") })
}

@Test("the shipped extension text is stable enough to diff")
func sourceIsDeterministic() {
// The configurator writes this text and later compares the file to it
// to decide that nothing changed, so it cannot be a function of time,
// locale, or process state.
let first = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath)
let second = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath)
#expect(first == second)
#expect(first.contains("\n"), "source with no newlines is not a file")
@Test("only a session a user is driving is reported")
func reportsOnlyDrivenModes() {
// `omp -p` and a subagent session are both `print` — a subagent's
// runner is built with `initialize(actions, contextActions)` and no
// mode, so it takes the runner's default even under an interactive
// parent (measured on 18.3.0). One test therefore has to cover both,
// and the gate has to sit where every event passes: `send`.
let source = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath)
// How the mode is turned into a decision is pinned wholesale in
// `unknownModeFailsOpen`, below: this one owns where the gate sits and
// who feeds it, not what `isReportable` says.

// The gate is in `send`, not in one handler: `report` and
// `reportContext` are its only callers, so a handler added later
// cannot grow an unguarded path to the shim.
let sendBody = source.components(separatedBy: "function send(event, payload, ctx) {")
#expect(sendBody.count == 2, "send does not take the ctx it must gate on")
// The gate is the first thing `send` does: before `try`, before the
// spawn. Indentation is the emitter's business, so compare on trimmed
// lines rather than on the exact column.
let firstLines = sendBody[1]
.split(separator: "\n", omittingEmptySubsequences: true)
.map { $0.trimmingCharacters(in: .whitespaces) }
#expect(firstLines.first == "if (!isReportable(ctx)) return;",
"send does not gate before it spawns: \(firstLines.prefix(3))")

// Both callers hand it the context they were given.
#expect(source.contains("send(event, payload, ctx);"))
#expect(source.contains("send(\"context_update\", context, ctx);"))
}

@Test("a mode the file has never heard of still reports")
func unknownModeFailsOpen() {
// Naming the silent modes rather than the loud ones is half of it: the
// other half is that nothing gets in front of the return. A default
// (`ctx.mode || "print"`) or a guard (`if (typeof mode !== "string")
// return false;`) leaves the return line intact and still silences a
// context carrying no mode at all — the one outcome the design chose
// against, and the one no user could see. So the whole body is what
// gets pinned here, not just its decision.
let source = AgentPetExtension.source(agentID: "omp", shimPath: aShimPath)
let helper = source.components(separatedBy: "function isReportable(ctx) {")
#expect(helper.count == 2, "the file has no mode test to gate on")
let body = helper[1]
.components(separatedBy: "function send(")[0]
.split(separator: "\n", omittingEmptySubsequences: true)
.map { $0.trimmingCharacters(in: .whitespaces) }
#expect(body == [
"try {",
"const mode = ctx && ctx.mode;",
"return mode !== \"print\" && mode !== \"json\";",
"} catch {",
"return true;",
"}",
"}",
], "mode must be read straight off ctx and decided on the return alone: \(body)")
}
}
11 changes: 11 additions & 0 deletions Tests/AgentPetCoreTests/PiConfiguratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,17 @@ struct PiConfigurationTests {
#expect(known.contains("context_update"), "context_update is sent but unknown")
}

@Test("Pi reports only a session a user is driving")
func reportsOnlyDrivenModes() {
// The template is shared, so `pi -p` is silenced by the same rule that
// keeps a subagent off the panel — the one behaviour worth pinning on
// this side, since Pi has no subagents of its own.
let source = PiConfigurator.template(shimPath: piShim)
#expect(source.contains("function isReportable(ctx)"))
#expect(source.contains("mode !== \"print\"") && source.contains("mode !== \"json\""))
#expect(source.contains("if (!isReportable(ctx)) return;"))
}

@Test("the extension hands the payload over in the environment, not on a pipe")
func payloadGoesThroughTheEnvironment() throws {
// Ported from PR #1's finding: a write to a pipe from inside the
Expand Down
22 changes: 18 additions & 4 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -1104,13 +1104,15 @@ UI 显示的组合状态:

生成文本由 `AgentPetExtension.source(agentID:shimPath:)` 拼出(不是随包资源):能被测试逐条断言,也能在不安装任何东西的情况下读全文——与 `HookSetup.claudeCodeJSON` 同样的理由。它自己只是把参数交给**两个扩展文件共用的模板** `ExtensionTemplate.source(agentID:shimPath:events:marker:)`:两个 Agent 的扩展 API 相同,事件集是显式参数(agent id 与所有权标记也是),其余(shim 调用、会话 id、上下文读数、环境变量通道)全部共享。Pi 的配置器(`PiConfigurator`)走同一个模板的 `.pi` 分支。

| 用途 | Pi 0.85 发 | Oh My Pi 18 发 |
| 用途 | Pi 0.87 发 | Oh My Pi 18 发 |
|---|---|---|
| "被扣住了" | `ui_prompt_start`(自己的提示框在屏上) | `tool_approval_requested` / `ask` 工具 |
| 一轮结束 | `agent_settled` | `agent_end`(丢掉 `willContinue` 为真的那些) |

一条普通 `agent_end` 在 Pi 上不是结算(之后还可能自动重试或续跑),而 `agent_settled` 在 omp 18.1.22 里**不存在**(逐事件核对过),所以一个文件不可能同时正确;模板按事件集参数化正是为此。写入走 `ConfigTransaction.performText`(快照 → 备份 → 原子写 → 读回验证 → 失败回滚),与 JSON 事务同一套纪律、同一份代码。

(版本号说明:表头的 **Pi 0.87** 是本机装机 0.87.1 的发行物口径——它的 `dist` 仍声明 `agent_settled` 与 `ui_prompt_start`——**不是**在 0.87.1 上重跑过行为验证。§6.7b 那条 2026-09-15 的实机验收记的是当时的 0.85.1,那里保留原数字不动;`ui_prompt_start` 至今仍未拿到行为证据,见 §6.7b 节尾的先测清单。)

#### 上报什么

扩展监听八个事件、发送九个(多出来的是每次结算后那条上下文读数),全部由它自己发给 shim(`--agent omp --event <名>`)。payload 只有 `session_id` 与 `cwd`;带工具名的那几个事件(`tool_execution_start` 与两个审批事件)多一个 `toolName`。
Expand All @@ -1137,13 +1139,25 @@ omp 的扩展是**进程内**的,能改 Agent 行为,所以边界必须写

#### 已知边界

- 子代理会话若也派发扩展事件,会作为独立 session 出现在面板上(本项目不读 Agent 内部状态,无法可靠区分主/子会话);实测 `-p` 模式下未出现。
- **面板只画用户正在驾驶的会话**(2026-09-24 改)。判据是扩展上下文里的 `ctx.mode`,不是会话树:omp 给子代理建扩展运行时用的是 `initialize(actions, contextActions)` **两个参数**,没有 mode,于是 `ctx.mode` 取运行时的默认值 `"print"`——**即使父会话是交互式 TUI**(实测 18.3.0)。所以"子代理"和"`omp -p`"在 mode 上是同一种东西,一条规则同时管住两者:`send()` 在 `mode === "print"` 或 `"json"` 时直接返回,不 spawn shim。这意味着:
- 用户敲 `omp` 进 TUI → `mode: "tui"` → 上报,面板有一行。
- 会话内部起的子代理 → `mode: "print"` → **完全不上报**,不再有独立行。
- `omp -p` / `--mode json` → `mode: "print"` / `"json"` → **完全不上报**(从前会画一行,几秒后随 `session_shutdown` 消失)。JSON 模式是 `--mode json`——没有 `--json` 这个 flag(`omp --json` 会被当成未知 flag 拒绝)。
- RPC / ACP → `mode: "rpc"` → 上报,按"有人在驾驶"处理。
- 未知的 mode → **上报**(fail-open):多点一行看得见,静默了没人会发现。
- 闸门写在 `send()` 里而不是某个 handler 里:`report()` 与 `reportContext()` 是它仅有的两个调用者,将来新加的 handler 不可能绕过去。
- **代价**:子代理干活期间面板上看不到"还在忙"。实测异步 spawn(`async.enabled` 默认 true):父会话 t=11.7s 发 `agent_end`,子代理最后的工具在 t=15.76s,父会话 t=15.76s 才重新 `agent_start`——中间约 4 秒父行显示"已完成"。这与本仓库为 Claude Code 记下的那一类是同一个误报(`README.md`:`Stop` lies when a subagent is running;机制见 §5.1b 的 `Stop` 后台任务抑制)。抑制这件事本身有先例:Claude Code 靠默认字段名 `background_tasks`(`NormalizationRule.suppressedByRunningBackgroundTask`,不声明 `backgroundTasksField` 时用它),Grok 显式声明 `backgroundTasks`;缺的是 **omp 侧把这个信号发出来**,omp profile 上也还没有对应的抑制规则。
- **代价**:只用 `omp -p` 的装机,`lastEventAt` 永远是 nil,`IntegrationHealthEvaluator` 据此判 `.degraded`:卡片上是小圆点变黄、`Connected` 改成 `Degraded`,而"Last event"那一行显示 `none yet`(`AgentsView.lastEventSummary` 的 nil 分支;CLI 的 `--status` 对同一个 nil 打印的是 `never`)。从前任何一次 `-p` 都会写下这个时间戳。
- 面板的**费用**一项对 Oh My Pi 没有数据源(扩展不发 `cost_usd`);模型与上下文占用随每次结算的 `context_update` 上报(§6.7)。
- 扩展在**会话启动时**加载:装完要新起一个 omp 会话才生效。UI 的 degraded / disconnected 文案按机制分支,正是为这句话。

#### 真机验证(2026-09-15)
#### 真机验证(2026-09-24 改)

**不要用 `omp -p` 验证。** 这一节原来的配方是 `omp -p` + `AGENTPET_SOCKET` 指向打印信封的 UDS 监听、以六个按序信封为凭证;但 §8.8 的 mode 闸门(见上方"已知边界")让 `omp -p` **一个信封都不发**——`send()` 在 spawn 之前就返回了。照旧配方跑,一个正常的装机会被判成坏了。

改用交互式会话(pty 里起 `omp`,真终端,逐字符输入 prompt 再回车):得到 `session_start`(`mode: "tui"`)→ `agent_start` → 工具事件 → `agent_end`,全部带同一个 session id;同一次运行里让模型起一个子代理,则**不出现**任何第二个 session id 的信封(子代理的 `mode` 是 `print`)。同样的警告对 §6.7b 的 Pi 验收成立:那条写的是 headless 会话,照它跑现在会一个信封都没有。

`--configure omp` 写文件 → 起一个真实 omp 会话(`omp -p`,`AGENTPET_SOCKET` 指向一个打印信封的 UDS 监听)→ 得到按序的 `session_start / agent_start / tool_execution_start(bash) / tool_execution_end / agent_end / session_shutdown`,每个信封都带同一个 session id 与 `/tmp` 工作目录 → `--unconfigure omp` 后文件消失、目录里另外两个扩展原样、`--status` 回到 `extension: 0`。
`--unconfigure omp` 后文件消失、目录里另外两个扩展原样、`--status` 回到 `extension: 0`(这部分不受闸门影响)。

---

Expand Down
Loading