diff --git a/docs/usage-guide.md b/docs/usage-guide.md index b0e365070..00eb7aac4 100644 --- a/docs/usage-guide.md +++ b/docs/usage-guide.md @@ -1361,7 +1361,7 @@ Recall counts every doc it returns (`recalled_count`). A returned doc is **adopt | Copilot CLI | Yes | No: the subagent has a session of its own, and no hook links it to its parent | | Cursor | Yes | No: as for Copilot CLI | | OpenCode | Yes | Yes: the `task` call links the subagent's session to its parent | -| OMP | Yes, settled only by the claim of its `bash` call | No: the subagent's session is not linked to its parent | +| OMP | Yes, settled only by the claim of its `bash` call | Yes: the subagent's session file sits under its parent's, whose session header links the two sessions (verified against OMP 18.4.8) | | Pi | Yes | None: TeamAI deploys no subagent to Pi | | ZCode | Yes | No: ZCode runs no hooks inside a subagent | | OpenClaw, Hermes, Kiro, JoyCode | No: no PostToolUse hook | No | @@ -1371,7 +1371,7 @@ Recall counts every doc it returns (`recalled_count`). A returned doc is **adopt **Known limits.** - **Cursor, Copilot CLI and ZCode subagents.** A recall run in a subagent never credits the main agent's reads: Cursor and Copilot CLI give the subagent its own session with no link to the parent, and ZCode runs no hooks in it. A recall the main agent runs itself does vote. -- **OMP.** The subagent's session is not linked to its parent, so the subagent path gives no adoption. OMP sets no session variable in its shell, so a run settles only through the claim of the `bash` call that ran it: when OMP moves a large output into an artifact, the `run=` line and the vote are lost, and an OMP started from a Claude Code shell records its run under the Claude session until that claim corrects it. +- **OMP.** The subagent path needs the main session's file on disk: when it has none (`--no-session`), the subagent is not linked to its parent and gives no adoption. OMP sets no session variable in its shell, so a run settles only through the claim of the `bash` call that ran it: when OMP moves a large output into an artifact, the `run=` line and the vote are lost, and an OMP started from a Claude Code shell records its run under the Claude session until that claim corrects it. - **Searches not counted.** OMP's `grep` (a markdown tree) and Cursor's `Grep` add no evidence; opening the doc still counts. ZCode prints `Grep` lines relative to its working directory, so a ZCode search run from a directory inside the team repo is not counted. - **No PostToolUse hook.** OpenClaw, Hermes, Kiro and JoyCode record their recalls but no reads, so these recalls never vote. - **Older CLIs.** A member on an older TeamAI still votes from the session transcript, and that path keys a skill, a doc in a subdirectory or a wiki page by its file's basename (`SKILL`, `setup`) rather than the id recall prints (`retry`, `common/setup`), so those votes miss the doc. Top-level learnings and docs are unaffected, and upgrading ends it. @@ -2158,7 +2158,7 @@ These paths are verified against the ZCode desktop app: profiles created in its ### Oh My Pi -Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A doc the main agent opens after a subagent's recall is not upvoted yet: the subagent's session is not linked to its parent. The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used. +Oh My Pi (OMP) is available as a built-in target. TeamAI deploys skills, rules, and subagents to OMP's native directories — `.omp/skills/`, `.omp/rules/`, and `.omp/agents/` at project scope, and `~/.omp/agent/skills/`, `~/.omp/agent/rules/`, and `~/.omp/agent/agents/` at user scope (user-scope resources live under the agent directory `~/.omp/agent/`, a different prefix from the project one, so TeamAI switches prefixes with the scope). Instructions (`claudemd`) deploy to the matching `AGENTS.md`, and MCP servers merge into `~/.omp/agent/mcp.json` / `/.omp/mcp.json` (Claude `mcpServers` shape — see the MCP section above). Skills are one-level `/SKILL.md` bundles and TeamAI fills in a `description` on sync, which OMP's native skill provider requires to discover a skill. These paths follow OMP's documented discovery layout (verified against OMP 18.2.5). Hooks ride OMP's extension runner: `teamai pull` writes a single generated extension to `~/.omp/agent/extensions/teamai-hooks.ts` (never a project copy — OMP auto-loads both roots and would double-dispatch every event), which forwards OMP's `session_start` / `session_stop` / `before_agent_start` / `tool_result` events to the same `teamai hook-dispatch` entry point every other agent uses, gated on the session `cwd`. Every event carries the OMP session id (`ctx.sessionManager.getSessionId()`; a subagent has its own), and `tool_result` also the tool's text output and a status from `isError`, so upvote **adoption** runs for OMP's main agent: OMP sets no session variable in its shell, so a recall joins the session of the `bash` call that ran it, and a `read` with a line selector (`x.md:50-200`, `x.md:raw`) counts as a read of the file. From OMP 18.3.2 a subagent's events also carry its `ctx.agent` id and name, so the `teamai-recall` subagent's own reads never count. A subagent's session file sits under its parent's, whose header names the parent session, so the extension links the two on the subagent's tool calls, and a doc the main agent opens after a subagent's recall is upvoted (verified against OMP 18.4.8). The `session_stop` handler returns nothing, so a dispatch can never force a session continuation, and there is no matcher-scoped post-tool-use pass because OMP's tool ids are lowercase (`bash`, `read`, …) and it has no `Skill` / `TodoWrite` tool. `teamai uninstall` removes the extension. OMP profiles (`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`), which relocate the agent directory, are not supported; the default `~/.omp/agent/` layout is used. ### DeepSeek Harness diff --git a/docs/usage-guide.zh-CN.md b/docs/usage-guide.zh-CN.md index 1e33ebf80..9f78b56ac 100644 --- a/docs/usage-guide.zh-CN.md +++ b/docs/usage-guide.zh-CN.md @@ -1241,7 +1241,7 @@ recall 会为返回的每篇文档计数(`recalled_count`)。运行 recall | Copilot CLI | 支持 | 不支持:subagent 有自己的会话,且没有 hook 将其关联到父会话 | | Cursor | 支持 | 不支持:同 Copilot CLI | | OpenCode | 支持 | 支持:`task` 调用将 subagent 的会话关联到父会话 | -| OMP | 支持,仅通过其 `bash` 调用的认领确定归属 | 不支持:subagent 的会话未关联到父会话 | +| OMP | 支持,仅通过其 `bash` 调用的认领确定归属 | 支持:subagent 的会话文件位于父会话文件之下,父会话文件的会话头把两个会话关联起来(对照 OMP 18.4.8 验证) | | Pi | 支持 | 不适用:TeamAI 不向 Pi 部署 subagent | | ZCode | 支持 | 不支持:ZCode 在 subagent 内不运行 hook | | OpenClaw、Hermes、Kiro、JoyCode | 不支持:没有 PostToolUse hook | 不支持 | @@ -1251,7 +1251,7 @@ recall 会为返回的每篇文档计数(`recalled_count`)。运行 recall **已知限制。** - **Cursor、Copilot CLI 和 ZCode 的 subagent。** 在 subagent 中运行的 recall 从不为主 agent 的读取计分:Cursor 和 Copilot CLI 给 subagent 分配独立会话且不关联父会话,ZCode 在 subagent 内不运行 hook。主 agent 自己运行的 recall 可以正常投票。 -- **OMP。** subagent 的会话未关联到父会话,因此 subagent 路径不产生采纳。OMP 不在 shell 中设置会话变量,因此 run 只能通过运行它的 `bash` 调用的认领确定归属:OMP 把大段输出转存为 artifact 时,`run=` 行和投票都会丢失;从 Claude Code shell 启动的 OMP 会先把 run 记在 Claude 会话下,直到该认领将其纠正。 +- **OMP。** subagent 路径要求主会话文件已写入磁盘:主会话没有会话文件时(`--no-session`),subagent 无法关联到父会话,也不产生采纳。OMP 不在 shell 中设置会话变量,因此 run 只能通过运行它的 `bash` 调用的认领确定归属:OMP 把大段输出转存为 artifact 时,`run=` 行和投票都会丢失;从 Claude Code shell 启动的 OMP 会先把 run 记在 Claude 会话下,直到该认领将其纠正。 - **不计入的搜索。** OMP 的 `grep`(markdown 树形输出)和 Cursor 的 `Grep` 不产生证据;打开文档仍然计入。ZCode 打印的 `Grep` 行是相对其工作目录的路径,因此从团队仓库内的目录发起的 ZCode 搜索不计入。 - **没有 PostToolUse hook。** OpenClaw、Hermes、Kiro 和 JoyCode 会记录其 recall,但不记录读取,因此这些 recall 从不投票。 - **旧版 CLI。** 使用旧版 TeamAI 的成员仍从会话 transcript 投票,该路径以文件的 basename(`SKILL`、`setup`)而非 recall 打印的 id(`retry`、`common/setup`)作为 skill、子目录中的文档或 wiki 页面的键,因此这些投票落不到该文档上。顶层的 learnings 和文档不受影响,升级后即可解决。 @@ -2023,7 +2023,7 @@ ZCode 已作为内置目标支持。Skills 下发到 `.zcode/skills/`(ZCode ### Oh My Pi -Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。指令(`claudemd`)下发到对应的 `AGENTS.md`;MCP Server 合并进 `~/.omp/agent/mcp.json` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。每个事件都携带 OMP 会话 id(`ctx.sessionManager.getSessionId()`;subagent 有自己的会话),`tool_result` 还带上工具的文本输出和根据 `isError` 得出的状态,因此 upvote **采纳(adoption)**在 OMP 主 agent 上生效:OMP 不在其 shell 中设置会话变量,所以 recall 归入运行它的那次 `bash` 调用所在的会话;带行选择器的 `read`(`x.md:50-200`、`x.md:raw`)计为对该文件的读取。从 OMP 18.3.2 起,subagent 的事件还会携带其 `ctx.agent` 的 id 和名称,因此 `teamai-recall` subagent 自身的读取从不计入。主 agent 在 subagent recall 之后打开的文档暂不会被 upvote:subagent 的会话尚未关联到父会话。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 +Oh My Pi(OMP)已作为内置目标支持。TeamAI 将 Skills、Rules 和 Subagents 下发到 OMP 的原生目录——项目级为 `.omp/skills/`、`.omp/rules/` 和 `.omp/agents/`,用户级为 `~/.omp/agent/skills/`、`~/.omp/agent/rules/` 和 `~/.omp/agent/agents/`(用户级资源位于 agent 目录 `~/.omp/agent/` 下,与项目级前缀不同,TeamAI 会随作用域自动切换)。指令(`claudemd`)下发到对应的 `AGENTS.md`;MCP Server 合并进 `~/.omp/agent/mcp.json` / `/.omp/mcp.json`(Claude `mcpServers` 结构,见上文 MCP 章节)。Skills 采用一层 `/SKILL.md` 目录结构,TeamAI 在同步时补全 `description`——OMP 原生 skill 发现要求该字段。以上路径遵循 OMP 官方文档的发现布局(对照 OMP 18.2.5 验证)。Hooks 走 OMP 的 extension runner:`teamai pull` 会生成唯一的 extension 写入 `~/.omp/agent/extensions/teamai-hooks.ts`(绝不写项目副本——OMP 会同时加载两个根并导致每个事件双派发),它把 OMP 的 `session_start` / `session_stop` / `before_agent_start` / `tool_result` 事件转发给所有 agent 共用的 `teamai hook-dispatch` 入口,并按会话 `cwd` 做项目门控。每个事件都携带 OMP 会话 id(`ctx.sessionManager.getSessionId()`;subagent 有自己的会话),`tool_result` 还带上工具的文本输出和根据 `isError` 得出的状态,因此 upvote **采纳(adoption)**在 OMP 主 agent 上生效:OMP 不在其 shell 中设置会话变量,所以 recall 归入运行它的那次 `bash` 调用所在的会话;带行选择器的 `read`(`x.md:50-200`、`x.md:raw`)计为对该文件的读取。从 OMP 18.3.2 起,subagent 的事件还会携带其 `ctx.agent` 的 id 和名称,因此 `teamai-recall` subagent 自身的读取从不计入。subagent 的会话文件位于父会话文件之下,父会话文件的头部写明父会话 id,因此 extension 会在 subagent 的工具调用中关联这两个会话,主 agent 在 subagent recall 之后打开的文档会被 upvote(对照 OMP 18.4.8 验证)。`session_stop` 处理器不返回任何值,分发绝不会强制会话继续;由于 OMP 的工具名是小写(`bash`、`read` 等)且没有 `Skill` / `TodoWrite` 工具,post-tool-use 不做 matcher 定向分发。`teamai uninstall` 会移除该 extension。OMP 的 profile(`OMP_PROFILE` / `PI_CODING_AGENT_DIR` / `PI_CONFIG_DIR`,会迁移 agent 目录)暂不支持,使用默认的 `~/.omp/agent/` 布局。 ### DeepSeek Harness diff --git a/skill-data/core/references/troubleshooting.md b/skill-data/core/references/troubleshooting.md index 32aab2f3a..19983aba3 100644 --- a/skill-data/core/references/troubleshooting.md +++ b/skill-data/core/references/troubleshooting.md @@ -203,9 +203,11 @@ judge (`TEAMAI_UPVOTE_JUDGE=1`) credits that. `teamai stats` shows each recent session's runs, recalled docs and adopted docs. Per agent: - **Claude Code, Codex (0.134+), CodeBuddy (2.103.1+), WorkBuddy, Qoder, - OpenCode**: both a recall the main agent runs and one the `teamai-recall` - subagent runs are credited when the main agent opens the doc. -- **Cursor, Copilot CLI, ZCode, OMP, Pi**: only a recall the main agent runs + OpenCode, OMP**: both a recall the main agent runs and one the + `teamai-recall` subagent runs are credited when the main agent opens the doc. + On OMP the subagent's recall needs the main session's file on disk, so a + `--no-session` run credits only the main agent's own recalls. +- **Cursor, Copilot CLI, ZCode, Pi**: only a recall the main agent runs itself. A subagent's recall is not linked to the main session, and Pi has no TeamAI subagent. - **OpenClaw, Hermes, Kiro, JoyCode**: no PostToolUse hook, so recalls never diff --git a/src/__tests__/helpers/pi-extensions.ts b/src/__tests__/helpers/pi-extensions.ts index 7f75ba6f9..2089e0c38 100644 --- a/src/__tests__/helpers/pi-extensions.ts +++ b/src/__tests__/helpers/pi-extensions.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs'; +import path from 'node:path'; import vm from 'node:vm'; import { EventEmitter } from 'node:events'; import { buildPiExtensionSource } from '../../pi-hooks.js'; @@ -12,7 +14,7 @@ export interface ExtensionDispatch { /** The ExtensionContext fields the extensions read: Pi and OMP share `cwd` and `sessionManager`; OMP adds `agent`. */ export interface ExtensionContext { cwd: string; - sessionManager?: { getSessionId: () => string }; + sessionManager?: { getSessionId: () => string; getSessionFile?: () => string | undefined }; agent?: { kind: 'main' | 'sub'; id: string; name: string; depth?: number; parentId?: string }; } @@ -65,6 +67,6 @@ export function loadOmpExtension(): LoadedExtension { })(); return { quiet: () => ({ nothrow: () => run }) }; }; - const on = register(buildOmpExtensionSource(), { $, Response }); + const on = register(buildOmpExtensionSource(), { $, Response, fs, path, Buffer }); return { on, dispatches }; } diff --git a/src/__tests__/recall-attribution.test.ts b/src/__tests__/recall-attribution.test.ts index 2ebf80b58..c25338134 100644 --- a/src/__tests__/recall-attribution.test.ts +++ b/src/__tests__/recall-attribution.test.ts @@ -531,6 +531,31 @@ class Harness { private piExt?: ReturnType; private ompExt?: ReturnType; + /** The session file OMP's session manager reports for each session, when one is written. */ + private readonly ompSessionFiles: Record = {}; + + /** + * Write OMP's session files as OMP lays them out: `parent` at + * `_.jsonl`, headed by its padded title slot and then its session + * header, and the subagent `agent` ran in session `child` at + * `_/.jsonl`. With `parentHeader: false` the parent's file + * is not on disk yet; the returned function writes it. + */ + ompSessions(parent: string, child: string, agent: OmpAgent, options: { parentHeader?: boolean } = {}): () => void { + const parentFile = path.join(this.tmp, 'omp-sessions', `2026-09-01T09-00-00-000Z_${parent}.jsonl`); + const artifactsDir = parentFile.slice(0, -'.jsonl'.length); + fs.mkdirSync(artifactsDir, { recursive: true }); + const timestamp = new Date(T0).toISOString(); + const header = (id: string) => JSON.stringify({ type: 'title', v: 1, title: '', updatedAt: timestamp, pad: ' '.repeat(128) }) + '\n' + + JSON.stringify({ type: 'session', version: 3, id, timestamp, cwd: this.root }) + '\n'; + const writeParent = () => fs.writeFileSync(parentFile, header(parent)); + if (options.parentHeader !== false) writeParent(); + const childFile = path.join(artifactsDir, `${agent.id}.jsonl`); + fs.writeFileSync(childFile, header(child)); + this.ompSessionFiles[parent] = parentFile; + this.ompSessionFiles[child] = childFile; + return writeParent; + } /** * The host fires `event` at the generated Pi or OMP extension (evaluated in @@ -540,7 +565,9 @@ class Harness { */ async bridge(tool: 'pi' | 'omp', event: string, payload: Record, session: string, agent?: OmpAgent): Promise { const ext = tool === 'pi' ? (this.piExt ??= loadPiExtension()) : (this.ompExt ??= loadOmpExtension()); - await ext.on[event](payload, { cwd: this.root, sessionManager: { getSessionId: () => session }, ...(agent ? { agent } : {}) }); + const file = tool === 'omp' ? this.ompSessionFiles[session] : undefined; + const sessionManager = { getSessionId: () => session, ...(file ? { getSessionFile: () => file } : {}) }; + await ext.on[event](payload, { cwd: this.root, sessionManager, ...(agent ? { agent } : {}) }); for (const { args: argv, payload: stdin } of ext.dispatches.splice(0)) { await this.dispatch(argv[1], { session_id: undefined, ...stdin }, null, tool); } @@ -1798,6 +1825,52 @@ const ROWS: Row[] = [ }, project: {}, }, + { + name: '10: OMP recall subagent recalls and reads, then the main agent reads; the subagent\'s session file sits under its parent\'s → +1', + trace: async (h) => { + h.ompSessions(OMP, OMP_SUB, OMP_RECALL_AGENT); + const { files } = await h.ompRecall('redis timeout', OMP_SUB, { caller: 'teamai-recall', agent: OMP_RECALL_AGENT }); + await h.ompTool(OMP_SUB, 'read', { path: files[0] }, '---', OMP_RECALL_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP_SUB, OMP_RECALL_AGENT); + await h.ompTool(OMP, 'read', { path: files[0] }, '---', OMP_MAIN_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP, OMP_MAIN_AGENT); + }, + project: { 'redis-timeout': 1 }, + }, + { + name: '10: OMP recall subagent linked to its parent reads the doc, and the main agent does not → 0', + trace: async (h) => { + h.ompSessions(OMP, OMP_SUB, OMP_RECALL_AGENT); + const { files } = await h.ompRecall('redis timeout', OMP_SUB, { caller: 'teamai-recall', agent: OMP_RECALL_AGENT }); + await h.ompTool(OMP_SUB, 'read', { path: files[0] }, '---', OMP_RECALL_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP_SUB, OMP_RECALL_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP, OMP_MAIN_AGENT); + }, + project: {}, + }, + { + name: '10: OMP parent session file not on disk at the subagent\'s first call, written before its read; then the main agent reads → +1', + trace: async (h) => { + const writeParent = h.ompSessions(OMP, OMP_SUB, OMP_RECALL_AGENT, { parentHeader: false }); + const { files } = await h.ompRecall('redis timeout', OMP_SUB, { caller: 'teamai-recall', agent: OMP_RECALL_AGENT }); + writeParent(); + await h.ompTool(OMP_SUB, 'read', { path: files[0] }, '---', OMP_RECALL_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP_SUB, OMP_RECALL_AGENT); + await h.ompTool(OMP, 'read', { path: files[0] }, '---', OMP_MAIN_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP, OMP_MAIN_AGENT); + }, + project: { 'redis-timeout': 1 }, + }, + { + name: '10: OMP recall subagent whose main session has no session file (--no-session: no parent to find), then the main agent reads → 0', + trace: async (h) => { + const { files } = await h.ompRecall('redis timeout', OMP_SUB, { caller: 'teamai-recall', agent: OMP_RECALL_AGENT }); + await h.bridge('omp', 'session_stop', {}, OMP_SUB, OMP_RECALL_AGENT); + await h.ompTool(OMP, 'read', { path: files[0] }, '---', OMP_MAIN_AGENT); + await h.bridge('omp', 'session_stop', {}, OMP, OMP_MAIN_AGENT); + }, + project: {}, + }, { name: '10: OMP recall subagent without --caller is marked by the agent type ctx.agent names; its own read → 0', trace: async (h) => { diff --git a/src/omp-hooks.ts b/src/omp-hooks.ts index 627029481..c6ac6dce0 100644 --- a/src/omp-hooks.ts +++ b/src/omp-hooks.ts @@ -72,7 +72,12 @@ export function resolveOmpExtensionsDir(): string { * session), and a subagent's events its `agent_id` / `agent_type` from * `ctx.agent`, which only OMP >= 18.3.2 has: it is read only when present. * `tool_result` also carries the tool's text output and a status from - * `isError`. + * `isError`. A subagent's session file is `/.jsonl` + * beside its parent's `.jsonl`, whose session header (after the + * title slot line) names the parent session, so a subagent's + * `tool_result` links its session to the parent's, once the parent's file + * is on disk; OMP has no API for it. A main session without a session + * file (`--no-session`) gives no link. * - Tool naming: OMP passes lowercase tool ids (`bash`, `read`, …) and has * no `Skill` / `TodoWrite` tool to map onto Claude's PascalCase matcher * names, so there is no matcher-scoped pass — only the wildcard @@ -95,6 +100,8 @@ export function buildOmpExtensionSource(): string { // would force a session continuation. import { $ } from "bun"; +import fs from "node:fs"; +import path from "node:path"; /** * The session this handler serves (a subagent's own), and for a subagent its @@ -115,6 +122,35 @@ const sessionOf = (ctx) => { return fields; }; +/** How much of a session file is read for its header lines. */ +const HEADER_READ_BYTES = 65536; + +/** + * The session a subagent's session was started from: its file is + * /.jsonl, and .jsonl opens with OMP's title slot + * line, then the parent's session header. Undefined for any other session, + * and while the parent's file is not on disk. + */ +const parentSessionOf = (ctx) => { + try { + const file = ctx.sessionManager && ctx.sessionManager.getSessionFile && ctx.sessionManager.getSessionFile(); + if (typeof file !== "string" || !file) return undefined; + const fd = fs.openSync(\`\${path.dirname(file)}.jsonl\`, "r"); + try { + const head = Buffer.alloc(HEADER_READ_BYTES); + const size = fs.readSync(fd, head, 0, head.length, 0); + const [first, second] = head.toString("utf8", 0, size).split("\\n", 2); + let header = JSON.parse(first); + if (header && header.type === "title") header = JSON.parse(second); + return header && header.type === "session" && typeof header.id === "string" && header.id ? header.id : undefined; + } finally { + fs.closeSync(fd); + } + } catch { + return undefined; + } +}; + /** A tool result's text parts, joined; undefined when it has no content list. */ const textOf = (content) => Array.isArray(content) ? content.filter((part) => part && part.type === "text" && typeof part.text === "string").map((part) => part.text).join("\\n") @@ -153,15 +189,25 @@ export default function teamaiHooks(pi) { await dispatch("prompt-submit", ctx, { prompt: event.prompt }); }); + // Subagent sessions already linked: a session's parent never changes. + const linked = new Set(); + // content is what the model saw; isError is set for a failed call, // including a bash command that exits non-zero. pi.on("tool_result", async (event, ctx) => { - await dispatch("post-tool-use", ctx, { + const payload = { tool_name: event.toolName, tool_input: event.input, tool_response: textOf(event.content), tool_status: event.isError === true ? "failure" : event.isError === false ? "success" : "unknown", - }); + }; + const child = sessionOf(ctx).session_id; + const parent = child && !linked.has(child) ? parentSessionOf(ctx) : undefined; + if (parent) { + payload.session_link = { child, parent }; + linked.add(child); + } + await dispatch("post-tool-use", ctx, payload); }); } `; diff --git a/src/recall-adoption.ts b/src/recall-adoption.ts index 6d7040f2e..34e6bf102 100644 --- a/src/recall-adoption.ts +++ b/src/recall-adoption.ts @@ -121,7 +121,7 @@ export async function recordToolCall(stdin: Record, tool: strin async function toolCallLines(stdin: Record, tool: string, config: LocalConfig): Promise { const lines: RecallLogLine[] = []; - // A subagent's own session, which a bridge names on the call that ran it (OpenCode's task tool). + // A subagent's own session, which a bridge names on the call that ran it (OpenCode's task tool) or on the subagent's first call (OMP). const link = stdin.session_link !== null && typeof stdin.session_link === 'object' ? stdin.session_link as Record : {}; const child = nonEmpty(link.child); const parent = nonEmpty(link.parent); @@ -258,7 +258,7 @@ function inWindow(at: number, run: RunLine): boolean { * docs are credited: an inherited user-scope doc stays read-only while a * project is active. * - * A subagent that ran in a child session (OpenCode's task tool) is linked to + * A subagent that ran in a child session (OpenCode's task tool, OMP) is linked to * the session that started it: the runs and reads of every session linked up * to the same root count as the root's, the ledger's session, whichever of * them stopped. The child stays their actor, so a marked run's own reads are