From 48df3a796510bb874507aee19757598d06d5ea67 Mon Sep 17 00:00:00 2001 From: whp Date: Wed, 9 Sep 2026 15:19:06 +0800 Subject: [PATCH 1/2] perf(auto-thread-title): preflight metadata locally and minimize model work --- CONTRIBUTING.md | 5 +- .../.codex-plugin/plugin.json | 2 +- plugins/auto-thread-title/CHANGELOG.md | 9 ++ plugins/auto-thread-title/README.md | 10 +- .../skills/configure-task-titles/SKILL.md | 20 +++- plugins/auto-thread-title/src/cli.mjs | 8 +- plugins/auto-thread-title/src/policy.mjs | 6 ++ .../src/startup-metadata.mjs | 38 ++++++++ plugins/auto-thread-title/src/transport.mjs | 30 +++++- .../tests/automatic-cli.test.mjs | 78 ++++++++++++++- .../tests/startup-metadata.test.mjs | 74 +++++++++++++++ .../tests/transport.test.mjs | 94 +++++++++++++++++++ 12 files changed, 357 insertions(+), 17 deletions(-) create mode 100644 plugins/auto-thread-title/src/startup-metadata.mjs create mode 100644 plugins/auto-thread-title/tests/startup-metadata.test.mjs diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2b22f25..5c45dbb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -96,7 +96,10 @@ node --test - 手动配置替换与追加分开验证:目录选项自动切到 manual,追加只验证新目录,保留离线或其他平台的旧目录及启停状态;拒绝混用替换/追加,以及 projects 与目录选项。 - 原生启动器、UTF-8/BOM 标准输入、解释器缺失、进程退出与超时;Windows 运行 PowerShell 启动器,不设置执行策略绕过。 - `doctor` 只检查环境且不启动 CLI;projects 未检查时 `automaticScopeReady` 为 `null`。`doctor --projects` 只读核对项目元数据及当前目录匹配,不读取任务或改名;`doctor --probe` 验证已安装 CLI 的 schema,不能用新版本在线文档替代本机能力检查。 -- 自动命名策略在首条请求足以确定主题后,优先于业务工具调用完成当前任务改名;不拖到最终答复。验证主题不明、命名工具不可用时跳过,以及仅限当前任务、最多一次、没有额外模型回合的边界。 +- 自动命名策略在首条请求足以确定主题后,优先于业务工具调用完成当前任务改名;不拖到最终答复。验证主题不明、命名工具不可用时跳过,以及仅限当前任务、最多一次、没有额外模型任务的边界。 +- 自动钩子的 `thread/read` 仅针对当前 ID 且使用 `includeTurns: false`;本地验证 ID、目录、创建时间和已有标题,不使用或注入正文、`preview`。验证正常路径只注入短规则并省去模型侧读取,以及合规标题不注入。 +- 验证元数据接口不可用或 startup 瞬间任务暂不可读时回退完整核验指令,身份或目录不匹配时跳过。实机检查首次 startup 的任务可读性和实际标题写入,不能只用已持久化任务证明启动路径可用。 +- 额度优化分别记录注入指令长度、模型侧工具往返和本机 RPC 开销;不把字符数变化换算成未经测量的额度百分比。允许同批工具调用中先改名再执行用户业务,不新增 MCP 服务或模型任务。 - 中断发生在命名完成之前时,标题可能保留原名;插件不自动恢复中断任务,不补触发旧任务,不把钩子注入成功当作实际改名成功。 - 批量分页、活动/归档范围、输出分片、冲突检测和只读 RPC 白名单;预览确认规则不能被性能优化削弱。 - 手动实机检查在隔离测试任务上进行,改名必须明确获批。CI 通过不等于完成真实桌面改名验收。 diff --git a/plugins/auto-thread-title/.codex-plugin/plugin.json b/plugins/auto-thread-title/.codex-plugin/plugin.json index 12cbb82..81b3305 100644 --- a/plugins/auto-thread-title/.codex-plugin/plugin.json +++ b/plugins/auto-thread-title/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "auto-thread-title", - "version": "0.2.0", + "version": "0.2.1", "description": "Cross-platform local task titles: diagnose and configure automatic scope, rename one linked task, or preview and confirm a title-only batch. Node.js 22+, no Python or npm dependencies.", "author": { "name": "Why.Ping" diff --git a/plugins/auto-thread-title/CHANGELOG.md b/plugins/auto-thread-title/CHANGELOG.md index 5f8a172..877c852 100644 --- a/plugins/auto-thread-title/CHANGELOG.md +++ b/plugins/auto-thread-title/CHANGELOG.md @@ -2,6 +2,15 @@ 正式版本使用 `MAJOR.MINOR.PATCH`。修复递增 PATCH,新增兼容功能递增 MINOR;时间戳缓存后缀仅用于可选的本地开发迭代。 +## 0.2.1 + +- 自动钩子在本机预读当前任务的必要元数据,核对 ID、目录和创建时间,并计算上海时区日期;标题格式已合规时不再注入命名指令。 +- 正常路径使用简短指令,模型仅需写入当前任务标题一次,减少一次模型侧读取工具往返;可在同批工具调用中先改名再开展业务操作。 +- 本地预读使用 `thread/read`、`includeTurns: false`,不请求历史回合,不使用或注入任务正文、`preview`;不新增 MCP 服务或模型任务。 +- 接口不可用或启动瞬间任务暂不可读时回退完整核验指令;身份或目录不匹配时跳过。保留提前命名、自动范围、固定标题格式和最多一次写入的边界。 + +本次减少注入指令和正常路径的模型工具往返,不承诺固定额度降幅。首次 startup 的任务可读性及真实桌面改名仍需在对应环境验收;更新不会补触发已有任务。 + ## 0.2.0 - 自动标题在首条请求主题明确后,优先于业务工具调用完成,不再等到最终答复。仍只处理当前任务、最多一次,不增加模型回合;主题不明或命名工具不可用时跳过。 diff --git a/plugins/auto-thread-title/README.md b/plugins/auto-thread-title/README.md index df46e35..ff1fc4c 100644 --- a/plugins/auto-thread-title/README.md +++ b/plugins/auto-thread-title/README.md @@ -4,7 +4,7 @@ 插件标识:`auto-thread-title@why-ping` -当前版本:`0.2.0` · [更新记录](CHANGELOG.md) +当前版本:`0.2.1` · [更新记录](CHANGELOG.md) 所属市场:[Why Ping Codex Plugins](https://github.com/oiOxOio/codex-plugins) @@ -100,7 +100,9 @@ MMDD | 类型 | 主题 仅在 `SessionStart` 的来源为 `startup`,且任务实际目录匹配自动范围时触发;恢复旧任务、继续对话或更改范围不会补触发旧任务。命名只读取当前任务,不扫描其他任务,不轮询。 -首条用户请求足以确定主题后,优先在开展业务工具调用之前完成标题整理,再继续用户的工作,不等待最终答复。主题不明确或命名工具不可用时跳过,不为命名增加模型回合。如果任务在改名完成前被中断,标题仍可能保留原名;插件不会自行恢复中断任务。钩子成功注入命名规则不等于标题已经写入。 +首条用户请求足以确定主题后,优先在开展业务工具调用之前完成标题整理,可在同批工具调用中先改名再开展业务操作,不等待最终答复。主题不明确或命名工具不可用时跳过,不为命名增加模型任务。如果任务在改名完成前被中断,标题仍可能保留原名;插件不会自行恢复中断任务。钩子成功注入命名规则不等于标题已经写入。 + +匹配范围后,钩子先在本机读取当前任务的必要元数据,核对身份、目录和创建时间;标题格式已合规时直接跳过,不再注入命名指令。核对成功时只注入简短规则,模型根据当前首条请求拟定标题并写入一次,无需再调用一次任务读取工具。接口不可用或任务在启动瞬间暂不可读时,回退到完整核验指令;身份或目录不匹配时跳过。 默认 [config.json](config.json) 使用 `scope: "projects"`,每次新任务启动时读取**本机 Codex 保存的项目及其全部根目录**。在 Codex 中新增、移除或迁移项目后,后续新任务自动使用最新范围,无需同步一份插件目录清单。匹配实际目录及其子目录,也支持已保存 Git 仓库的 linked worktree;项目显示名称不参与路径匹配。 @@ -175,6 +177,8 @@ Windows 同样在 PowerShell 启动器后使用 `configure --project-root "S:\pr 命名使用当前任务正在运行的模型,不调用独立模型接口、不启动额外模型任务,也不需要插件专用 API Key。命名会使用当前回合额度,并非零消耗;批量整理的用量随待处理任务数增长。 +自动钩子的本地预读使用 `thread/read` 且 `includeTurns: false`,只使用当前任务的 ID、目录、创建时间和标题,在本机计算上海时区的 `MMDD`,不请求历史回合,也不使用或注入任务正文、`preview`。正常路径缩短注入指令,并减少一次模型侧读取工具往返;不新增 MCP 服务或模型任务。实际额度节省受模型、上下文和是否回退影响,不承诺固定百分比;本机读取仍有子进程和 RPC 开销。 + 清单脚本在本机运行,先生成兼容性 schema,再仅发送初始化和 `thread/list` RPC;不请求模型回合、不读取完整会话文件,也不直接修改 SQLite 或 JSONL。设有进程、分页、任务数和输出大小上限,错误时不输出伪装成完整结果的部分清单。用于提炼主题的短历史会进入当前 Codex 回合上下文,不应把整个整理过程理解成完全离线。 换 Codex 账号本身不会替换本机插件文件,但仍需当前账号和环境允许使用对应工具。不要将任务清单、会话内容或认证信息提交到公开仓库。 @@ -211,6 +215,6 @@ node --test 测试使用临时配置、合成任务和假 App Server,覆盖路径、配置、命名、分页、启动与只读 RPC,不需要真实任务。需要检查本机清单时,由用户明确执行对应平台启动器的 `inventory --summary-only --page-size 20`;该命令会读取任务元数据,只输出计数,不改名。 -CI 已配置 Ubuntu、Windows、macOS × Node.js 22/24 的六种组合,见 [工作流](../../.github/workflows/title-plugin-tests.yml)。验证状态以对应提交的 Actions 结果和实机记录为准,模拟平台测试通过不等于完成各平台桌面验收。实机验收需覆盖安装和信任、桌面环境下运行时发现、自动作用域内/外行为、首条请求主题明确后在业务工具调用前完成改名、只读清单,以及明确确认后的隔离任务改名。发生中断时应区分钩子已注入、命名工具已调用和标题已写入,不能仅凭任务已启动判断命名结果。 +CI 已配置 Ubuntu、Windows、macOS × Node.js 22/24 的六种组合,见 [工作流](../../.github/workflows/title-plugin-tests.yml)。验证状态以对应提交的 Actions 结果和实机记录为准,模拟平台测试通过不等于完成各平台桌面验收。实机验收需覆盖安装和信任、桌面环境下运行时发现、自动作用域内/外行为、首次 startup 时当前任务元数据是否可读、正常路径与回退路径,以及首条请求主题明确后在业务工具调用前实际完成改名;只读清单和明确确认后的隔离任务改名也需验证。发生中断时应区分钩子已注入、命名工具已调用和标题已写入,不能仅凭任务已启动判断命名结果。 新增插件和发布要求见 [开发与发布指南](https://github.com/oiOxOio/codex-plugins/blob/main/CONTRIBUTING.md)。 diff --git a/plugins/auto-thread-title/skills/configure-task-titles/SKILL.md b/plugins/auto-thread-title/skills/configure-task-titles/SKILL.md index d29333c..ffff515 100644 --- a/plugins/auto-thread-title/skills/configure-task-titles/SKILL.md +++ b/plugins/auto-thread-title/skills/configure-task-titles/SKILL.md @@ -27,6 +27,16 @@ or rename them. To investigate batch API compatibility, use `doctor --probe`; this generates and cleans up a temporary CLI schema without requesting task data. None of these checks proves desktop tool availability or rename permissions. +The startup hook separately reads only the current task's metadata with +`thread/read`, `includeTurns: false`. It validates identity, cwd and createdAt +locally, computes the Shanghai date and skips already compliant titles. Normal +operation injects a short policy and needs only one model-side title write; +unavailable or not-yet-readable metadata falls back to the full verification +policy, while identity or scope mismatches skip naming. It does not use or inject +task bodies or previews. Doctor success does not prove metadata is visible at the +first startup or that a desktop rename completed. Do not promise a fixed usage +saving from fewer prompt characters or tool calls. + For CLI discovery, explicit overrides take precedence; automatic discovery prefers Codex's managed `plugins/.plugin-appserver/codex` (`codex.exe` on Windows) over PATH. Do not replace it with an older PATH CLI just because that binary @@ -96,10 +106,12 @@ state and diagnostic result. First-use hook trust still follows Codex's approval flow; do not bypass it. Only a new task with SessionStart source `startup` can trigger automatic naming; continuing an existing task does not retroactively trigger it. Once the first user request establishes a clear topic, the naming -policy prioritizes the current task's title before business tool calls instead -of waiting for the final answer. An unclear topic or unavailable naming tools +policy prioritizes the current task's title before business tool calls, including +sequential calls in the same batch, instead of waiting for the final answer. +An unclear topic or unavailable naming tools means skip. If interrupted before the rename completes, the original title may remain; never resume an interrupted task just to finish naming it. Distinguish successful hook injection from a completed title write when reporting diagnostics. -There is no polling or extra model turn, but project discovery may briefly start -a bounded local subprocess. Do not trigger a real title change as a setup test. +There is no polling, added MCP server or separate model task, but project +discovery and metadata reads use bounded local subprocesses and RPCs. Do not +trigger a real title change as a setup test. diff --git a/plugins/auto-thread-title/src/cli.mjs b/plugins/auto-thread-title/src/cli.mjs index b7a5575..7dc2191 100644 --- a/plugins/auto-thread-title/src/cli.mjs +++ b/plugins/auto-thread-title/src/cli.mjs @@ -4,6 +4,7 @@ import { loadConfig, saveConfig, nativeAbsolute, isWithin } from './config.mjs'; import { buildContext, eligibleStartup } from './policy.mjs'; import { discoverProjectRoots, matchesSavedProject } from './projects.mjs'; import { resolveProjectCodex } from './project-runtime.mjs'; +import { readStartupMetadata } from './startup-metadata.mjs'; import { collectTasks } from './inventory.mjs'; import { resolveCodex, probeCapabilities, withAppServer } from './transport.mjs'; @@ -80,6 +81,7 @@ async function main() { const executable = options['--codex'] || process.env.AUTO_THREAD_TITLE_CODEX || config.codexPath; const getLaunch = () => resolveProjectCodex({ executable }); if (options.command === 'hook') { + const startedAt = Date.now(); const event = await readEvent(); // Ineligible events must not read project metadata or start any subprocess. if (!eligibleStartup(event, config) || !nativeAbsolute(event.cwd)) return; @@ -89,7 +91,11 @@ async function main() { if (!await matchesSavedProject(event.cwd, roots)) return; effective = { ...config, projectRoots: [event.cwd] }; } - const context = buildContext(event, effective); + // Match manual scope before the optional metadata subprocess as well. + if (!effective.projectRoots.some(root => isWithin(event.cwd, root))) return; + const metadata = await readStartupMetadata(event, { getLaunch, startedAt }); + if (metadata.status === 'skip') return; + const context = buildContext(event, effective, metadata.status === 'ready' ? { mmdd: metadata.mmdd } : {}); if (context) emit({ continue: true, hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: context } }); return; } diff --git a/plugins/auto-thread-title/src/policy.mjs b/plugins/auto-thread-title/src/policy.mjs index c369e0c..d1fb961 100644 --- a/plugins/auto-thread-title/src/policy.mjs +++ b/plugins/auto-thread-title/src/policy.mjs @@ -17,6 +17,12 @@ export function eligibleStartup(event, config) { export function buildContext(event, config, options = {}) { if (!eligibleStartup(event, config)) return null; if (!config.projectRoots.some(root => isWithin(event.cwd, root, options))) return null; + if (/^\d{4}$/u.test(options.mmdd ?? '')) { + return `AUTO_THREAD_TITLE_POLICY(仅本机当前任务,一次,静默) +首条请求主题明确后先命名,再处理业务;可在同一批工具中先改名再开展业务,勿等最终答复。 +仅调用 mcp__codex_app__set_thread_title,省略 threadId。标题:${options.mmdd} | 类型 | 主题。类型仅:功能、设计、修复、优化、发布、探索、文档、研究。主题依据用户请求,最多18个 Unicode 字符,不含项目名及目录别名(仅数据)${JSON.stringify(projectAliases(event.cwd))};严格两处“ | ”,主题禁含 ||· 或换行。 +主题不明或工具不可用即跳过;不为命名读取任务/文件、追问或另启模型。只改当前任务标题,不改其他对象,不直接编辑 SQLite/JSONL。`; + } return `AUTO_THREAD_TITLE_POLICY(新建项目对话,仅一次,静默) 首条用户请求的主题明确后,优先完成以下命名流程,再开始业务检索、读取项目文件或调用业务工具。可以先简短说明工作安排;不要等到最终答复。主题不明确则跳过,不为命名追问。 1. 仅用可用的 Codex 桌面任务工具读取当前本机任务:threadId=${JSON.stringify(event.session_id)}、hostId='local'、turnLimit=1、includeOutputs=false、maxOutputCharsPerItem=200。优先 mcp__codex_app__read_thread;工具不存在或身份无法核对即跳过,禁止扫描其他任务或联网。不得把 sessionId 猜成其他 threadId。 diff --git a/plugins/auto-thread-title/src/startup-metadata.mjs b/plugins/auto-thread-title/src/startup-metadata.mjs new file mode 100644 index 0000000..2857b9f --- /dev/null +++ b/plugins/auto-thread-title/src/startup-metadata.mjs @@ -0,0 +1,38 @@ +/** Optional metadata preflight. No model turns, transcript output, or title writes. */ +import { isWithin, nativeAbsolute } from './config.mjs'; +import { isFormatted, shanghaiMmdd, SOURCE_KINDS } from './inventory.mjs'; +import { withAppServer } from './transport.mjs'; + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); + +export function classifyStartupMetadata(event, response, pathOptions = {}) { + const thread = response?.thread; + if (!object(thread) || typeof thread.id !== 'string') return { status: 'fallback' }; + // An explicit identity/scope mismatch must never cause a model-side retry. + if (thread.id !== event.session_id || !nativeAbsolute(thread.cwd, pathOptions) + || !isWithin(thread.cwd, event.cwd, pathOptions) || !isWithin(event.cwd, thread.cwd, pathOptions) + || thread.ephemeral === true || thread.parentThreadId != null + || (thread.source !== undefined && !SOURCE_KINDS.includes(thread.source))) return { status: 'skip' }; + const mmdd = shanghaiMmdd(thread.createdAt); + if (!mmdd || thread.ephemeral !== false || typeof thread.source !== 'string' + || !Array.isArray(thread.turns) || thread.turns.length + || (thread.name != null && typeof thread.name !== 'string')) return { status: 'fallback' }; + if (isFormatted(thread.name, mmdd, event.cwd)) return { status: 'skip' }; + // Do not pass name, preview, turns, session-tree IDs or other metadata to the model. + return { status: 'ready', mmdd }; +} + +export async function readStartupMetadata(event, { getLaunch, startedAt = Date.now(), now = Date.now } = {}) { + // Leave room for process cleanup and the original policy under the 10s hook + // timeout. Slow project discovery must not lose its already-confirmed scope. + if (now() - startedAt >= 5000) return { status: 'fallback' }; + try { + const response = await withAppServer(getLaunch(), request => request('thread/read', { + threadId: event.session_id, includeTurns: false, + }), { purpose: 'metadata', threadId: event.session_id, timeoutMs: 600, maxDurationMs: 800 }); + return classifyStartupMetadata(event, response); + } catch { + // Includes older CLI versions and threads not yet visible across processes. + return { status: 'fallback' }; + } +} diff --git a/plugins/auto-thread-title/src/transport.mjs b/plugins/auto-thread-title/src/transport.mjs index b6b41d9..abbfdae 100644 --- a/plugins/auto-thread-title/src/transport.mjs +++ b/plugins/auto-thread-title/src/transport.mjs @@ -12,6 +12,7 @@ const MAX_STDERR_BYTES = 1024 * 1024; const MAX_SCHEMA_BYTES = 64 * 1024 * 1024; const ALLOWED_LIST_KEYS = new Set(['limit', 'sortKey', 'sortDirection', 'modelProviders', 'sourceKinds', 'archived', 'useStateDbOnly', 'cursor']); const ALLOWED_PROJECT_KEYS = new Set(['limit', 'cursor']); +const STARTUP_THREAD_ID = /^[A-Za-z0-9][A-Za-z0-9_-]{7,127}$/u; function usableFile(file, platform) { try { @@ -225,11 +226,22 @@ function validateProjectRequest(method, params) { } } -export async function withAppServer(launch, callback, { timeoutMs = 30000, maxDurationMs = 120000, purpose = 'inventory', env = process.env } = {}) { +function validateMetadataRequest(method, params, threadId) { + if (method !== 'thread/read' || !params || typeof params !== 'object' || Array.isArray(params) + || Object.keys(params).length !== 2 || !Object.hasOwn(params, 'threadId') || !Object.hasOwn(params, 'includeTurns') + || params.threadId !== threadId || params.includeTurns !== false) { + throw new Error('Only the bound startup task may be read with includeTurns explicitly false'); + } +} + +export async function withAppServer(launch, callback, { timeoutMs = 30000, maxDurationMs = 120000, purpose = 'inventory', threadId, env = process.env } = {}) { validateLaunch(launch); validTimeout(timeoutMs); validTimeout(maxDurationMs); - if (!['inventory', 'projects'].includes(purpose)) throw new Error('Invalid read-only app-server purpose'); + if (!['inventory', 'projects', 'metadata'].includes(purpose)) throw new Error('Invalid read-only app-server purpose'); + if (purpose === 'metadata' && (typeof threadId !== 'string' || !STARTUP_THREAD_ID.test(threadId))) { + throw new Error('Metadata reads require a valid bound startup task ID'); + } if (typeof callback !== 'function') throw new Error('An inventory callback is required'); const child = spawn(launch.file, [...launch.args, 'app-server', '--listen', 'stdio://'], { shell: false, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'], env, @@ -238,6 +250,7 @@ export async function withAppServer(launch, callback, { timeoutMs = 30000, maxDu let fatalError; let pending; let sequence = 0; + let metadataReadSent = false; let totalBytes = 0; let stderrBytes = 0; let messageCount = 0; @@ -302,7 +315,8 @@ export async function withAppServer(launch, callback, { timeoutMs = 30000, maxDu } }); const send = (message) => { - if (!['initialize', 'initialized', purpose === 'projects' ? 'project/list' : 'thread/list'].includes(message.method)) throw new Error('Only read-only inventory methods are allowed'); + const readMethod = purpose === 'projects' ? 'project/list' : purpose === 'metadata' ? 'thread/read' : 'thread/list'; + if (!['initialize', 'initialized', readMethod].includes(message.method)) throw new Error('Only read-only inventory methods are allowed'); child.stdin.write(`${JSON.stringify(message)}\n`); }; const rpc = (method, params) => { @@ -316,12 +330,20 @@ export async function withAppServer(launch, callback, { timeoutMs = 30000, maxDu }); }; const request = async (method, params) => { + if (purpose === 'metadata') { + validateMetadataRequest(method, params, threadId); + if (metadataReadSent) throw new Error('Only one startup metadata read is allowed per session'); + metadataReadSent = true; + // Serialize only the validated bound identity, never caller-controlled + // getters, later mutations, or a custom params.toJSON implementation. + return rpc('thread/read', { threadId, includeTurns: false }); + } (purpose === 'projects' ? validateProjectRequest : validateListRequest)(method, params); return rpc(method, params); }; try { await rpc('initialize', { - clientInfo: { name: purpose === 'projects' ? 'auto_thread_title_projects' : 'auto_thread_title_inventory', version: '2.0.0' }, + clientInfo: { name: `auto_thread_title_${purpose}`, version: '2.0.0' }, ...(purpose === 'projects' ? { capabilities: { experimentalApi: true } } : {}), }); send({ method: 'initialized', params: {} }); diff --git a/plugins/auto-thread-title/tests/automatic-cli.test.mjs b/plugins/auto-thread-title/tests/automatic-cli.test.mjs index 4e4be90..47532fe 100644 --- a/plugins/auto-thread-title/tests/automatic-cli.test.mjs +++ b/plugins/auto-thread-title/tests/automatic-cli.test.mjs @@ -27,6 +27,77 @@ function fixture(t) { return { root, project, other, config, registry, marker, executable, event, env, run, saveProjects }; } +function metadataServer(f) { + const responseFile = path.join(f.root, 'metadata-response.json'); + const thread = { id: f.event.session_id, cwd: f.project, source: 'vscode', ephemeral: false, + name: null, createdAt: Date.parse('2026-09-08T16:00:00Z') / 1000, turns: [], preview: 'PRIVATE-PREVIEW' }; + fs.writeFileSync(f.executable, ` +import fs from 'node:fs'; +import readline from 'node:readline'; +readline.createInterface({input:process.stdin,crlfDelay:Infinity}).on('line', line => { + const m = JSON.parse(line); + fs.appendFileSync(${JSON.stringify(f.marker)}, JSON.stringify(m) + '\\n'); + const send = value => process.stdout.write(JSON.stringify({id:m.id,...value})+'\\n'); + if (m.method === 'initialize') send({result:{userAgent:'synthetic'}}); + else if (m.method === 'initialized') {} + else if (m.method === 'thread/read') { + const response = JSON.parse(fs.readFileSync(${JSON.stringify(responseFile)}, 'utf8')); + if (response.hang) return; + send(response); + } else process.exit(9); +}); +`); + const respond = response => fs.writeFileSync(responseFile, JSON.stringify(response)); + respond({ result: { thread } }); + return { thread, respond, audit: () => fs.readFileSync(f.marker, 'utf8').trim().split('\n').map(JSON.parse) }; +} + +test('normal startup reads only current metadata, emits a lean policy, and supports manual scope', t => { + const f = fixture(t), server = metadataServer(f); + f.saveProjects([f.project]); + for (const manual of [false, true]) { + if (manual) { + fs.mkdirSync(path.dirname(f.config)); + fs.writeFileSync(f.config, JSON.stringify({ scope: 'manual', projectRoots: [f.project] })); + } + const result = f.run(['hook'], f.event); + assert.equal(result.status, 0, result.stderr); + assert.equal(result.stderr, ''); + const context = JSON.parse(result.stdout).hookSpecificOutput.additionalContext; + assert.match(context, /0909 \| 类型 \| 主题/u); + assert.match(context, /省略 threadId/u); + assert.doesNotMatch(context, /read_thread|PRIVATE-PREVIEW|synthetic-startup-123/u); + } + const audit = server.audit(); + assert.deepEqual(audit.map(m => m.method), ['initialize', 'initialized', 'thread/read', 'initialize', 'initialized', 'thread/read']); + assert.equal(audit[0].params.capabilities, undefined); + assert.deepEqual(audit[2].params, { threadId: f.event.session_id, includeTurns: false }); + // The extra preflight cannot run outside either scope. + assert.equal(f.run(['hook'], { ...f.event, cwd: f.other }).stdout, ''); + assert.equal(server.audit().length, 6); +}); + +test('formatted titles and identity mismatches emit no context while transient unavailability falls back', t => { + const f = fixture(t), server = metadataServer(f); + f.saveProjects([f.project]); + for (const change of [{ name: '0909 | 优化 | 内存占用' }, { id: 'different-thread-123' }, { cwd: f.other }]) { + server.respond({ result: { thread: { ...server.thread, ...change } } }); + const result = f.run(['hook'], f.event); + assert.equal(result.stdout, ''); + assert.equal(result.stderr, ''); + } + for (const response of [{ error: { code: -32601, message: 'PRIVATE-SERVER-ERROR' } }, { hang: true }]) { + server.respond(response); + const result = f.run(['hook'], f.event); + assert.equal(result.status, 0, result.stderr); + assert.match(JSON.parse(result.stdout).hookSpecificOutput.additionalContext, /read_thread/u); + assert.doesNotMatch(result.stdout + result.stderr, /PRIVATE/u); + } + server.respond({ result: { thread: server.thread } }); + assert.doesNotMatch(JSON.parse(f.run(['hook'], f.event).stdout).hookSpecificOutput.additionalContext, /read_thread/u); + assert.equal(fs.existsSync(f.config), false); +}); + test('fresh installation follows additions/removals of saved project roots without writing config', t => { const f = fixture(t); f.saveProjects([f.other, f.project]); @@ -34,13 +105,14 @@ test('fresh installation follows additions/removals of saved project roots witho assert.equal(first.status, 0, first.stderr); assert.equal(JSON.parse(first.stdout).hookSpecificOutput.hookEventName, 'SessionStart'); assert.equal(fs.existsSync(f.config), false); - assert.equal(fs.existsSync(f.marker), false); + // This fixture simulates an older CLI; metadata failure keeps the full policy. + assert.equal(fs.existsSync(f.marker), true); f.saveProjects([f.other]); assert.equal(f.run(['hook'], f.event).stdout, ''); f.saveProjects([f.project]); assert.ok(f.run(['hook'], f.event).stdout); assert.equal(fs.existsSync(f.config), false); - assert.equal(fs.existsSync(f.marker), false); + assert.equal(fs.existsSync(f.marker), true); }); test('disabled, resumed and invalid startup events do not query projects', t => { @@ -110,7 +182,7 @@ input.on('line', line => { const result = f.run(['hook'], f.event); assert.equal(result.status, 0, result.stderr); assert.ok(result.stdout, result.stderr); - assert.deepEqual(fs.readFileSync(f.marker, 'utf8').trim().split('\n'), ['initialize', 'initialized', 'project/list']); + assert.deepEqual(fs.readFileSync(f.marker, 'utf8').trim().split('\n'), ['initialize', 'initialized', 'project/list', 'initialize']); installServer([f.other]); assert.equal(f.run(['hook'], f.event).stdout, ''); // API failure after migration must not reactivate the stale scope. diff --git a/plugins/auto-thread-title/tests/startup-metadata.test.mjs b/plugins/auto-thread-title/tests/startup-metadata.test.mjs new file mode 100644 index 0000000..d0f9fb0 --- /dev/null +++ b/plugins/auto-thread-title/tests/startup-metadata.test.mjs @@ -0,0 +1,74 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import path from 'node:path'; +import { classifyStartupMetadata, readStartupMetadata } from '../src/startup-metadata.mjs'; +import { buildContext } from '../src/policy.mjs'; + +const event = { hook_event_name: 'SessionStart', source: 'startup', + session_id: 'synthetic-startup-123', cwd: '/projects/demo-mixing' }; +const posix = { platform: 'darwin', realpath: value => path.posix.normalize(value) }; +const thread = { id: event.session_id, cwd: event.cwd, source: 'vscode', ephemeral: false, + createdAt: Date.parse('2026-09-08T16:00:00Z') / 1000, name: null, turns: [] }; +const classify = change => classifyStartupMetadata(event, { thread: { ...thread, ...change } }, posix); + +test('metadata projects only Shanghai creation date, ignoring preview, updated time and session-tree identity', () => { + assert.deepEqual(classify({ preview: 'private preview', updatedAt: thread.createdAt - 1, + sessionId: 'different-session-tree-id', name: 'old title' }), { status: 'ready', mmdd: '0909' }); + assert.deepEqual(classify({ createdAt: thread.createdAt - 1 }), { status: 'ready', mmdd: '0908' }); + for (const source of ['cli', 'vscode', 'appServer', 'exec']) assert.equal(classify({ source }).status, 'ready'); +}); + +test('already formatted titles skip; wrong date, format, type, length or project alias still need naming', () => { + assert.deepEqual(classify({ name: '0909 | 优化 | 内存占用' }), { status: 'skip' }); + for (const name of ['0908 | 优化 | 内存占用', '0909|优化|内存占用', '0909 | 无效 | 内存占用', + `0909 | 优化 | ${'长'.repeat(19)}`, '0909 | 优化 | demo内存占用', '0909 | 优化 | 内存\n占用']) { + assert.equal(classify({ name }).status, 'ready', name); + } +}); + +test('explicit identity, directory, ephemeral and subagent mismatches skip without model retry', () => { + for (const change of [{ id: 'different-thread-123' }, { cwd: '/projects/demo-mixing/sub' }, + { cwd: '/projects' }, { cwd: '/projects/demo-other' }, { cwd: 'relative' }, + { ephemeral: true }, { parentThreadId: 'parent-thread-123' }, + { source: { subAgent: { thread_spawn: {} } } }, { source: 'remote' }]) { + assert.deepEqual(classify(change), { status: 'skip' }); + } + const winEvent = { ...event, cwd: 'S:\\Projects\\Demo' }; + assert.deepEqual(classifyStartupMetadata(winEvent, { thread: { ...thread, cwd: 's:/projects/demo' } }, + { platform: 'win32', realpath: value => path.win32.normalize(value) }), { status: 'ready', mmdd: '0909' }); +}); + +test('unsupported/malformed metadata falls back without copying transcript content', () => { + for (const response of [null, {}, { thread: null }, { thread: [] }, { thread: {} }]) { + assert.deepEqual(classifyStartupMetadata(event, response, posix), { status: 'fallback' }); + } + for (const change of [{ createdAt: null }, { createdAt: '1788883200' }, { ephemeral: undefined }, + { source: undefined }, { turns: [{}] }, { turns: null }, { name: {} }]) { + assert.deepEqual(classify(change), { status: 'fallback' }); + } +}); + +test('slow scope discovery and missing CLI preserve the fallback without delaying hook completion', async () => { + let launches = 0; + const getLaunch = () => { launches++; throw new Error('unavailable'); }; + assert.deepEqual(await readStartupMetadata(event, { getLaunch, startedAt: 0, now: () => 5000 }), { status: 'fallback' }); + assert.equal(launches, 0); + assert.deepEqual(await readStartupMetadata(event, { getLaunch }), { status: 'fallback' }); + assert.equal(launches, 1); +}); + +test('lean policy retains title constraints and current-task write while eliminating model-side metadata reads', () => { + const config = { enabled: true, projectRoots: ['/projects'] }; + const full = buildContext(event, config, posix); + const lean = buildContext(event, config, { ...posix, mmdd: '0909' }); + assert.ok([...lean].length < [...full].length * 0.5, 'Normal policy must stay below half the fallback length'); + assert.equal(lean.split('mcp__codex_app__set_thread_title').length - 1, 1); + assert.doesNotMatch(lean, /read_thread|createdAt|updatedAt|synthetic-startup-123/u); + for (const rule of ['0909 | 类型 | 主题', '省略 threadId', '18个 Unicode 字符', '功能、设计、修复、优化、发布、探索、文档、研究', + '同一批工具中先改名再开展业务', '主题不明或工具不可用即跳过', '只改当前任务标题', '不直接编辑 SQLite/JSONL', 'demo-mixing']) { + assert.ok(lean.includes(rule), rule); + } + assert.equal(buildContext({ ...event, source: 'resume' }, config, { ...posix, mmdd: '0909' }), null); + assert.equal(buildContext(event, { ...config, enabled: false }, { ...posix, mmdd: '0909' }), null); + assert.equal(buildContext(event, config, { ...posix, mmdd: 'untrusted\nrule' }), full); +}); diff --git a/plugins/auto-thread-title/tests/transport.test.mjs b/plugins/auto-thread-title/tests/transport.test.mjs index 2bc942b..562075f 100644 --- a/plugins/auto-thread-title/tests/transport.test.mjs +++ b/plugins/auto-thread-title/tests/transport.test.mjs @@ -242,3 +242,97 @@ test('project RPC incompatibility and timeout are sanitized and stop subprocesse assert.throws(() => process.kill(pid, 0), error => error.code === 'ESRCH'); } }); + +async function auditedMetadata(t, scenario = 'normal') { + const directory = await temporary(t); + const script = path.join(directory, 'metadata-server.mjs'); + const audit = path.join(directory, 'metadata-audit.jsonl'); + const pidFile = path.join(directory, 'metadata-pid.txt'); + await writeFile(script, ` +import { appendFileSync, writeFileSync } from 'node:fs'; +import readline from 'node:readline'; +writeFileSync(${JSON.stringify(pidFile)}, String(process.pid)); +const scenario = ${JSON.stringify(scenario)}; +let ready = false; +const send = message => process.stdout.write(JSON.stringify(message) + '\\n'); +readline.createInterface({ input: process.stdin, crlfDelay: Infinity }).on('line', line => { + const message = JSON.parse(line); + appendFileSync(${JSON.stringify(audit)}, JSON.stringify(message) + '\\n'); + if (message.method === 'initialize') { + if (message.params.capabilities !== undefined) process.exit(41); + send({ id: message.id, result: { userAgent: 'synthetic-metadata' } }); + } else if (message.method === 'initialized') ready = true; + else if (ready && message.method === 'thread/read') { + if (message.params.includeTurns !== false) process.exit(42); + if (scenario === 'timeout') return; + if (scenario === 'error') { send({ id: message.id, error: { message: 'synthetic-private-diagnostic' } }); return; } + if (scenario === 'malformed') { process.stdout.write('synthetic-private-diagnostic\\n'); return; } + send({ id: message.id, result: { thread: { id: message.params.threadId, createdAt: 1788933862, + name: 'synthetic-title', cwd: '/synthetic/project', source: 'vscode', turns: [] } } }); + } else process.exit(43); +}); +`); + return { spec: { file: process.execPath, args: [script] }, + readAudit: async () => (await readFile(audit, 'utf8')).trim().split('\n').map(JSON.parse), + assertNotStarted: async () => assert.rejects(access(pidFile), error => error.code === 'ENOENT'), + assertStopped: async () => { + const pid = Number(await readFile(pidFile, 'utf8')); + assert.throws(() => process.kill(pid, 0), error => error.code === 'ESRCH'); + } }; +} + +test('metadata purpose binds a valid startup task ID before launching a subprocess', async t => { + const child = await auditedMetadata(t); + for (const threadId of [undefined, null, '', 'short12', 'a'.repeat(129), '_1234567', 'thread/123', 'thread id', '线程123456', 'thread\n123']) { + await assert.rejects(withAppServer(child.spec, () => {}, { purpose: 'metadata', threadId }), /valid bound startup task ID/); + } + await child.assertNotStarted(); + for (const threadId of ['a12345_-', 'a'.repeat(128)]) { + await withAppServer(child.spec, request => request('thread/read', { threadId, includeTurns: false }), { purpose: 'metadata', threadId }); + } + await child.assertStopped(); +}); + +test('metadata purpose sends only one exact read without experimental APIs or other task access', async t => { + const child = await auditedMetadata(t); + const threadId = 'synthetic-startup-123'; + const readParams = { threadId, includeTurns: false }; + const result = await withAppServer(child.spec, async request => { + for (const method of ['thread/list', 'project/list', 'thread/name/set', 'thread/metadata/update', 'thread/start', + 'turn/start', 'thread/resume', 'thread/archive', 'thread/turns/list', 'initialize', 'initialized']) { + await assert.rejects(request(method, readParams), /Only the bound startup task/); + } + for (const invalid of [{ threadId }, { ...readParams, threadId: 'another-startup-456' }, { ...readParams, includeTurns: true }, + { ...readParams, includeTurns: undefined }, { ...readParams, cursor: 'extra' }, { ...readParams, includeOutputs: false }, + { includeTurns: false }, null, [], Object.create(readParams)]) { + await assert.rejects(request('thread/read', invalid), /Only the bound startup task/); + } + const valid = { ...readParams }; + Object.defineProperty(valid, 'toJSON', { value: () => ({ threadId: 'another-startup-456', includeTurns: true }) }); + const first = request('thread/read', valid); + await assert.rejects(request('thread/read', readParams), /Only one startup metadata read/); + const response = await first; + await assert.rejects(request('thread/read', readParams), /Only one startup metadata read/); + return response; + }, { purpose: 'metadata', threadId }); + assert.equal(result.thread.id, threadId); + assert.equal(result.thread.createdAt, 1788933862); + assert.deepEqual(result.thread.turns, []); + const messages = await child.readAudit(); + assert.deepEqual(messages.map(message => message.method), ['initialize', 'initialized', 'thread/read']); + assert.equal(messages[0].params.capabilities, undefined); + assert.deepEqual(messages[2].params, readParams); + await child.assertStopped(); +}); + +test('metadata rejection and timeout stay sanitized and terminate the subprocess', async t => { + for (const scenario of ['error', 'malformed', 'timeout']) { + const child = await auditedMetadata(t, scenario); + const threadId = 'synthetic-startup-123'; + await assert.rejects(withAppServer(child.spec, request => request('thread/read', { threadId, includeTurns: false }), { + purpose: 'metadata', threadId, timeoutMs: 400, + }), error => !error.message.includes('synthetic-private')); + assert.deepEqual((await child.readAudit()).map(message => message.method), ['initialize', 'initialized', 'thread/read']); + await child.assertStopped(); + } +}); From f217b5e76a0a504a23c9dc5022709debc2962934 Mon Sep 17 00:00:00 2001 From: whp Date: Wed, 9 Sep 2026 15:19:52 +0800 Subject: [PATCH 2/2] fix(auto-thread-title): preserve all subagent exclusions in metadata preflight --- plugins/auto-thread-title/src/startup-metadata.mjs | 2 +- plugins/auto-thread-title/tests/startup-metadata.test.mjs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/auto-thread-title/src/startup-metadata.mjs b/plugins/auto-thread-title/src/startup-metadata.mjs index 2857b9f..06aca8c 100644 --- a/plugins/auto-thread-title/src/startup-metadata.mjs +++ b/plugins/auto-thread-title/src/startup-metadata.mjs @@ -11,7 +11,7 @@ export function classifyStartupMetadata(event, response, pathOptions = {}) { // An explicit identity/scope mismatch must never cause a model-side retry. if (thread.id !== event.session_id || !nativeAbsolute(thread.cwd, pathOptions) || !isWithin(thread.cwd, event.cwd, pathOptions) || !isWithin(event.cwd, thread.cwd, pathOptions) - || thread.ephemeral === true || thread.parentThreadId != null + || thread.ephemeral === true || thread.parentThreadId != null || thread.agentRole || thread.agentNickname || (thread.source !== undefined && !SOURCE_KINDS.includes(thread.source))) return { status: 'skip' }; const mmdd = shanghaiMmdd(thread.createdAt); if (!mmdd || thread.ephemeral !== false || typeof thread.source !== 'string' diff --git a/plugins/auto-thread-title/tests/startup-metadata.test.mjs b/plugins/auto-thread-title/tests/startup-metadata.test.mjs index d0f9fb0..dad7b90 100644 --- a/plugins/auto-thread-title/tests/startup-metadata.test.mjs +++ b/plugins/auto-thread-title/tests/startup-metadata.test.mjs @@ -29,7 +29,7 @@ test('already formatted titles skip; wrong date, format, type, length or project test('explicit identity, directory, ephemeral and subagent mismatches skip without model retry', () => { for (const change of [{ id: 'different-thread-123' }, { cwd: '/projects/demo-mixing/sub' }, { cwd: '/projects' }, { cwd: '/projects/demo-other' }, { cwd: 'relative' }, - { ephemeral: true }, { parentThreadId: 'parent-thread-123' }, + { ephemeral: true }, { parentThreadId: 'parent-thread-123' }, { agentRole: 'worker' }, { agentNickname: 'Scout' }, { source: { subAgent: { thread_spawn: {} } } }, { source: 'remote' }]) { assert.deepEqual(classify(change), { status: 'skip' }); }