@hasna-internal/kai-hook-protocol 0.1.1-rc.2 → 0.1.1-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -42
- package/index.js +2 -0
- package/package.json +10 -40
- package/LICENSE +0 -21
- package/README.i18n.yaml +0 -6
- package/README.zh.md +0 -44
- package/lib/index.js +0 -377
- package/lib/invariant.js +0 -97
- package/lib/types/codec.d.ts +0 -21
- package/lib/types/detached.d.ts +0 -42
- package/lib/types/events.d.ts +0 -74
- package/lib/types/index.d.ts +0 -19
- package/lib/types/invariant.d.ts +0 -13
- package/lib/types/matcher.d.ts +0 -29
- package/lib/types/merge.d.ts +0 -39
- package/lib/types/runner.d.ts +0 -62
- package/lib/types/types.d.ts +0 -132
package/README.md
CHANGED
|
@@ -1,44 +1,3 @@
|
|
|
1
1
|
# @hasna-internal/kai-hook-protocol
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
The **shared core** of the Claude Code / Codex hook wire protocol. NOT a cordis plugin — it registers nothing and injects nothing. It is a **library** of dialect-neutral primitives the two bridge plugins (`@hasna-internal/kai-hooks-claude-code`, `@hasna-internal/kai-hooks-codex`) import so neither re-implements the identical halves of the protocol.
|
|
6
|
-
|
|
7
|
-
Codex deliberately reimplements a *subset* of the Claude Code hook protocol — the same `hooks.json` matcher-group shape, the same exit-code/stdout output contract, the same command-hook execution model. The genuinely-shared parts live here; each bridge owns only what differs.
|
|
8
|
-
|
|
9
|
-
## What's shared (here) vs. per-dialect (the bridges)
|
|
10
|
-
|
|
11
|
-
| Concern | Here (`dsh-hook-protocol`) | The bridge (`dsh-hooks-claude-code` / `-codex`) |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| Matcher validation + test | `matcherDiagnostic(pattern, mode)` for parse-time diagnostics; `matchesMatcher(pattern, query, mode)` for contained runtime matching | picks its `mode` (`claude` = literal-or-regex, `codex` = always regex) and rejects a config group carrying a diagnostic |
|
|
14
|
-
| Run a hook | `runHook(bash, hook, opts, now)` — stdin payload + env via `ctx.shell`, decode | builds the per-event stdin **payload** + the dialect's **env** |
|
|
15
|
-
| Decode output | `parseHookOutput(exit, stdout, stderr)` → neutral `HookOutput` | maps the neutral `HookOutput` onto an extension-point-specific typed Decision |
|
|
16
|
-
| Merge N hooks | `mergeHookOutputs(outputs)` → most-restrictive `MergedHookOutcome` | — |
|
|
17
|
-
| Durable record | `appendHookInvoked` / `appendHookResult` (`hook/*` session events; the result's `decision`/`stderrSummary` derive from the `HookOutput` here) | calls them around each invocation |
|
|
18
|
-
| Detached-run quiescence | `createDetachedRuns()` — track fire-and-forget run chains; `drain()` aborts, then awaits them | passes `signal` to each detached `runHook`, registers `drain` as its effect disposer |
|
|
19
|
-
|
|
20
|
-
## Primitives
|
|
21
|
-
|
|
22
|
-
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`** — match-all on absent/`''`/`'*'`; `claude` mode treats a pure `[A-Za-z0-9_|]+` pattern as a literal (pipe = exact-match alternation) and anything else as a regex; `codex` mode is always an unanchored regex. Bridge parsers discard matcher fields for events without matcher subjects, then use `matcherDiagnostic` to reject an invalid consumed regex with a stable diagnostic before registering any hooks. The runtime predicate still contains an invalid pattern as a non-match, so a direct library caller cannot throw into the agent loop.
|
|
23
|
-
- **`runHook(bash, hook, options, now)`** — require and forward the caller-owned `options.signal`, serialize `options.payload` to the hook's stdin (with a trailing newline iff `options.trailingNewline`), merge `options.env` after the executor's credential scrub (the `dsh-shell` trusted-plugin API), honor the hook's `timeoutSec` (else `options.defaultTimeoutMs` — the bridge owns the default, its config defaulting to the lib's `DEFAULT_HOOK_TIMEOUT_MS` 10-minute reference), and decode the result (threading `options.expectedEventName` to the codec). Cancellation therefore reaches the executor's process-group kill and join boundary. Never throws: an executor rejection (infra fault) becomes a `HookOutput` with `exitCode: undefined` (a non-blocking error). `now` is injected for testable durations.
|
|
24
|
-
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** decodes exit status and structured stdout. Exit 2 blocks with stderr; other failures are non-blocking. A matching hook-specific permission decision overrides the legacy top-level decision; mismatched or missing event discriminators suppress only event-specific fields. Top-level fields remain event-agnostic, and successful non-JSON output is left to the bridge.
|
|
25
|
-
- **`mergeHookOutputs(outputs)`** — fold the results of every hook that matched one point: permission precedence **deny > ask > allow**, halt sticky on the first `continue:false`, block reasons joined with `\n\n`, `additionalContext`/`systemMessages` accumulated in order.
|
|
26
|
-
- **`createDetachedRuns()`** — quiescence tracking for the emit-shaped points, which run detached (no extension point awaits them). The bridge tracks each run chain — the hook run PLUS its continuation — and registers `drain()` as its effect disposer: drain fires the tracker's abort `signal` (so a still-running hook process is killed via `runHook`, not awaited out to its timeout), then resolves once every tracked chain has settled. `fiber.dispose()` resolving therefore means no detached hook work is left to fire into a disposed context ([defensive patterns](../../../docs/defensive-patterns.md): dispose must reach quiescence).
|
|
27
|
-
|
|
28
|
-
## `hook/*` session events
|
|
29
|
-
|
|
30
|
-
Declaration-merged into `SessionEventMap` (log-only, like `compaction/*` — NOT a `SurfaceEventType`, no `surfaceOp`): `hook/invoked` (a hook command ran) and `hook/result` (its outcome, paired by `handlerId`, with `appendHookResult` owning the decision rule). Payloads and per-event JSDoc are in the generated [persistence log event catalog](../../../docs/persistence-catalog.md); `stderrSummary` is truncated to the record's `stderrSummaryMaxChars` (the bridge's config, reference default `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500; omitted when empty).
|
|
31
|
-
|
|
32
|
-
Hook invocation/result records must sit inside an open turn. `UserPromptSubmit`, `PreToolUse`, `PostToolUse`, and `Stop` satisfy that owner-defined relation by construction. `SessionStart` runs before turn 1 and gets no `hook/*` record; its allowed context remains pending in the inbox until a waking delivery opens a turn — see the hooks Agent Note.
|
|
33
|
-
|
|
34
|
-
## Model Experience
|
|
35
|
-
|
|
36
|
-
Indirectly, through `dsh-hooks-claude-code` and `dsh-hooks-codex`, which can turn parsed hook output into prompt context, blocked outcomes, or continuation feedback.
|
|
37
|
-
|
|
38
|
-
#### KV Cache effect
|
|
39
|
-
|
|
40
|
-
No direct invalidation; the named consumer owns any request-prefix changes.
|
|
41
|
-
|
|
42
|
-
## Known Limitations and Deferred Work
|
|
43
|
-
|
|
44
|
-
- **`HookOutput.updatedInput` is parsed but not honored** — input rewrite is a deferred consistency-design problem ([the pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.md)); a bridge logs + warns when a hook sets it. See `src/types.ts` for the full contracts.
|
|
3
|
+
This package has been retired by its owner. This release is an empty placeholder with no dependencies or supported functionality. Do not use it.
|
package/index.js
ADDED
package/package.json
CHANGED
|
@@ -1,46 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hasna-internal/kai-hook-protocol",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"access": "public"
|
|
7
|
-
},
|
|
8
|
-
"repository": {
|
|
9
|
-
"type": "git",
|
|
10
|
-
"url": "git+https://github.com/deepseek-ai/deepseek-harness.git",
|
|
11
|
-
"directory": "packages/hooks/hook-protocol"
|
|
12
|
-
},
|
|
13
|
-
"type": "module",
|
|
14
|
-
"main": "lib/index.js",
|
|
15
|
-
"types": "lib/types/index.d.ts",
|
|
16
|
-
"exports": {
|
|
17
|
-
".": {
|
|
18
|
-
"types": "./lib/types/index.d.ts",
|
|
19
|
-
"default": "./lib/index.js"
|
|
20
|
-
},
|
|
21
|
-
"./invariant": {
|
|
22
|
-
"types": "./lib/types/invariant.d.ts",
|
|
23
|
-
"default": "./lib/invariant.js"
|
|
24
|
-
},
|
|
25
|
-
"./src/*": "./src/*",
|
|
26
|
-
"./package.json": "./package.json"
|
|
27
|
-
},
|
|
3
|
+
"version": "0.1.1-rc.3",
|
|
4
|
+
"description": "Retired package. Empty placeholder with no dependencies.",
|
|
5
|
+
"main": "index.js",
|
|
28
6
|
"files": [
|
|
29
|
-
"
|
|
30
|
-
"
|
|
31
|
-
"lib/types/**/*.d.ts"
|
|
7
|
+
"index.js",
|
|
8
|
+
"README.md"
|
|
32
9
|
],
|
|
33
10
|
"license": "MIT",
|
|
34
|
-
"
|
|
35
|
-
|
|
36
|
-
"
|
|
37
|
-
"
|
|
38
|
-
"@deepseek-ai/cordis": "^4.0.1"
|
|
39
|
-
},
|
|
40
|
-
"devDependencies": {
|
|
41
|
-
"@hasna-internal/kai-invariants": "^0.1.1-rc.2",
|
|
42
|
-
"@hasna-internal/kai-shell": "^0.1.1-rc.2",
|
|
43
|
-
"@hasna-internal/kai-session": "^0.1.1-rc.2",
|
|
44
|
-
"@deepseek-ai/cordis": "^4.0.1"
|
|
11
|
+
"deprecated": "Retired by the package owner. Empty placeholder; do not use.",
|
|
12
|
+
"publishConfig": {
|
|
13
|
+
"registry": "https://registry.npmjs.org/",
|
|
14
|
+
"access": "public"
|
|
45
15
|
}
|
|
46
|
-
}
|
|
16
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
MIT License
|
|
2
|
-
|
|
3
|
-
Copyright (c) 2026 DeepSeek
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
-
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
-
in the Software without restriction, including without limitation the rights
|
|
8
|
-
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
-
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
-
furnished to do so, subject to the following conditions:
|
|
11
|
-
|
|
12
|
-
The above copyright notice and this permission notice shall be included in all
|
|
13
|
-
copies or substantial portions of the Software.
|
|
14
|
-
|
|
15
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
-
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
-
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
-
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
-
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
-
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
-
SOFTWARE.
|
package/README.i18n.yaml
DELETED
|
@@ -1,6 +0,0 @@
|
|
|
1
|
-
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
|
2
|
-
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
|
3
|
-
# after editing either side, bring the other along and re-record with:
|
|
4
|
-
# pnpm run verify-translation-pairing --write packages/hooks/hook-protocol/README.md
|
|
5
|
-
README.md: cebf93a04caef67a82549663018c10aac4cd535d
|
|
6
|
-
README.zh.md: bc02e90aee14491ee62c1954a459cb70832a408c
|
package/README.zh.md
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
# @hasna-internal/kai-hook-protocol
|
|
2
|
-
|
|
3
|
-
[English](README.md) | 中文
|
|
4
|
-
|
|
5
|
-
Claude Code/Codex hook 协议格式(wire format)的**共享核心**。它不是 Cordis 插件:不注册也不注入任何内容。它是一个**库**,提供两个桥接插件(`@hasna-internal/kai-hooks-claude-code`、`@hasna-internal/kai-hooks-codex`)导入的方言无关原语,使两者都无需重复实现协议中相同的部分。
|
|
6
|
-
|
|
7
|
-
Codex 有意重新实现了 Claude Code hook 协议的一个*子集*,包括相同的 `hooks.json` matcher group 结构、相同的退出码/stdout 输出约定以及相同的 command hook 执行模式。真正共享的部分位于此处;每个桥接只负责不同的部分。
|
|
8
|
-
|
|
9
|
-
## 共享内容(此处)与各方言内容(桥接)
|
|
10
|
-
|
|
11
|
-
| 关注点 | 此处(`dsh-hook-protocol`) | 桥接(`dsh-hooks-claude-code` / `-codex`) |
|
|
12
|
-
|---|---|---|
|
|
13
|
-
| Matcher 校验与匹配判断 | `matcherDiagnostic(pattern, mode)` 用于解析时诊断;`matchesMatcher(pattern, query, mode)` 用于隔离的运行时匹配 | 选择自身的 `mode`(`claude` = 字面量或正则,`codex` = 始终使用正则),并拒绝带有诊断的配置组 |
|
|
14
|
-
| 运行 hook | `runHook(bash, hook, opts, now)`:通过 `ctx.shell` 提供 stdin payload + env,再解码 | 构造每个事件的 stdin **payload** + 该方言的 **env** |
|
|
15
|
-
| 解码输出 | `parseHookOutput(exit, stdout, stderr)` → 中性 `HookOutput` | 将中性 `HookOutput` 映射到扩展点特定的类型化 Decision |
|
|
16
|
-
| 合并 N 个 hook | `mergeHookOutputs(outputs)` → 最严格的 `MergedHookOutcome` | (无) |
|
|
17
|
-
| 持久记录 | `appendHookInvoked` / `appendHookResult`(`hook/*` 会话事件;结果的 `decision`/`stderrSummary` 从此处的 `HookOutput` 派生) | 在每次调用前后调用它们 |
|
|
18
|
-
| 脱离运行的完全停稳 | `createDetachedRuns()`:跟踪触发后不等待的运行链;`drain()` 先 abort,再等待它们 | 将 `signal` 传给每个脱离的 `runHook`,并将 `drain` 注册为 effect disposer |
|
|
19
|
-
|
|
20
|
-
## 原语
|
|
21
|
-
|
|
22
|
-
- **`matcherDiagnostic(matcher, mode)` / `matchesMatcher(matcher, query, mode)`**:缺失、`''` 或 `'*'` 时匹配全部;`claude` mode 将纯 `[A-Za-z0-9_|]+` pattern 视为字面量(管道符 = 精确匹配交替),其他 pattern 视为正则;`codex` mode 始终使用未锚定正则。桥接解析器会丢弃没有 matcher 匹配对象的事件所带的 matcher 字段,再用 `matcherDiagnostic` 拒绝事件实际使用的无效正则,并在注册任何钩子之前给出稳定诊断。运行时谓词仍会将无效 pattern 隔离为不匹配,因此直接调用本库不会向 agent loop(智能体循环)抛异常。
|
|
23
|
-
- **`runHook(bash, hook, options, now)`**:要求并转发调用方拥有的 `options.signal`,将 `options.payload` 序列化到 hook stdin(当且仅当 `options.trailingNewline` 时添加尾随换行符),在执行器凭证清理后合并 `options.env`(`dsh-shell` 受信任插件接口),遵循 hook 的 `timeoutSec`(否则使用 `options.defaultTimeoutMs`;默认值属于桥接,其配置默认为 lib 的 `DEFAULT_HOOK_TIMEOUT_MS` 10 分钟参考值),再解码结果(将 `options.expectedEventName` 传递给 codec)。因此取消会到达执行器的进程组终止与 join 边界。它绝不抛出异常:执行器拒绝(基础设施故障)会变为 `HookOutput`,其 `exitCode: undefined`(非阻塞错误)。`now` 会被注入,以便测试持续时间。
|
|
24
|
-
- **`parseHookOutput(exitCode, stdout, stderr, expectedEventName?)`** 解码退出状态与结构化 stdout。退出码为 2 时,会以 stderr 内容阻止执行;其他失败不阻塞。匹配的 hook 特定权限决策会覆盖遗留顶层决策;事件判别字段不匹配或缺失只会抑制事件特定字段。顶层字段仍与事件无关,成功但非 JSON 的输出会留给桥接处理。
|
|
25
|
-
- **`mergeHookOutputs(outputs)`**:折叠在一个点上匹配的每个 hook 结果:权限优先级为 **deny > ask > allow**,从首个 `continue:false` 起,halt 状态保持不变,阻塞原因用 `\n\n` 连接,`additionalContext`/`systemMessages` 按顺序累积。
|
|
26
|
-
- **`createDetachedRuns()`**:跟踪以 emit 形式脱离运行的点是否完全停稳(没有扩展点等待它们)。桥接会跟踪每条运行链,包括 hook 运行及其 continuation,并将 `drain()` 注册为 effect disposer。drain 会触发 tracker 的 abort `signal`(因此仍在运行的 hook 进程会通过 `runHook` 终止,而不是等待到超时),随后在所有已跟踪链结算后 resolve。因此 `fiber.dispose()` resolve 时,不会遗留任何可能作用于已 dispose(资源释放)的上下文的脱离 hook 工作(见 [防御模式](../../../docs/defensive-patterns.zh.md):dispose 必须达到完全停稳)。
|
|
27
|
-
|
|
28
|
-
## `hook/*` 会话事件
|
|
29
|
-
|
|
30
|
-
通过 declaration merging 合并到 `SessionEventMap`(仅日志,与 `compaction/*` 相同;不是 `SurfaceEventType`,没有 `surfaceOp`):`hook/invoked`(hook 命令已运行)与 `hook/result`(其结果,按 `handlerId` 配对,决策规则由 `appendHookResult` 负责)。Payload 与每事件 JSDoc 位于生成的 [持久化日志事件目录](../../../docs/persistence-catalog.zh.md);`stderrSummary` 会截断到记录的 `stderrSummaryMaxChars`(桥接配置,参考默认值 `DEFAULT_STDERR_SUMMARY_MAX_CHARS` = 500;为空时省略)。
|
|
31
|
-
|
|
32
|
-
Hook 调用/结果记录必须位于一个尚未结束的轮次内。`UserPromptSubmit`、`PreToolUse`、`PostToolUse` 与 `Stop` 按构造满足这条由所有者定义的关系。`SessionStart` 在轮次 1 之前运行,因此没有 `hook/*` 记录;其获准的上下文会在 inbox 中保持待处理,直到唤醒交付打开一个轮次,详见 hooks Agent Note。
|
|
33
|
-
|
|
34
|
-
## 模型体验
|
|
35
|
-
|
|
36
|
-
通过 `dsh-hooks-claude-code` 与 `dsh-hooks-codex` 间接影响;它们可以将解析后 hook 输出转为提示词上下文、已阻塞结果或 continuation 反馈。
|
|
37
|
-
|
|
38
|
-
#### KV Cache 影响
|
|
39
|
-
|
|
40
|
-
不会直接失效;请求前缀变更由上述消费方负责。
|
|
41
|
-
|
|
42
|
-
## 已知限制与暂缓事项
|
|
43
|
-
|
|
44
|
-
- **`HookOutput.updatedInput` 会被解析但不会应用**:输入改写是已暂缓的一致性设计问题(见 [pre-tool-input-rewrite Agent Note](../../../.agents/notes/proposed/feature/2026-06-30-pre-tool-input-rewrite.zh.md));当 hook 设置它时,桥接会记录 + 警告。完整约定见 `src/types.ts`。
|
package/lib/index.js
DELETED
|
@@ -1,377 +0,0 @@
|
|
|
1
|
-
//#region lib/types/matcher.js
|
|
2
|
-
/**
|
|
3
|
-
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
|
|
4
|
-
* pipe patterns as literal alternatives and other patterns as regex; Codex
|
|
5
|
-
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
|
|
6
|
-
* `*` match all. Runtime matching contains invalid regexes as non-matches;
|
|
7
|
-
* config parsers use {@link matcherDiagnostic} to reject them with a diagnostic.
|
|
8
|
-
* @module @hasna-internal/kai-hook-protocol/matcher
|
|
9
|
-
*/
|
|
10
|
-
/** True for an absent / empty / `'*'` pattern — the match-all sentinels. */
|
|
11
|
-
function isMatchAll(matcher) {
|
|
12
|
-
return matcher === void 0 || matcher === "" || matcher === "*";
|
|
13
|
-
}
|
|
14
|
-
/** A Claude-literal pattern is purely word chars + `|` (the regex-vs-literal discriminator). */
|
|
15
|
-
const CLAUDE_LITERAL = /^[A-Za-z0-9_|]+$/;
|
|
16
|
-
/** Compile an unanchored matcher regex; invalid patterns return `undefined`. */
|
|
17
|
-
function compileRegex(pattern) {
|
|
18
|
-
try {
|
|
19
|
-
return new RegExp(pattern);
|
|
20
|
-
} catch (_syntaxError) {
|
|
21
|
-
return;
|
|
22
|
-
}
|
|
23
|
-
}
|
|
24
|
-
/**
|
|
25
|
-
* Validate one matcher before a bridge accepts its config group.
|
|
26
|
-
* @param matcher - configured pattern; match-all sentinels are valid.
|
|
27
|
-
* @param mode - dialect deciding whether a word-and-pipe pattern is literal.
|
|
28
|
-
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
|
|
29
|
-
*/
|
|
30
|
-
function matcherDiagnostic(matcher, mode) {
|
|
31
|
-
if (isMatchAll(matcher)) return void 0;
|
|
32
|
-
const pattern = matcher;
|
|
33
|
-
if (mode === "claude-code" && CLAUDE_LITERAL.test(pattern)) return void 0;
|
|
34
|
-
return compileRegex(pattern) === void 0 ? `invalid ${mode} regex matcher ${JSON.stringify(pattern)}` : void 0;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Whether `matcher` selects `query` under the given dialect. Claude literal
|
|
38
|
-
* patterns exact-match pipe-separated alternatives; all other patterns are
|
|
39
|
-
* unanchored regexes. Invalid regexes return `false` rather than throwing;
|
|
40
|
-
* bridge config parsers surface them through {@link matcherDiagnostic} before use.
|
|
41
|
-
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
|
|
42
|
-
* @param query - the candidate value (a tool name, a session source, …).
|
|
43
|
-
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
|
|
44
|
-
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid
|
|
45
|
-
* regex.
|
|
46
|
-
*/
|
|
47
|
-
function matchesMatcher(matcher, query, mode) {
|
|
48
|
-
if (isMatchAll(matcher)) return true;
|
|
49
|
-
const pattern = matcher;
|
|
50
|
-
if (mode === "claude-code" && CLAUDE_LITERAL.test(pattern)) return pattern.split("|").includes(query);
|
|
51
|
-
return compileRegex(pattern)?.test(query) ?? false;
|
|
52
|
-
}
|
|
53
|
-
//#endregion
|
|
54
|
-
//#region lib/types/codec.js
|
|
55
|
-
/**
|
|
56
|
-
* Decode hook process outcomes for both dialects. Exit 0 may carry structured
|
|
57
|
-
* JSON or plain stdout; exit 2 blocks with stderr as the reason; every other
|
|
58
|
-
* exit is a non-blocking error. Bridges decide which recognized fields apply.
|
|
59
|
-
* @module @hasna-internal/kai-hook-protocol/codec
|
|
60
|
-
*/
|
|
61
|
-
/** The exit code a hook uses to signal a blocking error (stderr → model). */
|
|
62
|
-
const BLOCKING_EXIT_CODE = 2;
|
|
63
|
-
/** Read a string field from a parsed object, or `undefined` if absent/wrong type. */
|
|
64
|
-
function str(obj, key) {
|
|
65
|
-
const v = obj[key];
|
|
66
|
-
return typeof v === "string" ? v : void 0;
|
|
67
|
-
}
|
|
68
|
-
/** Read a boolean field, or `undefined` if absent/wrong type. */
|
|
69
|
-
function bool(obj, key) {
|
|
70
|
-
const v = obj[key];
|
|
71
|
-
return typeof v === "boolean" ? v : void 0;
|
|
72
|
-
}
|
|
73
|
-
/** A plain (non-null, non-array) object, or `undefined`. */
|
|
74
|
-
function obj(value) {
|
|
75
|
-
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : void 0;
|
|
76
|
-
}
|
|
77
|
-
/**
|
|
78
|
-
* The legacy TOP-LEVEL `decision` is only `approve`/`block` in both reference
|
|
79
|
-
* schemas — `allow`/`deny`/`ask` are reserved for `hookSpecificOutput.
|
|
80
|
-
* permissionDecision`. So an out-of-band `{"decision":"deny"}` is invalid and
|
|
81
|
-
* ignored here (it must not become a real blocking decision).
|
|
82
|
-
*/
|
|
83
|
-
function topLevelDecisionOf(value) {
|
|
84
|
-
return value === "approve" || value === "block" ? value : void 0;
|
|
85
|
-
}
|
|
86
|
-
/** A `hookSpecificOutput.permissionDecision` is `allow`/`deny`/`ask` only. */
|
|
87
|
-
function permissionDecisionOf(value) {
|
|
88
|
-
return value === "allow" || value === "deny" || value === "ask" ? value : void 0;
|
|
89
|
-
}
|
|
90
|
-
/**
|
|
91
|
-
* Decode process output into a dialect-neutral hook outcome. This function is
|
|
92
|
-
* total: malformed JSON remains plain stdout. When `expectedEventName` is set,
|
|
93
|
-
* a missing or different `hookSpecificOutput.hookEventName` discards only its
|
|
94
|
-
* event-scoped fields; top-level fields and the claimed discriminator remain.
|
|
95
|
-
* Omitting the guard applies the block as-is.
|
|
96
|
-
* @param exitCode - process exit, or `undefined` when spawn failed.
|
|
97
|
-
* @param stdout - output parsed as structured JSON only on exit 0.
|
|
98
|
-
* @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
|
|
99
|
-
* @param expectedEventName - firing event used to guard hook-specific fields; omit to disable the guard.
|
|
100
|
-
* @returns the dialect-neutral decoded outcome.
|
|
101
|
-
*/
|
|
102
|
-
function parseHookOutput(exitCode, stdout, stderr, expectedEventName) {
|
|
103
|
-
const trimmedErr = stderr.trim();
|
|
104
|
-
const trimmedOut = stdout.trim();
|
|
105
|
-
const output = {
|
|
106
|
-
exitCode,
|
|
107
|
-
stderr: trimmedErr,
|
|
108
|
-
stdout: trimmedOut
|
|
109
|
-
};
|
|
110
|
-
if (exitCode === BLOCKING_EXIT_CODE) {
|
|
111
|
-
output.decision = "block";
|
|
112
|
-
if (trimmedErr.length > 0) output.reason = trimmedErr;
|
|
113
|
-
}
|
|
114
|
-
if (exitCode === 0) {
|
|
115
|
-
if (trimmedOut.startsWith("{")) {
|
|
116
|
-
let parsed;
|
|
117
|
-
try {
|
|
118
|
-
parsed = obj(JSON.parse(trimmedOut));
|
|
119
|
-
} catch {
|
|
120
|
-
parsed = void 0;
|
|
121
|
-
}
|
|
122
|
-
if (parsed) applyStructured(output, parsed, expectedEventName);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
return output;
|
|
126
|
-
}
|
|
127
|
-
/**
|
|
128
|
-
* Fold a parsed structured-stdout object into `output` (mutates in place).
|
|
129
|
-
* `expectedEventName` (the firing event) gates the per-event `hookSpecificOutput`
|
|
130
|
-
* block: a block whose `hookEventName` names a different event — OR omits it — has
|
|
131
|
-
* its event-scoped fields discarded (any present `hookEventName` is still recorded).
|
|
132
|
-
*/
|
|
133
|
-
function applyStructured(output, parsed, expectedEventName) {
|
|
134
|
-
const cont = bool(parsed, "continue");
|
|
135
|
-
if (cont !== void 0) output.continue = cont;
|
|
136
|
-
const stopReason = str(parsed, "stopReason");
|
|
137
|
-
if (stopReason !== void 0) output.stopReason = stopReason;
|
|
138
|
-
const sysMsg = str(parsed, "systemMessage");
|
|
139
|
-
if (sysMsg !== void 0) output.systemMessage = sysMsg;
|
|
140
|
-
const topDecision = topLevelDecisionOf(str(parsed, "decision"));
|
|
141
|
-
if (topDecision !== void 0) output.decision = topDecision;
|
|
142
|
-
const topReason = str(parsed, "reason");
|
|
143
|
-
if (topReason !== void 0) output.reason = topReason;
|
|
144
|
-
const hso = obj(parsed.hookSpecificOutput);
|
|
145
|
-
if (hso) {
|
|
146
|
-
const eventName = str(hso, "hookEventName");
|
|
147
|
-
if (eventName !== void 0) output.hookEventName = eventName;
|
|
148
|
-
if (expectedEventName !== void 0 && eventName !== expectedEventName) return;
|
|
149
|
-
const permission = permissionDecisionOf(str(hso, "permissionDecision"));
|
|
150
|
-
if (permission !== void 0) output.decision = permission;
|
|
151
|
-
const permissionReason = str(hso, "permissionDecisionReason");
|
|
152
|
-
if (permissionReason !== void 0) output.reason = permissionReason;
|
|
153
|
-
const addCtx = str(hso, "additionalContext");
|
|
154
|
-
if (addCtx !== void 0) output.additionalContext = addCtx;
|
|
155
|
-
const updated = obj(hso.updatedInput);
|
|
156
|
-
if (updated !== void 0) output.updatedInput = updated;
|
|
157
|
-
}
|
|
158
|
-
}
|
|
159
|
-
//#endregion
|
|
160
|
-
//#region lib/types/runner.js
|
|
161
|
-
/**
|
|
162
|
-
* Execute command hooks through `ctx.shell`, using its credential scrub,
|
|
163
|
-
* process-group cancellation, and timeout machinery. The bridge supplies the
|
|
164
|
-
* trusted stdin payload and dialect environment, then this module decodes the
|
|
165
|
-
* captured outcome.
|
|
166
|
-
* @module @hasna-internal/kai-hook-protocol/runner
|
|
167
|
-
*/
|
|
168
|
-
/**
|
|
169
|
-
* The reference default per-hook timeout, in ms (10 minutes) — the value both
|
|
170
|
-
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
|
|
171
|
-
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
|
|
172
|
-
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
|
|
173
|
-
* override API.
|
|
174
|
-
*/
|
|
175
|
-
const DEFAULT_HOOK_TIMEOUT_MS = 6e5;
|
|
176
|
-
/**
|
|
177
|
-
* Run `hook` with serialized stdin and decode its outcome. A hook-specific
|
|
178
|
-
* timeout in seconds overrides the default; trusted environment entries merge
|
|
179
|
-
* after the executor scrub. Infrastructure rejection becomes an outcome with
|
|
180
|
-
* no exit code, so this function never throws or crashes the calling turn.
|
|
181
|
-
* @param bash - The executor service the command runs through.
|
|
182
|
-
* @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.
|
|
183
|
-
* @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.
|
|
184
|
-
* @param now - millisecond clock used for the reported duration.
|
|
185
|
-
* @returns the decoded output plus the run's wall-clock duration.
|
|
186
|
-
*/
|
|
187
|
-
async function runHook(bash, hook, options, now) {
|
|
188
|
-
const started = now();
|
|
189
|
-
const timeoutMs = hook.timeoutSec !== void 0 ? hook.timeoutSec * 1e3 : options.defaultTimeoutMs;
|
|
190
|
-
const stdin = JSON.stringify(options.payload) + (options.trailingNewline ? "\n" : "");
|
|
191
|
-
const request = {
|
|
192
|
-
command: hook.command,
|
|
193
|
-
timeoutMs,
|
|
194
|
-
stdin,
|
|
195
|
-
signal: options.signal,
|
|
196
|
-
...options.cwd !== void 0 ? { workdir: options.cwd } : {},
|
|
197
|
-
...options.env !== void 0 ? { env: options.env } : {}
|
|
198
|
-
};
|
|
199
|
-
try {
|
|
200
|
-
const result = await bash.run(bash.resolve(request));
|
|
201
|
-
return {
|
|
202
|
-
output: parseHookOutput(result.exitCode ?? void 0, result.stdout.text, result.stderr.text, options.expectedEventName),
|
|
203
|
-
durationMs: now() - started
|
|
204
|
-
};
|
|
205
|
-
} catch (error) {
|
|
206
|
-
return {
|
|
207
|
-
output: parseHookOutput(void 0, "", error instanceof Error ? error.message : String(error)),
|
|
208
|
-
durationMs: now() - started
|
|
209
|
-
};
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
//#endregion
|
|
213
|
-
//#region lib/types/merge.js
|
|
214
|
-
/**
|
|
215
|
-
* Merge matched hooks into one most-restrictive outcome. Permission precedence
|
|
216
|
-
* is `deny > ask > allow`; the first `continue:false` stop is sticky; reasons
|
|
217
|
-
* for the winning rank are joined; and context and system messages accumulate
|
|
218
|
-
* in hook order.
|
|
219
|
-
* @module @hasna-internal/kai-hook-protocol/merge
|
|
220
|
-
*/
|
|
221
|
-
/** Rank a single hook's decision for the deny>ask>allow precedence (higher = stricter). */
|
|
222
|
-
function rank(decision) {
|
|
223
|
-
switch (decision) {
|
|
224
|
-
case "deny":
|
|
225
|
-
case "block": return 3;
|
|
226
|
-
case "ask": return 2;
|
|
227
|
-
case "approve":
|
|
228
|
-
case "allow": return 1;
|
|
229
|
-
default: return 0;
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
/** Collapse a ranked decision back to the merged enum. */
|
|
233
|
-
function decisionForRank(maxRank) {
|
|
234
|
-
switch (maxRank) {
|
|
235
|
-
case 3: return "deny";
|
|
236
|
-
case 2: return "ask";
|
|
237
|
-
case 1: return "allow";
|
|
238
|
-
default: return "none";
|
|
239
|
-
}
|
|
240
|
-
}
|
|
241
|
-
/**
|
|
242
|
-
* Fold `outputs` (the results of every hook that matched a point, in hook order)
|
|
243
|
-
* into one {@link MergedHookOutcome} by the precedence rules above. An empty list
|
|
244
|
-
* yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
|
|
245
|
-
* caller treats that as "no hook had anything to say".
|
|
246
|
-
* @param outputs - every matched hook's decoded output, in hook order.
|
|
247
|
-
* @returns the single folded outcome the bridge maps onto its extension point.
|
|
248
|
-
*/
|
|
249
|
-
function mergeHookOutputs(outputs) {
|
|
250
|
-
let maxRank = 0;
|
|
251
|
-
const reasonsByRank = /* @__PURE__ */ new Map();
|
|
252
|
-
let stop = false;
|
|
253
|
-
let stopReason;
|
|
254
|
-
const additionalContext = [];
|
|
255
|
-
const systemMessages = [];
|
|
256
|
-
for (const out of outputs) {
|
|
257
|
-
const r = rank(out.decision);
|
|
258
|
-
if (r > maxRank) maxRank = r;
|
|
259
|
-
if ((r === 3 || r === 2) && out.reason !== void 0 && out.reason.length > 0) {
|
|
260
|
-
const list = reasonsByRank.get(r) ?? [];
|
|
261
|
-
list.push(out.reason);
|
|
262
|
-
reasonsByRank.set(r, list);
|
|
263
|
-
}
|
|
264
|
-
if (out.continue === false && !stop) {
|
|
265
|
-
stop = true;
|
|
266
|
-
if (out.stopReason !== void 0) stopReason = out.stopReason;
|
|
267
|
-
}
|
|
268
|
-
if (out.additionalContext !== void 0 && out.additionalContext.length > 0) additionalContext.push(out.additionalContext);
|
|
269
|
-
if (out.systemMessage !== void 0 && out.systemMessage.length > 0) systemMessages.push(out.systemMessage);
|
|
270
|
-
}
|
|
271
|
-
const reasons = reasonsByRank.get(maxRank) ?? [];
|
|
272
|
-
return {
|
|
273
|
-
decision: decisionForRank(maxRank),
|
|
274
|
-
...reasons.length > 0 ? { reason: reasons.join("\n\n") } : {},
|
|
275
|
-
stop,
|
|
276
|
-
...stopReason !== void 0 ? { stopReason } : {},
|
|
277
|
-
additionalContext,
|
|
278
|
-
systemMessages
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
//#endregion
|
|
282
|
-
//#region lib/types/events.js
|
|
283
|
-
/**
|
|
284
|
-
* Append helpers for durable, log-only hook events. They carry no surface
|
|
285
|
-
* intent and must remain turn-enclosed and invoked/result paired. Mid-turn hook
|
|
286
|
-
* points satisfy that boundary; SessionStart records injected context instead
|
|
287
|
-
* and does not append `hook/*` outside a turn.
|
|
288
|
-
* @module @hasna-internal/kai-hook-protocol/events
|
|
289
|
-
*/
|
|
290
|
-
/**
|
|
291
|
-
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
|
|
292
|
-
* (both bridges' config default). It lives here, once, next to the truncation
|
|
293
|
-
* rule it bounds, so the bridges cannot drift apart on the shared event's
|
|
294
|
-
* default cap.
|
|
295
|
-
*/
|
|
296
|
-
const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500;
|
|
297
|
-
/**
|
|
298
|
-
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
|
299
|
-
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
|
300
|
-
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
|
301
|
-
* the config default and passes it in.
|
|
302
|
-
* @param stderr - the hook's raw captured stderr.
|
|
303
|
-
* @param maxChars - the character cap for the summary (the bridge's config value).
|
|
304
|
-
* @returns the trimmed, capped summary, or `undefined` when stderr is blank.
|
|
305
|
-
*/
|
|
306
|
-
function summarizeStderr(stderr, maxChars) {
|
|
307
|
-
const t = stderr.trim();
|
|
308
|
-
if (t.length === 0) return void 0;
|
|
309
|
-
return t.length > maxChars ? t.slice(0, maxChars) + "…" : t;
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Append a `hook/invoked` event naming the handler and hook point to `session`.
|
|
313
|
-
* @param session - the session whose open turn records the event.
|
|
314
|
-
* @param invocation - the invocation identity; an absent `matcher` is omitted from the payload.
|
|
315
|
-
*/
|
|
316
|
-
function appendHookInvoked(session, invocation) {
|
|
317
|
-
session.append("hook/invoked", {
|
|
318
|
-
turn: invocation.turn,
|
|
319
|
-
point: invocation.point,
|
|
320
|
-
dialect: invocation.dialect,
|
|
321
|
-
handlerId: invocation.handlerId,
|
|
322
|
-
...invocation.matcher !== void 0 ? { matcher: invocation.matcher } : {}
|
|
323
|
-
});
|
|
324
|
-
}
|
|
325
|
-
/**
|
|
326
|
-
* Append the durable result paired with `hook/invoked`. The recorded decision
|
|
327
|
-
* is the parsed decision, then `stop` for `continue:false`, else `pass`; stderr
|
|
328
|
-
* is trimmed and capped, and an absent process exit stays omitted.
|
|
329
|
-
* @param session - the session whose open turn records the event.
|
|
330
|
-
* @param record - the outcome to record: the decoded output plus the summary cap and duration.
|
|
331
|
-
*/
|
|
332
|
-
function appendHookResult(session, record) {
|
|
333
|
-
const { output } = record;
|
|
334
|
-
const stderrSummary = summarizeStderr(output.stderr, record.stderrSummaryMaxChars);
|
|
335
|
-
session.append("hook/result", {
|
|
336
|
-
turn: record.turn,
|
|
337
|
-
point: record.point,
|
|
338
|
-
handlerId: record.handlerId,
|
|
339
|
-
decision: output.decision ?? (output.continue === false ? "stop" : "pass"),
|
|
340
|
-
...output.exitCode !== void 0 ? { exitCode: output.exitCode } : {},
|
|
341
|
-
...stderrSummary !== void 0 ? { stderrSummary } : {},
|
|
342
|
-
durationMs: record.durationMs
|
|
343
|
-
});
|
|
344
|
-
}
|
|
345
|
-
//#endregion
|
|
346
|
-
//#region lib/types/detached.js
|
|
347
|
-
/**
|
|
348
|
-
* Quiescence tracking for emit-shaped hook runs that no extension point awaits. Bridges
|
|
349
|
-
* track the run plus its continuation, pass the tracker signal into execution,
|
|
350
|
-
* and drain on disposal so no process or late callback outlives the fiber.
|
|
351
|
-
* @module @hasna-internal/kai-hook-protocol/detached
|
|
352
|
-
*/
|
|
353
|
-
/**
|
|
354
|
-
* Create a {@link DetachedRuns} tracker (one per bridge `apply()`); settled
|
|
355
|
-
* runs are pruned so a long-lived session does not accumulate them.
|
|
356
|
-
* @returns the tracker.
|
|
357
|
-
*/
|
|
358
|
-
function createDetachedRuns() {
|
|
359
|
-
const inflight = /* @__PURE__ */ new Set();
|
|
360
|
-
const controller = new AbortController();
|
|
361
|
-
return {
|
|
362
|
-
signal: controller.signal,
|
|
363
|
-
track(run) {
|
|
364
|
-
inflight.add(run);
|
|
365
|
-
const settled = () => {
|
|
366
|
-
inflight.delete(run);
|
|
367
|
-
};
|
|
368
|
-
run.then(settled, settled);
|
|
369
|
-
},
|
|
370
|
-
async drain() {
|
|
371
|
-
controller.abort(/* @__PURE__ */ new Error("hook bridge disposed"));
|
|
372
|
-
while (inflight.size > 0) await Promise.allSettled([...inflight]);
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
}
|
|
376
|
-
//#endregion
|
|
377
|
-
export { DEFAULT_HOOK_TIMEOUT_MS, DEFAULT_STDERR_SUMMARY_MAX_CHARS, appendHookInvoked, appendHookResult, createDetachedRuns, matcherDiagnostic, matchesMatcher, mergeHookOutputs, parseHookOutput, runHook, summarizeStderr };
|
package/lib/invariant.js
DELETED
|
@@ -1,97 +0,0 @@
|
|
|
1
|
-
//#region lib/types/invariant.js
|
|
2
|
-
/** Package-owned hook invocation/result stream invariants. @module @hasna-internal/kai-hook-protocol/invariant */
|
|
3
|
-
const PACKAGE_NAME = "@hasna-internal/kai-hook-protocol";
|
|
4
|
-
/** Cordis companion plugin name. */
|
|
5
|
-
const name = "hook-protocol-invariant";
|
|
6
|
-
/** Service required before the companion can reserve package ownership. */
|
|
7
|
-
const inject = ["invariants"];
|
|
8
|
-
/** Correlation key shared by an invoked/result pair. */
|
|
9
|
-
function hookKey(data) {
|
|
10
|
-
return `${data.turn}\0${data.point}\0${data.handlerId}`;
|
|
11
|
-
}
|
|
12
|
-
/** Validate one hook event against committed pending invocations. */
|
|
13
|
-
function validateHookEvent(trace, event, fail) {
|
|
14
|
-
if (event.type !== "hook/invoked" && event.type !== "hook/result") return void 0;
|
|
15
|
-
if (trace.openTurn === null) fail(`${event.type} appended outside any open turn`);
|
|
16
|
-
if (event.data.turn !== trace.openTurn) fail(`${event.type} names turn ${event.data.turn} but open turn is ${trace.openTurn}`);
|
|
17
|
-
if (event.type === "hook/invoked") {
|
|
18
|
-
if (event.data.point.length === 0 || event.data.handlerId.length === 0) fail("hook/invoked point and handlerId must be non-empty");
|
|
19
|
-
const dialect = event.data.dialect;
|
|
20
|
-
if (dialect !== "claude-code" && dialect !== "codex") fail(`hook/invoked carries unknown dialect ${JSON.stringify(dialect)}`);
|
|
21
|
-
return {
|
|
22
|
-
key: hookKey(event.data),
|
|
23
|
-
delta: 1
|
|
24
|
-
};
|
|
25
|
-
}
|
|
26
|
-
const key = hookKey(event.data);
|
|
27
|
-
if ((trace.pending.get(key) ?? 0) === 0) fail(`hook/result has no matching hook/invoked for ${JSON.stringify(event.data.handlerId)}`);
|
|
28
|
-
if (!Number.isFinite(event.data.durationMs) || event.data.durationMs < 0) fail("hook/result durationMs must be a non-negative finite number");
|
|
29
|
-
return {
|
|
30
|
-
key,
|
|
31
|
-
delta: -1
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
/** Apply one committed hook-pair transition. */
|
|
35
|
-
function applyHookTransition(pending, transition) {
|
|
36
|
-
const next = (pending.get(transition.key) ?? 0) + transition.delta;
|
|
37
|
-
if (next === 0) pending.delete(transition.key);
|
|
38
|
-
else pending.set(transition.key, next);
|
|
39
|
-
}
|
|
40
|
-
/** Install hook invoked/result pairing checks. */
|
|
41
|
-
const install = Object.assign((ctx, fail) => {
|
|
42
|
-
const traces = /* @__PURE__ */ new WeakMap();
|
|
43
|
-
const staged = /* @__PURE__ */ new WeakMap();
|
|
44
|
-
const seed = (session) => {
|
|
45
|
-
const trace = {
|
|
46
|
-
openTurn: null,
|
|
47
|
-
pending: /* @__PURE__ */ new Map()
|
|
48
|
-
};
|
|
49
|
-
traces.set(session, trace);
|
|
50
|
-
for (const event of session.events) {
|
|
51
|
-
if (event.type === "turn/start") trace.openTurn = event.data.turn;
|
|
52
|
-
else if (event.type === "turn/end") trace.openTurn = null;
|
|
53
|
-
const transition = validateHookEvent(trace, event, fail);
|
|
54
|
-
if (transition !== void 0) applyHookTransition(trace.pending, transition);
|
|
55
|
-
}
|
|
56
|
-
return trace;
|
|
57
|
-
};
|
|
58
|
-
const traceFor = (session) => traces.get(session) ?? seed(session);
|
|
59
|
-
for (const session of ctx.sessions.list()) seed(session);
|
|
60
|
-
ctx.on("session/created", (session) => {
|
|
61
|
-
seed(session);
|
|
62
|
-
}, { global: true });
|
|
63
|
-
ctx.on("session/event", (session, event) => {
|
|
64
|
-
const trace = traceFor(session);
|
|
65
|
-
if (event.type === "turn/start") {
|
|
66
|
-
trace.openTurn = event.data.turn;
|
|
67
|
-
return;
|
|
68
|
-
}
|
|
69
|
-
if (event.type === "turn/end") {
|
|
70
|
-
trace.openTurn = null;
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
if (event.type !== "hook/invoked" && event.type !== "hook/result") return;
|
|
74
|
-
const candidate = staged.get(event);
|
|
75
|
-
/* v8 ignore next -- internal/dispatch stages every hook invocation/result event */
|
|
76
|
-
if (candidate === void 0 || candidate.session !== session) return fail("hook event published without pre-commit validation");
|
|
77
|
-
staged.delete(event);
|
|
78
|
-
applyHookTransition(trace.pending, candidate.transition);
|
|
79
|
-
}, { global: true });
|
|
80
|
-
ctx.on("internal/dispatch", (_mode, eventName, args) => {
|
|
81
|
-
if (eventName !== "session/event") return;
|
|
82
|
-
const [session, event] = args;
|
|
83
|
-
const transition = validateHookEvent(traceFor(session), event, fail);
|
|
84
|
-
if (transition !== void 0) staged.set(event, {
|
|
85
|
-
session,
|
|
86
|
-
transition
|
|
87
|
-
});
|
|
88
|
-
}, { global: true });
|
|
89
|
-
}, { inject: ["sessions"] });
|
|
90
|
-
/**
|
|
91
|
-
* Register the hook-protocol invariant companion.
|
|
92
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
93
|
-
* @returns the installed registration's disposer after setup succeeds.
|
|
94
|
-
*/
|
|
95
|
-
const apply = (ctx) => Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install));
|
|
96
|
-
//#endregion
|
|
97
|
-
export { apply, inject, name };
|
package/lib/types/codec.d.ts
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Decode hook process outcomes for both dialects. Exit 0 may carry structured
|
|
3
|
-
* JSON or plain stdout; exit 2 blocks with stderr as the reason; every other
|
|
4
|
-
* exit is a non-blocking error. Bridges decide which recognized fields apply.
|
|
5
|
-
* @module @hasna-internal/kai-hook-protocol/codec
|
|
6
|
-
*/
|
|
7
|
-
import type { HookOutput } from './types.ts';
|
|
8
|
-
/**
|
|
9
|
-
* Decode process output into a dialect-neutral hook outcome. This function is
|
|
10
|
-
* total: malformed JSON remains plain stdout. When `expectedEventName` is set,
|
|
11
|
-
* a missing or different `hookSpecificOutput.hookEventName` discards only its
|
|
12
|
-
* event-scoped fields; top-level fields and the claimed discriminator remain.
|
|
13
|
-
* Omitting the guard applies the block as-is.
|
|
14
|
-
* @param exitCode - process exit, or `undefined` when spawn failed.
|
|
15
|
-
* @param stdout - output parsed as structured JSON only on exit 0.
|
|
16
|
-
* @param stderr - the captured stderr stream; becomes the blocking `reason` on exit 2.
|
|
17
|
-
* @param expectedEventName - firing event used to guard hook-specific fields; omit to disable the guard.
|
|
18
|
-
* @returns the dialect-neutral decoded outcome.
|
|
19
|
-
*/
|
|
20
|
-
export declare function parseHookOutput(exitCode: number | undefined, stdout: string, stderr: string, expectedEventName?: string): HookOutput;
|
|
21
|
-
//# sourceMappingURL=codec.d.ts.map
|
package/lib/types/detached.d.ts
DELETED
|
@@ -1,42 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Quiescence tracking for emit-shaped hook runs that no extension point awaits. Bridges
|
|
3
|
-
* track the run plus its continuation, pass the tracker signal into execution,
|
|
4
|
-
* and drain on disposal so no process or late callback outlives the fiber.
|
|
5
|
-
* @module @hasna-internal/kai-hook-protocol/detached
|
|
6
|
-
*/
|
|
7
|
-
/** In-flight registry for one bridge's detached hook runs; see the module doc for the wiring contract. */
|
|
8
|
-
export interface DetachedRuns {
|
|
9
|
-
/**
|
|
10
|
-
* The abort signal every tracked run must hand to {@link runHook} (via its
|
|
11
|
-
* `signal` option). {@link drain} fires it so a still-running hook process is
|
|
12
|
-
* killed rather than awaited out to its timeout (default 10 minutes).
|
|
13
|
-
*/
|
|
14
|
-
readonly signal: AbortSignal;
|
|
15
|
-
/**
|
|
16
|
-
* Register one detached run until it settles. Pass the FULL chain — the hook
|
|
17
|
-
* run and its continuation/error handler — so {@link drain} waits for the
|
|
18
|
-
* side effects (an inject, a warn), not just the process exit. A rejected
|
|
19
|
-
* chain is absorbed here (settlement bookkeeping only), but rejection
|
|
20
|
-
* handling is still the caller's job: an untracked `.catch` is what turns a
|
|
21
|
-
* failure into a logged warning instead of silence.
|
|
22
|
-
* @param run - the detached run chain to hold until settled.
|
|
23
|
-
*/
|
|
24
|
-
track(run: Promise<unknown>): void;
|
|
25
|
-
/**
|
|
26
|
-
* Abort {@link signal}, then resolve once every tracked chain has settled —
|
|
27
|
-
* including chains tracked while the drain is in progress. The bridge
|
|
28
|
-
* registers this as its effect disposer; cordis awaits it, so
|
|
29
|
-
* `fiber.dispose()` resolving means the bridge's detached work is quiescent.
|
|
30
|
-
* A run tracked AFTER drain resolves is not awaited by anyone — by then the
|
|
31
|
-
* bridge's listeners are disposed, so nothing can start one.
|
|
32
|
-
* @returns resolves when all tracked runs have settled.
|
|
33
|
-
*/
|
|
34
|
-
drain(): Promise<void>;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Create a {@link DetachedRuns} tracker (one per bridge `apply()`); settled
|
|
38
|
-
* runs are pruned so a long-lived session does not accumulate them.
|
|
39
|
-
* @returns the tracker.
|
|
40
|
-
*/
|
|
41
|
-
export declare function createDetachedRuns(): DetachedRuns;
|
|
42
|
-
//# sourceMappingURL=detached.d.ts.map
|
package/lib/types/events.d.ts
DELETED
|
@@ -1,74 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Append helpers for durable, log-only hook events. They carry no surface
|
|
3
|
-
* intent and must remain turn-enclosed and invoked/result paired. Mid-turn hook
|
|
4
|
-
* points satisfy that boundary; SessionStart records injected context instead
|
|
5
|
-
* and does not append `hook/*` outside a turn.
|
|
6
|
-
* @module @hasna-internal/kai-hook-protocol/events
|
|
7
|
-
*/
|
|
8
|
-
import type { Session } from '@hasna-internal/kai-session';
|
|
9
|
-
import type { HookDialect, HookOutput } from './types.ts';
|
|
10
|
-
/** What identifies a hook invocation across its invoked/result pair. */
|
|
11
|
-
export interface HookInvocation {
|
|
12
|
-
/** The open turn the invocation lives inside. */
|
|
13
|
-
turn: number;
|
|
14
|
-
/** The hook point (`PreToolUse`, `Stop`, …). */
|
|
15
|
-
point: string;
|
|
16
|
-
/** The bridge dialect that ran it. */
|
|
17
|
-
dialect: HookDialect;
|
|
18
|
-
/** A stable id correlating the invoked event with its result. */
|
|
19
|
-
handlerId: string;
|
|
20
|
-
/** The matcher-group pattern that selected it (absent for match-all). */
|
|
21
|
-
matcher?: string;
|
|
22
|
-
}
|
|
23
|
-
/** The decided outcome half of the pair. */
|
|
24
|
-
export interface HookResultRecord {
|
|
25
|
-
turn: number;
|
|
26
|
-
point: string;
|
|
27
|
-
handlerId: string;
|
|
28
|
-
/**
|
|
29
|
-
* The decoded outcome the run produced. {@link appendHookResult} derives the
|
|
30
|
-
* durable `decision`/`exitCode`/`stderrSummary` fields from it, so the shared
|
|
31
|
-
* event's semantics live here, in the lib that declares it, not per-bridge.
|
|
32
|
-
*/
|
|
33
|
-
output: HookOutput;
|
|
34
|
-
/**
|
|
35
|
-
* Character cap for the derived `stderrSummary`. The bound is the bridge's
|
|
36
|
-
* to own (its `stderrSummaryMaxChars` config) and is passed in explicitly —
|
|
37
|
-
* {@link DEFAULT_STDERR_SUMMARY_MAX_CHARS} is the reference default.
|
|
38
|
-
*/
|
|
39
|
-
stderrSummaryMaxChars: number;
|
|
40
|
-
/** Wall-clock duration of the run (from `runHook`) — durable audit timing. */
|
|
41
|
-
durationMs: number;
|
|
42
|
-
}
|
|
43
|
-
/**
|
|
44
|
-
* The reference default for {@link HookResultRecord.stderrSummaryMaxChars}
|
|
45
|
-
* (both bridges' config default). It lives here, once, next to the truncation
|
|
46
|
-
* rule it bounds, so the bridges cannot drift apart on the shared event's
|
|
47
|
-
* default cap.
|
|
48
|
-
*/
|
|
49
|
-
export declare const DEFAULT_STDERR_SUMMARY_MAX_CHARS = 500;
|
|
50
|
-
/**
|
|
51
|
-
* Truncate a hook's stderr for {@link HookResultRecord.stderrSummary}: trimmed,
|
|
52
|
-
* `undefined` when empty, cut at `maxChars` with an ellipsis when over. The
|
|
53
|
-
* bound is a parameter — like `runHook`'s `defaultTimeoutMs`, each bridge owns
|
|
54
|
-
* the config default and passes it in.
|
|
55
|
-
* @param stderr - the hook's raw captured stderr.
|
|
56
|
-
* @param maxChars - the character cap for the summary (the bridge's config value).
|
|
57
|
-
* @returns the trimmed, capped summary, or `undefined` when stderr is blank.
|
|
58
|
-
*/
|
|
59
|
-
export declare function summarizeStderr(stderr: string, maxChars: number): string | undefined;
|
|
60
|
-
/**
|
|
61
|
-
* Append a `hook/invoked` event naming the handler and hook point to `session`.
|
|
62
|
-
* @param session - the session whose open turn records the event.
|
|
63
|
-
* @param invocation - the invocation identity; an absent `matcher` is omitted from the payload.
|
|
64
|
-
*/
|
|
65
|
-
export declare function appendHookInvoked(session: Session, invocation: HookInvocation): void;
|
|
66
|
-
/**
|
|
67
|
-
* Append the durable result paired with `hook/invoked`. The recorded decision
|
|
68
|
-
* is the parsed decision, then `stop` for `continue:false`, else `pass`; stderr
|
|
69
|
-
* is trimmed and capped, and an absent process exit stays omitted.
|
|
70
|
-
* @param session - the session whose open turn records the event.
|
|
71
|
-
* @param record - the outcome to record: the decoded output plus the summary cap and duration.
|
|
72
|
-
*/
|
|
73
|
-
export declare function appendHookResult(session: Session, record: HookResultRecord): void;
|
|
74
|
-
//# sourceMappingURL=events.d.ts.map
|
package/lib/types/index.d.ts
DELETED
|
@@ -1,19 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared, non-plugin hook protocol library: matching, command execution and
|
|
3
|
-
* decoding, restrictive outcome merging, durable event helpers, and detached
|
|
4
|
-
* run quiescence. Claude Code and Codex bridges own their distinct payloads,
|
|
5
|
-
* environment rules, matcher mode, and typed extension-point mappings.
|
|
6
|
-
* @module @hasna-internal/kai-hook-protocol
|
|
7
|
-
*/
|
|
8
|
-
export type { CommandHook, HookDialect, HookOutput, MatcherGroup, MatcherMode, } from './types.ts';
|
|
9
|
-
export { matcherDiagnostic, matchesMatcher } from './matcher.ts';
|
|
10
|
-
export { parseHookOutput } from './codec.ts';
|
|
11
|
-
export { DEFAULT_HOOK_TIMEOUT_MS, runHook } from './runner.ts';
|
|
12
|
-
export type { RunHookOptions, RunHookResult } from './runner.ts';
|
|
13
|
-
export { mergeHookOutputs } from './merge.ts';
|
|
14
|
-
export type { MergedDecision, MergedHookOutcome } from './merge.ts';
|
|
15
|
-
export { appendHookInvoked, appendHookResult, DEFAULT_STDERR_SUMMARY_MAX_CHARS, summarizeStderr } from './events.ts';
|
|
16
|
-
export type { HookInvocation, HookResultRecord } from './events.ts';
|
|
17
|
-
export { createDetachedRuns } from './detached.ts';
|
|
18
|
-
export type { DetachedRuns } from './detached.ts';
|
|
19
|
-
//# sourceMappingURL=index.d.ts.map
|
package/lib/types/invariant.d.ts
DELETED
|
@@ -1,13 +0,0 @@
|
|
|
1
|
-
/** Package-owned hook invocation/result stream invariants. @module @hasna-internal/kai-hook-protocol/invariant */
|
|
2
|
-
import type { Context } from '@deepseek-ai/cordis';
|
|
3
|
-
/** Cordis companion plugin name. */
|
|
4
|
-
export declare const name = "hook-protocol-invariant";
|
|
5
|
-
/** Service required before the companion can reserve package ownership. */
|
|
6
|
-
export declare const inject: string[];
|
|
7
|
-
/**
|
|
8
|
-
* Register the hook-protocol invariant companion.
|
|
9
|
-
* @param ctx - Cordis context carrying the invariant service.
|
|
10
|
-
* @returns the installed registration's disposer after setup succeeds.
|
|
11
|
-
*/
|
|
12
|
-
export declare const apply: (ctx: Context) => Promise<() => void>;
|
|
13
|
-
//# sourceMappingURL=invariant.d.ts.map
|
package/lib/types/matcher.d.ts
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Matcher shared by both hook dialects. Claude treats alphanumeric/underscore/
|
|
3
|
-
* pipe patterns as literal alternatives and other patterns as regex; Codex
|
|
4
|
-
* treats every non-empty pattern as an unanchored regex. Missing, empty, and
|
|
5
|
-
* `*` match all. Runtime matching contains invalid regexes as non-matches;
|
|
6
|
-
* config parsers use {@link matcherDiagnostic} to reject them with a diagnostic.
|
|
7
|
-
* @module @hasna-internal/kai-hook-protocol/matcher
|
|
8
|
-
*/
|
|
9
|
-
import type { MatcherMode } from './types.ts';
|
|
10
|
-
/**
|
|
11
|
-
* Validate one matcher before a bridge accepts its config group.
|
|
12
|
-
* @param matcher - configured pattern; match-all sentinels are valid.
|
|
13
|
-
* @param mode - dialect deciding whether a word-and-pipe pattern is literal.
|
|
14
|
-
* @returns `undefined` for a valid matcher, otherwise a stable diagnostic.
|
|
15
|
-
*/
|
|
16
|
-
export declare function matcherDiagnostic(matcher: string | undefined, mode: MatcherMode): string | undefined;
|
|
17
|
-
/**
|
|
18
|
-
* Whether `matcher` selects `query` under the given dialect. Claude literal
|
|
19
|
-
* patterns exact-match pipe-separated alternatives; all other patterns are
|
|
20
|
-
* unanchored regexes. Invalid regexes return `false` rather than throwing;
|
|
21
|
-
* bridge config parsers surface them through {@link matcherDiagnostic} before use.
|
|
22
|
-
* @param matcher - the configured pattern; absent/empty/`'*'` are the match-all sentinels.
|
|
23
|
-
* @param query - the candidate value (a tool name, a session source, …).
|
|
24
|
-
* @param mode - the dialect deciding literal-vs-regex interpretation of the pattern.
|
|
25
|
-
* @returns `true` when the pattern selects the query; `false` on a non-match or an invalid
|
|
26
|
-
* regex.
|
|
27
|
-
*/
|
|
28
|
-
export declare function matchesMatcher(matcher: string | undefined, query: string, mode: MatcherMode): boolean;
|
|
29
|
-
//# sourceMappingURL=matcher.d.ts.map
|
package/lib/types/merge.d.ts
DELETED
|
@@ -1,39 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Merge matched hooks into one most-restrictive outcome. Permission precedence
|
|
3
|
-
* is `deny > ask > allow`; the first `continue:false` stop is sticky; reasons
|
|
4
|
-
* for the winning rank are joined; and context and system messages accumulate
|
|
5
|
-
* in hook order.
|
|
6
|
-
* @module @hasna-internal/kai-hook-protocol/merge
|
|
7
|
-
*/
|
|
8
|
-
import type { HookOutput } from './types.ts';
|
|
9
|
-
/** The single decision a hook point resolves to after merging all matched hooks. */
|
|
10
|
-
export type MergedDecision = 'allow' | 'ask' | 'deny' | 'none';
|
|
11
|
-
/** The folded outcome of every hook that matched one point. */
|
|
12
|
-
export interface MergedHookOutcome {
|
|
13
|
-
/**
|
|
14
|
-
* The most-restrictive permission decision across all hooks (`deny` > `ask` >
|
|
15
|
-
* `allow`), or `none` when no hook expressed one. `block`/`deny` both fold to
|
|
16
|
-
* `deny`; `approve`/`allow` both fold to `allow`.
|
|
17
|
-
*/
|
|
18
|
-
decision: MergedDecision;
|
|
19
|
-
/** Joined (`\n\n`) reasons from every blocking/denying hook, or `undefined`. */
|
|
20
|
-
reason?: string;
|
|
21
|
-
/** `true` when any hook asked to halt (`continue:false`). */
|
|
22
|
-
stop: boolean;
|
|
23
|
-
/** The first halting hook's `stopReason`, when one halted. */
|
|
24
|
-
stopReason?: string;
|
|
25
|
-
/** Every hook's `additionalContext`, in hook order (no joining — the bridge decides). */
|
|
26
|
-
additionalContext: string[];
|
|
27
|
-
/** Every hook's `systemMessage`, in hook order. */
|
|
28
|
-
systemMessages: string[];
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Fold `outputs` (the results of every hook that matched a point, in hook order)
|
|
32
|
-
* into one {@link MergedHookOutcome} by the precedence rules above. An empty list
|
|
33
|
-
* yields a neutral outcome (`decision: 'none'`, no stop, empty context) — the
|
|
34
|
-
* caller treats that as "no hook had anything to say".
|
|
35
|
-
* @param outputs - every matched hook's decoded output, in hook order.
|
|
36
|
-
* @returns the single folded outcome the bridge maps onto its extension point.
|
|
37
|
-
*/
|
|
38
|
-
export declare function mergeHookOutputs(outputs: HookOutput[]): MergedHookOutcome;
|
|
39
|
-
//# sourceMappingURL=merge.d.ts.map
|
package/lib/types/runner.d.ts
DELETED
|
@@ -1,62 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Execute command hooks through `ctx.shell`, using its credential scrub,
|
|
3
|
-
* process-group cancellation, and timeout machinery. The bridge supplies the
|
|
4
|
-
* trusted stdin payload and dialect environment, then this module decodes the
|
|
5
|
-
* captured outcome.
|
|
6
|
-
* @module @hasna-internal/kai-hook-protocol/runner
|
|
7
|
-
*/
|
|
8
|
-
import type { ShellExecutor } from '@hasna-internal/kai-shell';
|
|
9
|
-
import type { CommandHook, HookOutput } from './types.ts';
|
|
10
|
-
/**
|
|
11
|
-
* The reference default per-hook timeout, in ms (10 minutes) — the value both
|
|
12
|
-
* Claude Code and Codex apply to a hook whose config sets no `timeout`. It
|
|
13
|
-
* lives here, once, as the protocol's default; the bridges' `defaultTimeoutMs`
|
|
14
|
-
* config defaults to it, and a per-hook {@link CommandHook.timeoutSec} is the
|
|
15
|
-
* override API.
|
|
16
|
-
*/
|
|
17
|
-
export declare const DEFAULT_HOOK_TIMEOUT_MS = 600000;
|
|
18
|
-
/** Everything a single hook invocation needs beyond its command line. */
|
|
19
|
-
export interface RunHookOptions {
|
|
20
|
-
/** The JSON payload object written to the hook's stdin (the bridge builds it). */
|
|
21
|
-
payload: unknown;
|
|
22
|
-
/** Extra env vars for the hook process (`CLAUDE_PROJECT_DIR`, …); the bridge builds these. */
|
|
23
|
-
env?: Record<string, string>;
|
|
24
|
-
/** Working directory for the hook (defaults to the executor's own default when omitted). */
|
|
25
|
-
cwd?: string;
|
|
26
|
-
/** Explicit owning-operation signal; firing it cancels the hook run. */
|
|
27
|
-
readonly signal: AbortSignal;
|
|
28
|
-
/** Whether to append a trailing newline to the stdin payload (CC yes, Codex no). */
|
|
29
|
-
trailingNewline: boolean;
|
|
30
|
-
/**
|
|
31
|
-
* Timeout applied when the hook's config sets no `timeout` of its own. The
|
|
32
|
-
* bridge owns the default (its `defaultTimeoutMs` config, reference default
|
|
33
|
-
* {@link DEFAULT_HOOK_TIMEOUT_MS}) and passes it in explicitly.
|
|
34
|
-
*/
|
|
35
|
-
defaultTimeoutMs: number;
|
|
36
|
-
/**
|
|
37
|
-
* The event this hook is firing for (e.g. `'PreToolUse'`). When set, a
|
|
38
|
-
* structured `hookSpecificOutput` block whose `hookEventName` names a DIFFERENT
|
|
39
|
-
* event is treated as malformed and its event-scoped fields are discarded (see
|
|
40
|
-
* {@link parseHookOutput}). Omit it to apply any block as-is.
|
|
41
|
-
*/
|
|
42
|
-
expectedEventName?: string;
|
|
43
|
-
}
|
|
44
|
-
/** The {@link HookOutput} plus the wall-clock duration of the run (for `hook/result`). */
|
|
45
|
-
export interface RunHookResult {
|
|
46
|
-
output: HookOutput;
|
|
47
|
-
/** Wall-clock duration of the run, from `now` — durable on the `hook/result` event. */
|
|
48
|
-
durationMs: number;
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Run `hook` with serialized stdin and decode its outcome. A hook-specific
|
|
52
|
-
* timeout in seconds overrides the default; trusted environment entries merge
|
|
53
|
-
* after the executor scrub. Infrastructure rejection becomes an outcome with
|
|
54
|
-
* no exit code, so this function never throws or crashes the calling turn.
|
|
55
|
-
* @param bash - The executor service the command runs through.
|
|
56
|
-
* @param hook - the configured command; its `timeoutSec` (wire unit: seconds) overrides the default timeout.
|
|
57
|
-
* @param options - the invocation's payload, env, cwd, signal, stdin framing, and default timeout.
|
|
58
|
-
* @param now - millisecond clock used for the reported duration.
|
|
59
|
-
* @returns the decoded output plus the run's wall-clock duration.
|
|
60
|
-
*/
|
|
61
|
-
export declare function runHook(bash: ShellExecutor, hook: CommandHook, options: RunHookOptions, now: () => number): Promise<RunHookResult>;
|
|
62
|
-
//# sourceMappingURL=runner.d.ts.map
|
package/lib/types/types.d.ts
DELETED
|
@@ -1,132 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Dialect-neutral vocabulary and log-only events shared by the Claude Code and
|
|
3
|
-
* Codex hook bridges. Payload construction, matching differences, environment,
|
|
4
|
-
* and extension-point-specific decision mapping remain owned by each bridge.
|
|
5
|
-
* @module @hasna-internal/kai-hook-protocol/types
|
|
6
|
-
*/
|
|
7
|
-
declare module '@hasna-internal/kai-session/types' {
|
|
8
|
-
interface SessionEventMap {
|
|
9
|
-
/**
|
|
10
|
-
* A hook command was invoked at a hook point — a log-only record (like
|
|
11
|
-
* `compaction/*`; NOT a {@link SurfaceEventType}, carries no `surfaceOp`).
|
|
12
|
-
* `dialect` is the bridge that ran it (`claude`/`codex`), `point`
|
|
13
|
-
* the hook point (`PreToolUse`, `Stop`, …), `matcher` the matcher-group
|
|
14
|
-
* pattern that selected it (absent for match-all), `handlerId` a stable id
|
|
15
|
-
* for the command (so an invoked/result pair correlates). `turn` is the open
|
|
16
|
-
* turn the invocation lives inside.
|
|
17
|
-
*/
|
|
18
|
-
'hook/invoked': {
|
|
19
|
-
turn: number;
|
|
20
|
-
point: string;
|
|
21
|
-
dialect: HookDialect;
|
|
22
|
-
matcher?: string;
|
|
23
|
-
handlerId: string;
|
|
24
|
-
};
|
|
25
|
-
/**
|
|
26
|
-
* Log-only outcome paired to `hook/invoked` by `handlerId`. Decision is the
|
|
27
|
-
* parsed permission result, `stop` for `continue:false`, or `pass`; exit code
|
|
28
|
-
* may be absent, stderr is bounded, and duration is wall-clock runtime.
|
|
29
|
-
*/
|
|
30
|
-
'hook/result': {
|
|
31
|
-
turn: number;
|
|
32
|
-
point: string;
|
|
33
|
-
handlerId: string;
|
|
34
|
-
decision: string;
|
|
35
|
-
exitCode?: number;
|
|
36
|
-
stderrSummary?: string;
|
|
37
|
-
durationMs: number;
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
/**
|
|
42
|
-
* The bridge that ran a hook — the CC bridge stamps `'claude-code'`, the Codex
|
|
43
|
-
* bridge `'codex'`. A native plugin at the interception points is not a bridge
|
|
44
|
-
* and writes no `hook/*` invocation/result records (see the interception extension-points Agent Note).
|
|
45
|
-
*/
|
|
46
|
-
export type HookDialect = 'claude-code' | 'codex';
|
|
47
|
-
/**
|
|
48
|
-
* One configured command hook (the `{ type: 'command', command, timeout? }`
|
|
49
|
-
* shape shared by both dialects). Non-command hook types (CC's `prompt`/`agent`/
|
|
50
|
-
* `http`) are parsed-and-skipped by a bridge, so only this shape reaches the
|
|
51
|
-
* runner.
|
|
52
|
-
*/
|
|
53
|
-
export interface CommandHook {
|
|
54
|
-
/** The shell command line to run. */
|
|
55
|
-
command: string;
|
|
56
|
-
/** Per-hook timeout in SECONDS (the wire unit); the runner converts to ms. */
|
|
57
|
-
timeoutSec?: number;
|
|
58
|
-
}
|
|
59
|
-
/**
|
|
60
|
-
* One matcher group: a `matcher` pattern (absent / `''` / `'*'` = match-all)
|
|
61
|
-
* plus the command hooks that run when it matches. Both dialects share this
|
|
62
|
-
* shape (CC's `hooks.json` and Codex's `hooks.json`).
|
|
63
|
-
*/
|
|
64
|
-
export interface MatcherGroup {
|
|
65
|
-
matcher?: string;
|
|
66
|
-
hooks: CommandHook[];
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* How a matcher pattern is interpreted. Claude Code uses {@link literal} when the
|
|
70
|
-
* pattern is purely `[A-Za-z0-9_|]+` (pipe = exact-match alternation) and
|
|
71
|
-
* {@link regex} otherwise; Codex is always {@link regex}. The bridge picks the
|
|
72
|
-
* mode for its dialect.
|
|
73
|
-
*/
|
|
74
|
-
export type MatcherMode = 'claude-code' | 'codex';
|
|
75
|
-
/**
|
|
76
|
-
* The dialect-neutral OUTCOME a hook produced, parsed from its exit code +
|
|
77
|
-
* stdout JSON + stderr by {@link parseHookOutput}. A bridge maps this onto a
|
|
78
|
-
* extension-point-specific typed Decision (PreToolDecision, PreStepDecision, …). Every field
|
|
79
|
-
* is OPTIONAL because a hook may exercise any subset; the bridge decides which
|
|
80
|
-
* fields are meaningful for its hook point and which it ignores (faithful-but-
|
|
81
|
-
* degraded — e.g. Codex ignores `allow`/`ask`).
|
|
82
|
-
*/
|
|
83
|
-
export interface HookOutput {
|
|
84
|
-
/** The raw process exit code (`undefined` if the hook could not be run). */
|
|
85
|
-
exitCode: number | undefined;
|
|
86
|
-
/** Trimmed stderr — the block-reason source on a blocking (exit 2) hook. */
|
|
87
|
-
stderr: string;
|
|
88
|
-
/**
|
|
89
|
-
* Trimmed stdout, verbatim. On a clean exit a hook may emit PLAIN (non-JSON)
|
|
90
|
-
* stdout that the protocol renders as output (CC) or treats as
|
|
91
|
-
* `additionalContext` (Codex SessionStart/UserPromptSubmit) — so the bridge
|
|
92
|
-
* needs the raw text, not just the parsed structured fields. Empty string when
|
|
93
|
-
* the hook produced no stdout.
|
|
94
|
-
*/
|
|
95
|
-
stdout: string;
|
|
96
|
-
/**
|
|
97
|
-
* `false` ⇒ the hook asked to halt (CC/Codex `continue:false`); pairs with
|
|
98
|
-
* {@link stopReason}. `true`/absent ⇒ proceed.
|
|
99
|
-
*/
|
|
100
|
-
continue?: boolean;
|
|
101
|
-
/** Human-readable reason shown when {@link continue} is `false`. */
|
|
102
|
-
stopReason?: string;
|
|
103
|
-
/**
|
|
104
|
-
* The neutral blocking decision a hook expressed, folded from the two channels
|
|
105
|
-
* the reference protocols keep DISTINCT: the legacy top-level `decision`
|
|
106
|
-
* (`approve`/`block` only) and `hookSpecificOutput.permissionDecision`
|
|
107
|
-
* (`allow`/`deny`/`ask`). We normalize them to one enum — `'block'`/`'deny'`
|
|
108
|
-
* forbid, `'approve'`/`'allow'` permit, `'ask'` requests confirmation — but
|
|
109
|
-
* `'allow'`/`'deny'`/`'ask'` arise ONLY from a `permissionDecision`, never from
|
|
110
|
-
* a top-level `decision` (an out-of-band `{"decision":"deny"}` is invalid and
|
|
111
|
-
* ignored, matching the schemas). Absent ⇒ no explicit decision (exit code governs).
|
|
112
|
-
*/
|
|
113
|
-
decision?: 'approve' | 'allow' | 'block' | 'deny' | 'ask';
|
|
114
|
-
/** The reason/explanation accompanying {@link decision}. */
|
|
115
|
-
reason?: string;
|
|
116
|
-
/**
|
|
117
|
-
* Event discriminator claimed by `hookSpecificOutput`. On mismatch,
|
|
118
|
-
* {@link parseHookOutput} preserves this value but discards event-scoped fields.
|
|
119
|
-
*/
|
|
120
|
-
hookEventName?: string;
|
|
121
|
-
/** Extra context to inject for the next model request (CC `additionalContext`). */
|
|
122
|
-
additionalContext?: string;
|
|
123
|
-
/** A warning surfaced to the user (CC `systemMessage`). */
|
|
124
|
-
systemMessage?: string;
|
|
125
|
-
/**
|
|
126
|
-
* A tool-input rewrite a hook requested (CC `updatedInput`). PARSED but NOT
|
|
127
|
-
* honored — input rewrite is deferred (see the interception extension-points Agent Note); a
|
|
128
|
-
* bridge logs + warns when this is present.
|
|
129
|
-
*/
|
|
130
|
-
updatedInput?: Record<string, unknown>;
|
|
131
|
-
}
|
|
132
|
-
//# sourceMappingURL=types.d.ts.map
|