Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification

# Conflicts:
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.i18n.yaml
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.md
#	.agents/notes/implemented/architecture/2026-07-22-unified-send-and-coalesced-user-messages.zh.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.i18n.yaml
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.md
#	.agents/notes/implemented/feature/2026-07-21-tui-skill-slash-command.zh.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.i18n.yaml
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.md
#	.agents/notes/implemented/simplification/2026-07-17-one-send-one-turn.zh.md
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/events.md
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/core-data-structures/core.md
#	docs/core-data-structures/core.zh.md
#	docs/defensive-patterns.i18n.yaml
#	packages/client/runtime/src/client/sessions/session.ts
#	packages/client/runtime/tests/queue-store.spec.ts
#	packages/context/time-context/tests/time-context.spec.ts
#	packages/context/workspace-context/tests/workspace-context.spec.ts
#	packages/cordis/tool-cordis/src/api-catalog.ts
#	packages/core/agent-loop/README.i18n.yaml
#	packages/core/agent-loop/README.md
#	packages/core/agent-loop/README.zh.md
#	packages/core/agent-loop/src/agent.ts
#	packages/core/agent/README.i18n.yaml
#	packages/core/agent/README.md
#	packages/core/agent/README.zh.md
#	packages/core/agent/src/types.ts
#	packages/core/agent/tests/agent.spec.ts
#	packages/core/scope/src/scoped-events.generated.ts
#	packages/goal/command-goal/tests/command-goal.spec.ts
#	packages/goal/goal-session/src/index.ts
#	packages/goal/goal-session/tests/goal-session.spec.ts
#	packages/goal/goal/tests/goal.spec.ts
#	packages/goal/goal/tests/projection.spec.ts
#	packages/goal/tool-goal/tests/tool-goal.spec.ts
#	packages/host/apiproxy/src/api-proxy.ts
#	packages/host/apiproxy/src/api/events.schema.ts
#	packages/host/apiproxy/src/api/events.ts
#	packages/host/apiproxy/tests/api-proxy-workspace.spec.ts
#	packages/llm/llm/README.i18n.yaml
#	packages/llm/llm/README.zh.md
#	packages/llm/llm/src/index.ts
#	packages/pty/pty-local/tests/index.spec.ts
#	packages/pty/pty-local/tests/local.spec.ts
#	packages/pty/pty/tests/service.spec.ts
#	packages/pty/tool-pty/tests/loader-composition.spec.ts
#	packages/pty/tool-pty/tests/tools.spec.ts
#	packages/skill/tool-skill/tests/tool-skill.spec.ts
#	packages/tasks/tasks-local/tests/tasks.spec.ts
#	packages/ui/tui/src/index.ts
#	packages/ui/tui/tests/harness.ts
#	packages/ui/tui/tests/tui.spec.ts
#	scripts/gen-cordis-catalog.ts
#	scripts/type-equiv.manifest.json
This commit is contained in:
_Kerman
2026-07-30 14:04:53 +08:00
1175 changed files with 49683 additions and 8452 deletions

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/hooks/hooks-codex/README.md
README.md: fd57762c6fb91e0ea47ec57c30bf9850bc488a33
README.zh.md: 367d6acd0fec486cb0f9fb50023ad2ed4cca7217
README.md: e906810ed58c3d0204c618c32787af06c91cfb78
README.zh.md: 4940fdb976dd963bbb2e41c0ec6ef274ee475334

View File

@@ -34,7 +34,7 @@ In a `cordis.yml`:
model: deepseek-v4
```
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing). Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
The config is parsed **once** at load. `configPath` is **process-level** — a relative path resolves against the process launch cwd at load time, not per-session (`TODO(per-session-hook-config)`). A read/parse failure is contained (logs + registers nothing); an invalid regex matcher on an event that consumes matchers is one such failure and reports its pattern and event. Only sync `type: 'command'` hooks run — a non-command or `async: true` hook is parsed-and-skipped with a warning. A hook accepts `timeout` or the `timeoutSec` alias; one that sets neither runs under the protocol's reference default (`DEFAULT_HOOK_TIMEOUT_MS` from `dsh-hook-protocol`, 10 minutes). Events outside the five bridge-supported points are dropped at parse.
The hooks themselves run in the agent's session workspace: for the agent-scoped points the bridge passes the session's `cwd` as the hook process's working directory, so a hook operates in the user's project tree, not the server launch dir.

View File

@@ -2,17 +2,17 @@
[English](README.md) | 中文
一个 cordis 插件,在 harness 的规范拦截 seam 上运行 user 现有 **Codex** hook 配置的受支持子集。它是 hooks 子系统 **Codex 方言** 一半。方言无关原语来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md);该桥接拥有 Codex 形状 payload、matcher 模式和决策映射。
一个 Cordis 插件,在 harness 的规范拦截 seam 上运行用户现有 **Codex** hook 配置的受支持子集。它是 hooks 子系统中采用 **Codex 方言** 的一侧。方言无关原语来自 [`@deepseek-ai/dsh-hook-protocol`](../hook-protocol/README.md);该桥接负责处理 Codex 形状 payload、matcher 模式和决策映射。
该桥接实现 Codex 当前 hook 协议的一个明确子集:
- **10 个 hook 点中的 5 个:** `PreToolUse``PostToolUse``SessionStart``UserPromptSubmit``Stop`
- **使用正则 matcher**没有字面快速路径matcher 始终是未锚定正则)。
- **snake_case stdin payload**,携带 `turn_id``model` 额外字段,写入时**不带** 尾随换行符。
- **使用正则 matcher**(没有字面快速路径matcher 始终是未锚定正则)。
- **snake_case stdin payload**,携带 `turn_id``model` 额外字段,写入时**不带**尾随换行符。
- **没有 Codex 插件 env 注入,也没有配置时 placeholder 替换**(命令仍会接收执行器环境,并通过其 shell 运行)。
- **没有工具前批或改写路径**hook 可以阻塞,但桥接不会预批或替换工具输入。
- **没有工具前批或改写路径**hook 可以阻塞,但桥接不会预批或替换工具输入。
原生 cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Note](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。
原生 Cordis 插件可以完成此桥接的所有工作,并且功能更强;该桥接只是已映射 Codex 子集的兼容路径(见 [拦截 seam Agent Noteagent 决策记录)](../../../.agents/notes/implemented/feature/2026-06-30-interception-seams.md))。
## 配置
@@ -34,25 +34,25 @@ const config: Config = {
model: deepseek-v4
```
配置只在加载时解析**一次**。`configPath` 是**进程级** 配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被容纳(记录 + 不注册任何内容)。只运行同步 `type: 'command'` hook非 command 或 `async: true` hook 会被解析并跳过同时记录警告。hook 接受 `timeout``timeoutSec` alias两者都未设置时使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
配置只在加载时解析**一次**。`configPath` 是**进程级**配置:相对路径在加载时根据进程启动 cwd 解析,而非每会话解析(`TODO(per-session-hook-config)`)。读取/解析失败会被隔离处理(记录 + 不注册任何内容);实际消费 matcher 的事件所带的无效 matcher 正则属于此类失败,并报告其 pattern 与事件。只运行同步 `type: 'command'` hook非 command 或 `async: true` hook 会被解析并跳过同时记录警告。hook 接受 `timeout``timeoutSec` alias两者都未设置时使用协议参考默认值 `DEFAULT_HOOK_TIMEOUT_MS`(来自 `dsh-hook-protocol`10 分钟)。五个桥接支持点之外的事件会在解析时丢弃。
hook 本身会在 agent 的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于 user 项目树,而非服务器启动目录。
hook 本身会在 agent(智能体)的会话工作区中运行:对 agent scope 点,桥接会将会话 `cwd` 作为 hook 进程工作目录,因此 hook 作用于用户项目树,而非服务器启动目录。
## Hook 点 → seam Decision
| Codex hook | Harness seam | 映射 |
|---|---|---|
| `SessionStart` | `agent/session-start`emit | 纯 stdout hook 的输出 → additionalContext → `agent.inject()` |
| `UserPromptSubmit` | `agent/prompt-submit`waterfall | `block`(退出码 2`PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` |
| `UserPromptSubmit` | `agent/prompt-submit`waterfall,瀑布式事件 | `block`(退出码 2`PromptDecision.block`;仅 additionalContext → 通过 `next()` 委托,再将一个单独标记源的上下文前置到下游 `additionalContexts` |
| `PreToolUse` | `tools/pre-execute`waterfall | `block``PreToolDecision.deny`(没有 `allow``ask` |
| `PostToolUse` | `tools/post-execute`waterfall | `block` → 带反馈的 `block`;仅 additionalContext → 通过 `next()` 委托再将一个单独标记源的上下文前置到下游决策Code Mode 将子调用上下文延迟到外层 `run_code` 结果 |
| `Stop` | `agent/turn-stopping`serial | 阻塞 Stop hook 通过 `steer()` 送入其原因,强制再执行一步 |
工具调用的 payload 携带真实 `tool_name`matcher 测试的相同值)与 Codex `tool_input: { command }` 形状(存在 `command` arg 时使用该值,否则使用 `''`。matcher subject 是工具名称(`PreToolUse``PostToolUse`)或会话源(`SessionStart``UserPromptSubmit``Stop` 忽略 matcher。
每个 agent scope stdin payload 都携带 `session_id``transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `null`,保留 Codex `string | null` 形状。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,也可能省略当前开启轮次。
每个 agent scope stdin payload 都携带 `session_id``transcript_path`。可用时,桥接通过 `ctx.sessionPersistence.locate(session.header)` 解析后者,否则发送 `null`,保留 Codex `string | null` 形状。查找不会创建或 flush 产物,因此第一个轮次结束检查点之前路径可能不存在,或其指向的 transcript 可能尚未包含当前未结束的轮次。
`SessionStart` 是唯一的 emit 点它会脱离运行。每条运行链都会被跟踪dispose 桥接会中止仍在运行的 hook 进程,再排空 continuation后 dispose resolve`createDetachedRuns`,位于 `dsh-hook-protocol`)。
`SessionStart` 是唯一的 emit 点,它会脱离运行。每条运行链都会被跟踪;对桥接执行 dispose(资源释放)会中止仍在运行的 hook 进程,再排空 continuation后 dispose 才会完成`createDetachedRuns`,位于 `dsh-hook-protocol`)。
## 上下文源
@@ -64,15 +64,15 @@ hook 本身会在 agent 的会话工作区中运行:对 agent scope 点,桥
#### 模型看到的内容
`SessionStart`、已接受提示词和工具后 hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将其原因添加为下一步 steering。
`SessionStart`、已接受提示词和工具后 hook 可以添加带源归因的上下文消息;阻塞 `Stop` hook 将其原因添加为下一步 steering(中途引导)
#### Token 影响
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并重发直到压缩。
hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记录,并重发直到压缩compaction
#### KV Cache 影响
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 配置项失效。
仅追加;新可见内容位于可复用请求前缀之后,不会使现有 KV-cache 条目失效。
### 已阻塞提示词或工具结果
@@ -96,5 +96,5 @@ hook 不返回上下文时没有成本。Hook 文本取决于数据,会被记
- **`PreToolUse` 只支持部分功能:** 支持阻塞,但会忽略 `additionalContext``permissionDecision: "allow"``updatedInput`。每个工具都表示为 `tool_input: { command }`,因此非 shell 工具参数不会如实公开给 hook。
- **`PostToolUse` 只支持部分功能:** 支持阻塞反馈与 JSON `additionalContext`,但不会强制执行 `{"continue": false}`,非 shell 工具参数会缩减为 `{ command }`,结构化工具输出会在 `tool_response` 中展平为文本。
- **`Stop` 只支持部分功能:** 阻塞会强制另一个模型轮次,但 `stop_hook_active` 始终为 `false``last_assistant_message` 始终为 `null`,且不会强制执行 `{"continue": false}`。因此,无条件阻塞 hook 会在每个步骤中强制 continuation除非它自我限制`TODO(stop-loop-guard)`)。
- **通用 payload 与输出字段只支持部分功能:** 每个已映射事件都报告 `transcript_path: null`、静态配置的 `model``permission_mode: "default"`,而非当前 Codex 运行时值。`systemMessage` 会被记录 + 警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 事件特定停止行为(`TODO(hook-continue-false)`)。
- **配置加载与执行只支持部分功能:** 一个进程级 `configPath` 会在加载时解析;尚未实现 Codex 的活动 user、project、session、system/managed 和 plugin 分层、信任控制与内联 `config.toml` hook 形式(`TODO(per-session-hook-config)`)。只运行同步 `command` handler忽略 `statusMessage``commandWindows` 等当前元数据,匹配 handler 串行运行,而非使用 Codex 的并发启动语义。
- **通用 payload 与输出字段只支持部分功能:** 每个已映射事件都报告 `transcript_path: null`、静态配置的 `model``permission_mode: "default"`,而非当前 Codex 运行时值。`systemMessage` 会被记录并触发警告但不呈现,`{"continue": false}` 会被记录但不会应用 Codex 事件特定停止行为(`TODO(hook-continue-false)`)。
- **配置加载与执行只支持部分功能:** 一个进程级 `configPath` 会在加载时解析;尚未实现 Codex 的活动用户层、项目层、会话层、系统/托管层和插件层、信任控制与内联 `config.toml` hook 形式(`TODO(per-session-hook-config)`)。只运行同步 `command` handler忽略 `statusMessage``commandWindows` 等当前元数据,匹配 handler 串行运行,而非使用 Codex 的并发启动语义。

View File

@@ -5,7 +5,7 @@
* @module @deepseek-ai/dsh-hooks-codex/config
*/
import type { MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
import { matcherDiagnostic, type MatcherGroup } from '@deepseek-ai/dsh-hook-protocol'
/** The five Codex hook points this bridge supports. */
export const CODEX_EVENTS = ['PreToolUse', 'PostToolUse', 'SessionStart', 'UserPromptSubmit', 'Stop'] as const
@@ -33,7 +33,10 @@ function asObject(value: unknown): Record<string, unknown> | undefined {
/**
* Parse a wrapped or bare Codex event map. Unknown events and malformed entries are ignored rather
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`.
* than failing boot; unsupported or asynchronous hooks are returned in `skipped`. Matcher fields on
* UserPromptSubmit and Stop are discarded because those events have no matcher subject. A
* matcher-bearing runnable group with an invalid regex throws a `SyntaxError`, allowing the bridge
* to reject the complete config before listener registration.
* @param raw - the parsed JSON config: a `{ hooks: … }` wrapper or the bare event map.
* @returns the runnable per-event groups plus the skipped hooks with their reasons.
*/
@@ -69,7 +72,12 @@ export function parseCodexConfig(raw: unknown): ParsedCodexConfig {
commands.push({ command: hook.command, ...timeout !== undefined ? { timeoutSec: timeout } : {} })
}
if (commands.length === 0) continue
groups.push({ ...typeof group.matcher === 'string' ? { matcher: group.matcher } : {}, hooks: commands })
const matcher = event === 'UserPromptSubmit' || event === 'Stop'
? undefined
: typeof group.matcher === 'string' ? group.matcher : undefined
const diagnostic = matcherDiagnostic(matcher, 'codex')
if (diagnostic !== undefined) throw new SyntaxError(`${diagnostic} on event ${JSON.stringify(event)}`)
groups.push({ ...matcher !== undefined ? { matcher } : {}, hooks: commands })
}
if (groups.length > 0) config[event] = groups
}

View File

@@ -39,12 +39,13 @@ function writeHooks(dir: string, hooks: unknown): void {
writeFileSync(join(dir, 'hooks.json'), JSON.stringify({ hooks }))
}
async function harness(dir: string, adapter: MockAdapter): Promise<Context> {
async function harness(dir: string, adapter: MockAdapter, beforeHooks?: (ctx: Context) => void): Promise<Context> {
const ctx = new Context()
await mountAgentLoopTestDependencies(ctx)
await ctx.plugin(AgentLoop, { agents: [] })
await ctx.plugin(LocalSubprocessService)
await ctx.plugin(LocalBashExecutor, { timeoutMs: 10_000 })
beforeHooks?.(ctx)
await ctx.plugin(HooksCodex, { configPath: join(dir, 'hooks.json'), model: 'test-model' })
ctx.llm.registerAdapter(['mock'], adapter)
return ctx
@@ -88,11 +89,11 @@ describe('hooks-codex bridge', () => {
it('a Stop hook (exit 2) forces the turn to continue with the reason as steering', async () => {
const dir = configDir()
// Block once with a marker; until the loop guard lands, an always-blocking
// hook would never let this test finish.
// Stop ignores its malformed matcher field. Block once with a marker;
// until the loop guard lands, an always-blocking hook would never finish.
const marker = join(dir, 'fired')
const cont = script(dir, 'cont.sh', `#!/usr/bin/env bash\nif [ -e "${marker}" ]; then exit 0; fi\ntouch "${marker}"\necho "keep going: address the goal" >&2\nexit 2\n`)
writeHooks(dir, { Stop: [{ hooks: [{ type: 'command', command: cont }] }] })
writeHooks(dir, { Stop: [{ matcher: '[', hooks: [{ type: 'command', command: cont }] }] })
const adapter = new MockAdapter([textResponse('first answer'), textResponse('second answer after goal')])
const ctx = await harness(dir, adapter)
@@ -151,6 +152,26 @@ describe('hooks-codex bridge', () => {
expect(adapter.requests).toHaveLength(1)
})
it('an invalid regex matcher is reported and registers no hooks', async () => {
const dir = configDir()
writeHooks(dir, {
UserPromptSubmit: [{ hooks: [{ type: 'command', command: 'exit 2' }] }],
PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 'exit 2' }] }],
})
const adapter = new MockAdapter([textResponse('ok')])
const warn = vi.fn()
const ctx = await harness(dir, adapter, (ctx) => { ctx.logger.warn = warn as never })
const agent = ctx.agentLoop.create(SessionId('invalid-codex-matcher'), { provider: 'mock', model: 'mock' })
agent.followup(createUserMessage({ content: [{ type: 'text', text: 'go' }], source: { kind: 'user' } }))
await waitForIdle(ctx, agent)
expect(adapter.requests).toHaveLength(1)
expect(events(agent).some(event => event.type === 'hook/invoked')).toBe(false)
expect(warn).toHaveBeenCalledWith(expect.stringContaining(
'invalid codex regex matcher "[" on event "PreToolUse"',
))
})
it('disposing the bridge fiber removes its listeners (HMR safety)', async () => {
const dir = configDir()
// A leaked listener would let this blocking hook veto the prompt and log an invocation; a

View File

@@ -65,4 +65,22 @@ describe('parseCodexConfig', () => {
const { config } = parseCodexConfig({ PreToolUse: [{ matcher: '^Bash$', hooks: [{ type: 'command', command: 'b.sh' }] }] })
expect(config.PreToolUse![0]!.matcher).toBe('^Bash$')
})
it('rejects an invalid regex matcher with its event name', () => {
expect(() => parseCodexConfig({
PreToolUse: [{ matcher: '[', hooks: [{ type: 'command', command: 's.sh' }] }],
})).toThrow('invalid codex regex matcher "[" on event "PreToolUse"')
})
it('discards matcher fields on events without matcher subjects before validation', () => {
const { config } = parseCodexConfig({
UserPromptSubmit: [{ matcher: '[', hooks: [{ type: 'command', command: 'prompt.sh' }] }],
Stop: [{ matcher: '(', hooks: [{ type: 'command', command: 'stop.sh' }] }],
})
expect(config).toEqual({
UserPromptSubmit: [{ hooks: [{ command: 'prompt.sh' }] }],
Stop: [{ hooks: [{ command: 'stop.sh' }] }],
})
})
})