@akira-tl/forgerelay 0.1.1 → 0.2.1
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/CHANGELOG.md +37 -0
- package/README.md +51 -6
- package/dist/apply-patch.js +21 -6
- package/dist/artifact-tools.js +35 -16
- package/dist/cli.js +46 -3
- package/dist/config.js +10 -6
- package/dist/db/migrations.js +8 -0
- package/dist/db/schema.js +1 -0
- package/dist/hook-cli.js +100 -0
- package/dist/hooks.js +545 -0
- package/dist/local-agent-store.js +14 -1
- package/dist/logger.js +157 -15
- package/dist/mcp/server-instructions.js +6 -5
- package/dist/pi-tools.js +14 -13
- package/dist/process-platform.js +1 -0
- package/dist/roots.js +30 -1
- package/dist/server.js +586 -446
- package/dist/user-config.js +33 -1
- package/dist/workspaces.js +64 -7
- package/docs/configuration.md +138 -5
- package/docs/debugging.md +127 -0
- package/docs/roadmap.md +16 -21
- package/docs/security.md +32 -7
- package/package.json +5 -3
- package/scripts/debug/accept.mjs +649 -0
- package/scripts/debug/config.json +40 -0
- package/scripts/debug/hook-recorder.mjs +35 -0
- package/scripts/debug/runtime.mjs +47 -0
- package/scripts/debug/serve.mjs +37 -0
- package/scripts/dev-server.mjs +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,43 @@ All notable ForgeRelay changes are documented here.
|
|
|
4
4
|
|
|
5
5
|
## [Unreleased]
|
|
6
6
|
|
|
7
|
+
## [0.2.1] - 2026-08-09
|
|
8
|
+
|
|
9
|
+
### Changed
|
|
10
|
+
|
|
11
|
+
- Local console logging now defaults to a compact Loguru-style `pretty` format focused on Agent operations: short timestamps, workspace/session context, tool or Hook action, target, and `ok`/`error` or shell exit status. HTTP request logging is off by default in human mode, while explicit `json` mode keeps the previous request-on and shell-command-off machine defaults.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- File and search tools can access and modify files in the operating system temporary directory without making that directory an implicit workspace root; Codex `apply_patch` supports absolute OS-temp paths while workspace patch paths remain relative.
|
|
16
|
+
|
|
17
|
+
### Security
|
|
18
|
+
|
|
19
|
+
- 文件工具在 workspace 与 OS temp 边界内都会校验 canonical path,阻止通过 symlink 跳转到任意文件系统位置;shell cwd 与 workspace-open allowed roots 不随 temp 文件访问而扩大。
|
|
20
|
+
|
|
21
|
+
## [0.2.0] - 2026-08-09
|
|
22
|
+
|
|
23
|
+
### Added
|
|
24
|
+
|
|
25
|
+
- A checked-in `127.0.0.1:7677` local debug environment with reproducible OAuth/MCP acceptance scripts, isolated runtime state, and Hooks v1 lifecycle recording.
|
|
26
|
+
- 用户或 Agent 可写的生命周期 Hook,覆盖 workspace、MCP tool、明确文件变更、managed-worktree close 与本地 subagent 生命周期。
|
|
27
|
+
- 首选的一 Hook 一文件配置:全局 `hooks/<hook-name>.json` 与项目 `.forgerelay/hooks/<hook-name>.json` 自动组合,文件名直接作为 Hook 名并按文件名稳定排序;旧 inline/聚合格式继续兼容。
|
|
28
|
+
- 独立 Hook 的 `event + matcher + command`、bounded timeout 与 `report` 配置;可报告结果进入 Agent 可见返回,阻断失败始终可见。
|
|
29
|
+
- 只读的 `forgerelay hooks list` / `hooks check` CLI,用于查看实际加载规则和在不执行 Hook 的情况下校验全局/项目配置。
|
|
30
|
+
- `BeforeTool` 与 `BeforeWorktreeClose` 阻断语义,以及不会伪装回滚已完成操作的 observational after-events。
|
|
31
|
+
- 通过 `FORGERELAY_HOOK_*` 和 workspace 环境变量提供生命周期上下文,同时避免暴露 native-file credentials 或 subagent prompts。
|
|
32
|
+
- 异步 subagent Hook report 的 session 持久化与 `agents show` 展示。
|
|
33
|
+
|
|
34
|
+
### Fixed
|
|
35
|
+
|
|
36
|
+
- MCP `initialize` now reports the package version from `package.json` instead of a stale hardcoded `0.1.0` server version.
|
|
37
|
+
- Hook commands on Windows preserve quoted arguments when executed through `cmd.exe`, fixing release-gate and other Hook commands that reference absolute paths.
|
|
38
|
+
|
|
39
|
+
### Security
|
|
40
|
+
|
|
41
|
+
- 项目 Hook 作为 allowed-root 内项目执行约定自动生效,不需要批准;Hook 文件只能声明生命周期规则,不能扩大 allowed roots、修改认证配置或覆盖全局规则。
|
|
42
|
+
- 无效项目 Hook 配置会作为 Agent 可见 diagnostic 返回,同时保持工具可用;独立目录中的坏文件会被跳过,其他有效 Hook 继续加载,避免 Agent 因写坏单个规则而无法修复。
|
|
43
|
+
|
|
7
44
|
## [0.1.1] - 2026-08-09
|
|
8
45
|
|
|
9
46
|
### Changed
|
package/README.md
CHANGED
|
@@ -90,7 +90,9 @@ Once a workspace is open, the host can:
|
|
|
90
90
|
parallel work;
|
|
91
91
|
- close a managed worktree by committing its remaining changes and
|
|
92
92
|
fast-forwarding the target branch when that can be done safely;
|
|
93
|
-
- show aggregate changes through optional ChatGPT Apps-compatible UI cards
|
|
93
|
+
- show aggregate changes through optional ChatGPT Apps-compatible UI cards;
|
|
94
|
+
- run user-configured lifecycle hooks around tool calls, file changes, managed
|
|
95
|
+
worktree close, and local subagent execution.
|
|
94
96
|
|
|
95
97
|
Normal work happens in your existing checkout. ForgeRelay does not silently move
|
|
96
98
|
every task into a worktree.
|
|
@@ -117,6 +119,39 @@ If the histories have diverged, the close is refused and the worktree is left in
|
|
|
117
119
|
place. ForgeRelay does not put the source checkout into a merge-conflict state.
|
|
118
120
|
You can rebase and verify inside the worktree, then retry the close.
|
|
119
121
|
|
|
122
|
+
## Lifecycle hooks
|
|
123
|
+
|
|
124
|
+
Hook 是 ForgeRelay 的自动生命周期规则。首选方式是一个 Hook 一个文件:全局放在 `~/.forgerelay/hooks/<hook-name>.json`,项目放在 `<repo>/.forgerelay/hooks/<hook-name>.json`。文件名就是 Hook 名,方便直接从目录看出每条规则的用途;全局与项目规则组合执行,不需要额外批准。
|
|
125
|
+
|
|
126
|
+
例如项目里的 `.forgerelay/hooks/release-tag-local-ci.json` 可以要求稳定版本 tag push 前先完成本地发布检查:
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"event": "BeforeTool",
|
|
131
|
+
"matcher": {
|
|
132
|
+
"tool": "bash",
|
|
133
|
+
"commandRegex": "^git\\s+push\\s+origin\\s+v\\d+\\.\\d+\\.\\d+$"
|
|
134
|
+
},
|
|
135
|
+
"command": "npm run release:verify",
|
|
136
|
+
"timeoutSeconds": 300,
|
|
137
|
+
"report": true
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
命中 `BeforeTool` 后,Hook 先执行;成功才继续原始 `git push`,失败则直接阻断。Hook 结果会回到 Agent,Agent 应向用户说明重要 Hook 是否通过或阻断了操作。`report:false` 可以隐藏不重要的成功报告,但阻断失败始终可见。
|
|
142
|
+
|
|
143
|
+
旧的 inline `hooks` 和聚合 `hooks.json` 仍兼容;新配置建议都用独立 `hooks/*.json` 文件。
|
|
144
|
+
|
|
145
|
+
可以直接检查当前全局与项目规则,而不会执行 Hook:
|
|
146
|
+
|
|
147
|
+
```bash
|
|
148
|
+
forgerelay hooks list
|
|
149
|
+
forgerelay hooks check
|
|
150
|
+
forgerelay hooks list --project /path/to/project
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
`list` 展示实际加载的规则、matcher、timeout、report 与 command;`check` 只校验配置并在发现坏文件时返回非零状态。完整 matcher、事件与环境变量见 [Configuration Reference](docs/configuration.md#lifecycle-hooks)。
|
|
154
|
+
|
|
120
155
|
## Local coding agents
|
|
121
156
|
|
|
122
157
|
ForgeRelay can delegate work to local coding runtimes through user-defined
|
|
@@ -200,11 +235,10 @@ forgerelay doctor
|
|
|
200
235
|
The next additions are focused on making the local execution layer more useful,
|
|
201
236
|
not on turning ForgeRelay into another all-in-one agent framework:
|
|
202
237
|
|
|
203
|
-
1.
|
|
204
|
-
2.
|
|
205
|
-
3.
|
|
206
|
-
4.
|
|
207
|
-
5. checkpoint/rewind and retention improvements.
|
|
238
|
+
1. LSP-backed code intelligence;
|
|
239
|
+
2. first-class MCP subagent delegation;
|
|
240
|
+
3. stronger worktree verification and recovery;
|
|
241
|
+
4. checkpoint/rewind and retention improvements.
|
|
208
242
|
|
|
209
243
|
ForgeRelay does not plan to add its own shell sandbox, long-term memory system,
|
|
210
244
|
or plugin marketplace. Conversation, planning, web access, and other host-native
|
|
@@ -239,6 +273,7 @@ Trusted Publishing setup.
|
|
|
239
273
|
## Documentation
|
|
240
274
|
|
|
241
275
|
- [Setup Guide](docs/setup.md)
|
|
276
|
+
- [Local Debugging and 7677 Acceptance](docs/debugging.md)
|
|
242
277
|
- [ChatGPT Coding Workflow](docs/chatgpt-coding-workflow.md)
|
|
243
278
|
- [Configuration Reference](docs/configuration.md)
|
|
244
279
|
- [Agent Profile Schema](docs/agent-profile-schema.md)
|
|
@@ -266,7 +301,17 @@ The original copyright notice remains in [LICENSE](LICENSE). See
|
|
|
266
301
|
```bash
|
|
267
302
|
npm install --include=dev
|
|
268
303
|
npm run dev
|
|
304
|
+
npm run debug:accept
|
|
269
305
|
npm run typecheck
|
|
270
306
|
npm test
|
|
271
307
|
npm run build
|
|
272
308
|
```
|
|
309
|
+
|
|
310
|
+
`npm run dev` uses the checked-in local debug configuration and binds ForgeRelay
|
|
311
|
+
to `127.0.0.1:7677`, keeping the normal `7676` product port free. The
|
|
312
|
+
`debug:accept` command starts a temporary 7677 server and sends real
|
|
313
|
+
HTTP/OAuth/MCP requests through it, including workspace tools, managed worktree
|
|
314
|
+
close, and Hooks v1 lifecycle recording. Debug state stays under the gitignored
|
|
315
|
+
`.forgerelay-debug/` directory.
|
|
316
|
+
|
|
317
|
+
See [Local Debugging](docs/debugging.md) for the exact configuration and scripts.
|
package/dist/apply-patch.js
CHANGED
|
@@ -4,6 +4,7 @@ import { access, lstat, mkdir, readFile, realpath, rename, rm, stat, writeFile }
|
|
|
4
4
|
import { dirname, isAbsolute, relative, resolve } from "node:path";
|
|
5
5
|
import { TextDecoder } from "node:util";
|
|
6
6
|
import { createTwoFilesPatch, FILE_HEADERS_ONLY } from "diff";
|
|
7
|
+
import { AccessDeniedError, resolveCanonicalAllowedPath } from "./roots.js";
|
|
7
8
|
function patchError(message) {
|
|
8
9
|
return new Error(`Invalid patch: ${message}`);
|
|
9
10
|
}
|
|
@@ -137,10 +138,24 @@ function isInside(root, path) {
|
|
|
137
138
|
const rel = relative(root, path);
|
|
138
139
|
return rel === "" || (!rel.startsWith("..") && !isAbsolute(rel));
|
|
139
140
|
}
|
|
140
|
-
async function resolveConfinedPath(root, input) {
|
|
141
|
-
if (!input || input.includes("\0")
|
|
141
|
+
async function resolveConfinedPath(root, input, auxiliaryRoots = []) {
|
|
142
|
+
if (!input || input.includes("\0")) {
|
|
142
143
|
throw patchError(`path must be relative to the workspace: ${input}`);
|
|
143
144
|
}
|
|
145
|
+
if (isAbsolute(input)) {
|
|
146
|
+
if (auxiliaryRoots.length === 0) {
|
|
147
|
+
throw patchError(`path must be relative to the workspace: ${input}`);
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
return await resolveCanonicalAllowedPath(input, root, auxiliaryRoots);
|
|
151
|
+
}
|
|
152
|
+
catch (error) {
|
|
153
|
+
if (error instanceof AccessDeniedError) {
|
|
154
|
+
throw patchError(`path is outside allowed auxiliary roots: ${input}`);
|
|
155
|
+
}
|
|
156
|
+
throw error;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
144
159
|
const rootPath = await realpath(root);
|
|
145
160
|
const target = resolve(rootPath, input);
|
|
146
161
|
if (!isInside(rootPath, target)) {
|
|
@@ -269,7 +284,7 @@ export async function isSamePatchFile(source, destination, readIdentity = lstat)
|
|
|
269
284
|
throw error;
|
|
270
285
|
}
|
|
271
286
|
}
|
|
272
|
-
export async function applyPatch(root, patch) {
|
|
287
|
+
export async function applyPatch(root, patch, auxiliaryRoots = []) {
|
|
273
288
|
const actions = parsePatch(patch);
|
|
274
289
|
const results = [];
|
|
275
290
|
const patches = [];
|
|
@@ -289,14 +304,14 @@ export async function applyPatch(root, patch) {
|
|
|
289
304
|
};
|
|
290
305
|
for (const action of actions) {
|
|
291
306
|
if (action.kind === "add") {
|
|
292
|
-
const absolute = await resolveConfinedPath(root, action.path);
|
|
307
|
+
const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
|
|
293
308
|
const original = await readStagedOptional(absolute, action.path);
|
|
294
309
|
staged.set(absolute, { content: action.content, mode: original?.mode });
|
|
295
310
|
patches.push(unifiedFilePatch(action.path, action.path, original?.content ?? null, action.content));
|
|
296
311
|
results.push({ path: action.path, operation: original ? "update" : "add" });
|
|
297
312
|
continue;
|
|
298
313
|
}
|
|
299
|
-
const absolute = await resolveConfinedPath(root, action.path);
|
|
314
|
+
const absolute = await resolveConfinedPath(root, action.path, auxiliaryRoots);
|
|
300
315
|
const file = await readStagedRequired(absolute, action.path);
|
|
301
316
|
if (action.kind === "delete") {
|
|
302
317
|
staged.set(absolute, null);
|
|
@@ -306,7 +321,7 @@ export async function applyPatch(root, patch) {
|
|
|
306
321
|
}
|
|
307
322
|
const updated = applyHunks(action.path, file.content, action.hunks);
|
|
308
323
|
if (action.moveTo) {
|
|
309
|
-
const destination = await resolveConfinedPath(root, action.moveTo);
|
|
324
|
+
const destination = await resolveConfinedPath(root, action.moveTo, auxiliaryRoots);
|
|
310
325
|
const samePatchFile = await isSamePatchFile(absolute, destination);
|
|
311
326
|
if (!samePatchFile)
|
|
312
327
|
await readStagedOptional(destination, action.moveTo);
|
package/dist/artifact-tools.js
CHANGED
|
@@ -5,8 +5,9 @@ import { isAbsolute, join, normalize, sep } from "node:path";
|
|
|
5
5
|
import { registerAppTool } from "@modelcontextprotocol/ext-apps/server";
|
|
6
6
|
import * as z from "zod/v4";
|
|
7
7
|
import { ArtifactError } from "./artifact-error.js";
|
|
8
|
+
import { runToolWithHooks } from "./hooks.js";
|
|
8
9
|
import { describeIncomingArtifactValue, IncomingArtifactAdapterRegistry, } from "./incoming-artifacts.js";
|
|
9
|
-
import { logEvent } from "./logger.js";
|
|
10
|
+
import { logEvent, sessionIdPrefix, workspaceLogLabel } from "./logger.js";
|
|
10
11
|
const ARTIFACT_WRITE_ANNOTATIONS = {
|
|
11
12
|
readOnlyHint: false,
|
|
12
13
|
destructiveHint: false,
|
|
@@ -31,7 +32,7 @@ const openAIFileReferenceInputSchema = z.strictObject({
|
|
|
31
32
|
export function isArtifactDownloadSupportedPlatform(platform = process.platform) {
|
|
32
33
|
return ARTIFACT_DOWNLOAD_PLATFORMS.has(platform);
|
|
33
34
|
}
|
|
34
|
-
export function registerArtifactTools(server, { config, workspaces, incomingArtifactAdapters = [], }) {
|
|
35
|
+
export function registerArtifactTools(server, { config, workspaces, hooks, incomingArtifactAdapters = [], }) {
|
|
35
36
|
const incomingRegistry = new IncomingArtifactAdapterRegistry(incomingArtifactAdapters);
|
|
36
37
|
registerAppTool(server, "download_artifact", {
|
|
37
38
|
title: "Download attached or generated file",
|
|
@@ -46,21 +47,37 @@ export function registerArtifactTools(server, { config, workspaces, incomingArti
|
|
|
46
47
|
},
|
|
47
48
|
_meta: { "openai/fileParams": ["file"] },
|
|
48
49
|
annotations: ARTIFACT_WRITE_ANNOTATIONS,
|
|
49
|
-
}, async (input
|
|
50
|
+
}, async (input, extra) => {
|
|
50
51
|
const workspace = workspaces.getWorkspace(input.workspaceId);
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
52
|
+
return runToolWithHooks(hooks, {
|
|
53
|
+
tool: "download_artifact",
|
|
54
|
+
invocation: {
|
|
55
|
+
workspaceId: workspace.id,
|
|
56
|
+
workspaceRoot: workspace.root,
|
|
57
|
+
workspaceMode: workspace.mode,
|
|
58
|
+
sourceRoot: workspace.sourceRoot,
|
|
59
|
+
},
|
|
60
|
+
payload: { path: input.path },
|
|
61
|
+
changedPaths: (result) => [result.structuredContent.path],
|
|
62
|
+
operation: () => executeArtifactTool(config, input, {
|
|
63
|
+
workspace: workspaceLogLabel(workspace.root, workspace.id),
|
|
64
|
+
session: sessionIdPrefix(extra?.sessionId),
|
|
65
|
+
}, async () => {
|
|
66
|
+
const downloaded = await downloadIncomingArtifact({
|
|
67
|
+
registry: incomingRegistry,
|
|
68
|
+
workspaceId: workspace.id,
|
|
69
|
+
workspaceRoot: workspace.root,
|
|
70
|
+
maxFileBytes: config.artifactMaxFileBytes,
|
|
71
|
+
file: input.file,
|
|
72
|
+
path: input.path,
|
|
73
|
+
});
|
|
74
|
+
return {
|
|
75
|
+
publicResult: { path: downloaded.path },
|
|
76
|
+
logResult: downloaded,
|
|
77
|
+
};
|
|
78
|
+
}),
|
|
58
79
|
});
|
|
59
|
-
|
|
60
|
-
publicResult: { path: downloaded.path },
|
|
61
|
-
logResult: downloaded,
|
|
62
|
-
};
|
|
63
|
-
}));
|
|
80
|
+
});
|
|
64
81
|
}
|
|
65
82
|
/**
|
|
66
83
|
* Stream a trusted native file directly into one already-open workspace.
|
|
@@ -151,7 +168,7 @@ export function artifactToolLogFields(input) {
|
|
|
151
168
|
path: input.path,
|
|
152
169
|
};
|
|
153
170
|
}
|
|
154
|
-
async function executeArtifactTool(config, input, operation) {
|
|
171
|
+
async function executeArtifactTool(config, input, logContext, operation) {
|
|
155
172
|
const startedAt = performance.now();
|
|
156
173
|
try {
|
|
157
174
|
const { publicResult, logResult } = await operation();
|
|
@@ -159,6 +176,7 @@ async function executeArtifactTool(config, input, operation) {
|
|
|
159
176
|
logEvent(config.logging, "info", "artifact_tool_call", {
|
|
160
177
|
tool: "download_artifact",
|
|
161
178
|
...artifactToolLogFields(input),
|
|
179
|
+
...logContext,
|
|
162
180
|
path: logResult.path,
|
|
163
181
|
size: logResult.size,
|
|
164
182
|
sha256: logResult.sha256,
|
|
@@ -173,6 +191,7 @@ async function executeArtifactTool(config, input, operation) {
|
|
|
173
191
|
logEvent(config.logging, "warn", "artifact_tool_call", {
|
|
174
192
|
tool: "download_artifact",
|
|
175
193
|
...artifactToolLogFields(input),
|
|
194
|
+
...logContext,
|
|
176
195
|
success: false,
|
|
177
196
|
errorCode: error instanceof ArtifactError ? error.code : "internal_error",
|
|
178
197
|
durationMs: Math.round(performance.now() - startedAt),
|
package/dist/cli.js
CHANGED
|
@@ -11,6 +11,8 @@ import * as prompts from "@clack/prompts";
|
|
|
11
11
|
import { getShellConfig } from "@earendil-works/pi-coding-agent";
|
|
12
12
|
import { satisfies } from "semver";
|
|
13
13
|
import { loadConfig } from "./config.js";
|
|
14
|
+
import { formatVisibleHookReports, HookRunner } from "./hooks.js";
|
|
15
|
+
import { runHooksCommand } from "./hook-cli.js";
|
|
14
16
|
import { runLocalAgentProvider } from "./local-agent-adapters.js";
|
|
15
17
|
import { isLocalAgentProvider, loadLocalAgentProfiles, } from "./local-agent-profiles.js";
|
|
16
18
|
import { assertLocalAgentProviderAvailable, formatLocalAgentProviderAvailabilitySummary, } from "./local-agent-availability.js";
|
|
@@ -39,6 +41,9 @@ async function main(argv) {
|
|
|
39
41
|
case "config":
|
|
40
42
|
runConfigCommand(args);
|
|
41
43
|
return;
|
|
44
|
+
case "hooks":
|
|
45
|
+
await runHooksCommand(args);
|
|
46
|
+
return;
|
|
42
47
|
case "agents":
|
|
43
48
|
await runAgentsCommand(args);
|
|
44
49
|
return;
|
|
@@ -53,7 +58,7 @@ async function main(argv) {
|
|
|
53
58
|
function normalizeCommand(command) {
|
|
54
59
|
if (!command || command === "serve" || command === "start")
|
|
55
60
|
return "serve";
|
|
56
|
-
if (command === "init" || command === "doctor" || command === "config" || command === "agents")
|
|
61
|
+
if (command === "init" || command === "doctor" || command === "config" || command === "hooks" || command === "agents")
|
|
57
62
|
return command;
|
|
58
63
|
if (command === "help" || command === "--help" || command === "-h")
|
|
59
64
|
return "help";
|
|
@@ -261,6 +266,8 @@ function printHelp() {
|
|
|
261
266
|
" forgerelay doctor Show config, runtime, and native dependency status",
|
|
262
267
|
" forgerelay config get Print persisted config",
|
|
263
268
|
" forgerelay config set publicBaseUrl <url|null>",
|
|
269
|
+
" forgerelay hooks list [--project <path>]",
|
|
270
|
+
" forgerelay hooks check [--project <path>]",
|
|
264
271
|
" forgerelay agents ls List subagent sessions",
|
|
265
272
|
" forgerelay agents run <profile-or-provider-or-id> [--model <model>] <prompt>",
|
|
266
273
|
" forgerelay agents show <id>",
|
|
@@ -326,6 +333,7 @@ async function runAgentsRun(args) {
|
|
|
326
333
|
thinking: parsed.thinking ?? existing.thinking,
|
|
327
334
|
latestResponse: undefined,
|
|
328
335
|
error: undefined,
|
|
336
|
+
hookReports: undefined,
|
|
329
337
|
});
|
|
330
338
|
spawnAgentWorker(existing.id, promptFile);
|
|
331
339
|
console.log(formatAgentLine({
|
|
@@ -369,6 +377,9 @@ async function runAgentsShow(args) {
|
|
|
369
377
|
record = store.get(id) ?? record;
|
|
370
378
|
}
|
|
371
379
|
console.log(formatAgentLine(record));
|
|
380
|
+
const hookSummary = formatVisibleHookReports(record.hookReports ?? []);
|
|
381
|
+
if (hookSummary)
|
|
382
|
+
console.log(hookSummary);
|
|
372
383
|
if (record.latestResponse) {
|
|
373
384
|
console.log(record.latestResponse);
|
|
374
385
|
return;
|
|
@@ -391,7 +402,21 @@ async function runAgentsWorker(args) {
|
|
|
391
402
|
const record = store.get(id);
|
|
392
403
|
if (!record)
|
|
393
404
|
throw new Error(`Unknown subagent id: ${id}`);
|
|
394
|
-
|
|
405
|
+
const hooks = new HookRunner(config.hooks, config.logging);
|
|
406
|
+
const hookInvocation = {
|
|
407
|
+
workspaceId: record.workspaceId,
|
|
408
|
+
workspaceRoot: record.workspaceRoot,
|
|
409
|
+
payload: {
|
|
410
|
+
agentId: record.id,
|
|
411
|
+
profile: record.profileName,
|
|
412
|
+
provider: record.provider,
|
|
413
|
+
model: record.model,
|
|
414
|
+
thinking: record.thinking,
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
store.update(record.id, { status: "running", error: undefined, hookReports: undefined });
|
|
418
|
+
const hookReports = await hooks.run("SubagentStart", hookInvocation);
|
|
419
|
+
store.update(record.id, { hookReports });
|
|
395
420
|
try {
|
|
396
421
|
const profiles = await loadLocalAgentProfiles(config, record.workspaceRoot);
|
|
397
422
|
const profile = profiles.find((candidate) => candidate.name === record.profileName);
|
|
@@ -399,17 +424,35 @@ async function runAgentsWorker(args) {
|
|
|
399
424
|
const result = profile
|
|
400
425
|
? await runLocalAgentProfile(profile, record, prompt)
|
|
401
426
|
: await runRawLocalAgentProvider(record, prompt);
|
|
427
|
+
hookReports.push(...await hooks.run("SubagentStop", {
|
|
428
|
+
...hookInvocation,
|
|
429
|
+
payload: {
|
|
430
|
+
...hookInvocation.payload,
|
|
431
|
+
status: "idle",
|
|
432
|
+
providerSessionId: result.providerSessionId,
|
|
433
|
+
},
|
|
434
|
+
}));
|
|
402
435
|
store.update(record.id, {
|
|
403
436
|
providerSessionId: result.providerSessionId ?? undefined,
|
|
404
437
|
status: "idle",
|
|
405
438
|
latestResponse: result.finalResponse,
|
|
406
439
|
error: undefined,
|
|
440
|
+
hookReports,
|
|
407
441
|
});
|
|
408
442
|
}
|
|
409
443
|
catch (error) {
|
|
444
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
445
|
+
hookReports.push(...await hooks.run("SubagentStop", {
|
|
446
|
+
...hookInvocation,
|
|
447
|
+
payload: {
|
|
448
|
+
...hookInvocation.payload,
|
|
449
|
+
status: "error",
|
|
450
|
+
},
|
|
451
|
+
}));
|
|
410
452
|
store.update(record.id, {
|
|
411
453
|
status: "error",
|
|
412
|
-
error:
|
|
454
|
+
error: message,
|
|
455
|
+
hookReports,
|
|
413
456
|
});
|
|
414
457
|
}
|
|
415
458
|
}
|
package/dist/config.js
CHANGED
|
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join, resolve } from "node:path";
|
|
4
4
|
import { expandHomePath } from "./roots.js";
|
|
5
|
+
import { mergeHookConfigs, parseHookConfig } from "./hooks.js";
|
|
5
6
|
import { forgerelayAgentsDir, forgerelaySkillsDir, loadForgeRelayFiles } from "./user-config.js";
|
|
6
7
|
const DEFAULT_OAUTH_ACCESS_TOKEN_TTL_SECONDS = 60 * 60;
|
|
7
8
|
const DEFAULT_OAUTH_REFRESH_TOKEN_TTL_SECONDS = 30 * 24 * 60 * 60;
|
|
@@ -80,10 +81,10 @@ function parseLogLevel(value) {
|
|
|
80
81
|
throw new Error(`Invalid FORGERELAY_LOG_LEVEL: ${value}`);
|
|
81
82
|
}
|
|
82
83
|
function parseLogFormat(value) {
|
|
83
|
-
if (!value || value === "
|
|
84
|
-
return "json";
|
|
85
|
-
if (value === "pretty")
|
|
84
|
+
if (!value || value === "pretty")
|
|
86
85
|
return "pretty";
|
|
86
|
+
if (value === "json")
|
|
87
|
+
return "json";
|
|
87
88
|
throw new Error(`Invalid FORGERELAY_LOG_FORMAT: ${value}`);
|
|
88
89
|
}
|
|
89
90
|
function parsePathList(value) {
|
|
@@ -109,15 +110,17 @@ function parsePositiveInteger(value, fallback, name, max = Number.MAX_SAFE_INTEG
|
|
|
109
110
|
return parsed;
|
|
110
111
|
}
|
|
111
112
|
function parseLoggingConfig(env) {
|
|
113
|
+
const format = parseLogFormat(productEnv(env, "LOG_FORMAT"));
|
|
112
114
|
const requests = productEnv(env, "LOG_REQUESTS");
|
|
113
115
|
const toolCalls = productEnv(env, "LOG_TOOL_CALLS");
|
|
116
|
+
const shellCommands = productEnv(env, "LOG_SHELL_COMMANDS");
|
|
114
117
|
return {
|
|
115
118
|
level: parseLogLevel(productEnv(env, "LOG_LEVEL")),
|
|
116
|
-
format
|
|
117
|
-
requests: requests === undefined ?
|
|
119
|
+
format,
|
|
120
|
+
requests: requests === undefined ? format === "json" : parseBoolean(requests),
|
|
118
121
|
assets: parseBoolean(productEnv(env, "LOG_ASSETS")),
|
|
119
122
|
toolCalls: toolCalls === undefined ? true : parseBoolean(toolCalls),
|
|
120
|
-
shellCommands:
|
|
123
|
+
shellCommands: shellCommands === undefined ? format === "pretty" : parseBoolean(shellCommands),
|
|
121
124
|
trustProxy: parseBoolean(productEnv(env, "TRUST_PROXY")),
|
|
122
125
|
};
|
|
123
126
|
}
|
|
@@ -214,6 +217,7 @@ export function loadConfig(env = process.env) {
|
|
|
214
217
|
: parseBoolean(productEnv(env, "SUBAGENTS")),
|
|
215
218
|
agentDir: resolve(expandHomePath(productEnv(env, "AGENT_DIR") ?? files.config.agentDir ?? defaultAgentDir())),
|
|
216
219
|
systemInstructionsPath: parseSystemInstructionsPath(productEnv(env, "SYSTEM_INSTRUCTIONS_PATH") ?? files.config.systemInstructionsPath),
|
|
220
|
+
hooks: mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles),
|
|
217
221
|
logging: parseLoggingConfig(env),
|
|
218
222
|
};
|
|
219
223
|
}
|
package/dist/db/migrations.js
CHANGED
|
@@ -24,6 +24,11 @@ const migrations = [
|
|
|
24
24
|
name: "workspace-worktree-branches",
|
|
25
25
|
up: migrateWorkspaceWorktreeBranches,
|
|
26
26
|
},
|
|
27
|
+
{
|
|
28
|
+
version: 6,
|
|
29
|
+
name: "local-agent-hook-reports",
|
|
30
|
+
up: migrateLocalAgentHookReports,
|
|
31
|
+
},
|
|
27
32
|
];
|
|
28
33
|
export function migrateDatabase(sqlite) {
|
|
29
34
|
const migrate = sqlite.transaction(() => {
|
|
@@ -181,6 +186,9 @@ function migrateWorkspaceWorktreeBranches(sqlite) {
|
|
|
181
186
|
addColumnIfMissing(sqlite, "workspace_sessions", "branch", "text");
|
|
182
187
|
addColumnIfMissing(sqlite, "workspace_sessions", "target_branch", "text");
|
|
183
188
|
}
|
|
189
|
+
function migrateLocalAgentHookReports(sqlite) {
|
|
190
|
+
addColumnIfMissing(sqlite, "local_agent_sessions", "hook_reports_json", "text");
|
|
191
|
+
}
|
|
184
192
|
function addColumnIfMissing(sqlite, table, column, definition) {
|
|
185
193
|
const columns = sqlite.prepare(`pragma table_info(${table})`).all();
|
|
186
194
|
if (columns.some((existingColumn) => existingColumn.name === column))
|
package/dist/db/schema.js
CHANGED
|
@@ -76,6 +76,7 @@ export const localAgentSessions = sqliteTable("local_agent_sessions", {
|
|
|
76
76
|
status: text("status").notNull(),
|
|
77
77
|
latestResponse: text("latest_response"),
|
|
78
78
|
error: text("error"),
|
|
79
|
+
hookReportsJson: text("hook_reports_json"),
|
|
79
80
|
createdAt: text("created_at").notNull(),
|
|
80
81
|
updatedAt: text("updated_at").notNull(),
|
|
81
82
|
}, (table) => [
|
package/dist/hook-cli.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
import { HOOK_EVENTS, loadProjectHookConfig, mergeHookConfigs, parseHookConfig, } from "./hooks.js";
|
|
3
|
+
import { loadForgeRelayFiles } from "./user-config.js";
|
|
4
|
+
export async function runHooksCommand(args) {
|
|
5
|
+
const [subcommand, ...rest] = args;
|
|
6
|
+
if (!subcommand || ["help", "--help", "-h"].includes(subcommand)) {
|
|
7
|
+
printHooksHelp();
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
const projectRoot = parseProjectRoot(rest);
|
|
11
|
+
const globalHooks = loadGlobalHooks();
|
|
12
|
+
const project = await loadProjectHookConfig(projectRoot);
|
|
13
|
+
const globalEntries = flattenHooks(globalHooks, "global");
|
|
14
|
+
const projectEntries = flattenHooks(project.hooks, "project");
|
|
15
|
+
if (subcommand === "list") {
|
|
16
|
+
for (const entry of [...globalEntries, ...projectEntries]) {
|
|
17
|
+
console.log(formatHookEntry(entry));
|
|
18
|
+
}
|
|
19
|
+
if (globalEntries.length === 0 && projectEntries.length === 0) {
|
|
20
|
+
console.log("No hooks configured.");
|
|
21
|
+
}
|
|
22
|
+
if (project.diagnostic) {
|
|
23
|
+
console.error(`Project hooks diagnostic: ${project.diagnostic}`);
|
|
24
|
+
}
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (subcommand === "check") {
|
|
28
|
+
if (project.diagnostic) {
|
|
29
|
+
throw new Error(`Hook check failed: ${project.diagnostic}`);
|
|
30
|
+
}
|
|
31
|
+
console.log(`Hooks OK: ${globalEntries.length} global, ${projectEntries.length} project`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
throw new Error(`Unknown hooks command: ${subcommand}`);
|
|
35
|
+
}
|
|
36
|
+
function loadGlobalHooks() {
|
|
37
|
+
const files = loadForgeRelayFiles();
|
|
38
|
+
return mergeHookConfigs(parseHookConfig(files.config.hooks), parseHookConfig(files.hooks), files.hookFiles);
|
|
39
|
+
}
|
|
40
|
+
function parseProjectRoot(args) {
|
|
41
|
+
let projectRoot = process.cwd();
|
|
42
|
+
for (let index = 0; index < args.length; index += 1) {
|
|
43
|
+
const arg = args[index];
|
|
44
|
+
if (arg !== "--project") {
|
|
45
|
+
throw new Error(`Unknown hooks option: ${arg}`);
|
|
46
|
+
}
|
|
47
|
+
const value = args[index + 1];
|
|
48
|
+
if (!value)
|
|
49
|
+
throw new Error("Usage: forgerelay hooks <list|check> [--project <path>]");
|
|
50
|
+
projectRoot = resolve(value);
|
|
51
|
+
index += 1;
|
|
52
|
+
}
|
|
53
|
+
return projectRoot;
|
|
54
|
+
}
|
|
55
|
+
function flattenHooks(config, scope) {
|
|
56
|
+
const entries = [];
|
|
57
|
+
for (const event of HOOK_EVENTS) {
|
|
58
|
+
let handlerIndex = 0;
|
|
59
|
+
for (const rule of config[event] ?? []) {
|
|
60
|
+
for (const handler of rule.handlers) {
|
|
61
|
+
handlerIndex += 1;
|
|
62
|
+
entries.push({
|
|
63
|
+
scope,
|
|
64
|
+
event,
|
|
65
|
+
name: handler.name ?? `${event} handler ${handlerIndex}`,
|
|
66
|
+
matcher: rule.matcher,
|
|
67
|
+
command: handler.command,
|
|
68
|
+
timeoutSeconds: handler.timeoutSeconds,
|
|
69
|
+
report: handler.report,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return entries;
|
|
75
|
+
}
|
|
76
|
+
function formatHookEntry(entry) {
|
|
77
|
+
const matcher = formatMatcher(entry.matcher);
|
|
78
|
+
return `${entry.scope} ${entry.name} ${entry.event} ${matcher} timeout=${entry.timeoutSeconds}s report=${entry.report} :: ${entry.command}`;
|
|
79
|
+
}
|
|
80
|
+
function formatMatcher(matcher) {
|
|
81
|
+
if (!matcher)
|
|
82
|
+
return "matcher=*";
|
|
83
|
+
const parts = [
|
|
84
|
+
matcher.tool ? `tool=${matcher.tool}` : undefined,
|
|
85
|
+
matcher.commandRegex ? `commandRegex=${matcher.commandRegex}` : undefined,
|
|
86
|
+
matcher.pathRegex ? `pathRegex=${matcher.pathRegex}` : undefined,
|
|
87
|
+
matcher.provider ? `provider=${matcher.provider}` : undefined,
|
|
88
|
+
matcher.workspaceMode ? `workspaceMode=${matcher.workspaceMode}` : undefined,
|
|
89
|
+
].filter((value) => value !== undefined);
|
|
90
|
+
return parts.length > 0 ? parts.join(" ") : "matcher=*";
|
|
91
|
+
}
|
|
92
|
+
function printHooksHelp() {
|
|
93
|
+
console.log([
|
|
94
|
+
"ForgeRelay hooks",
|
|
95
|
+
"",
|
|
96
|
+
"Usage:",
|
|
97
|
+
" forgerelay hooks list [--project <path>]",
|
|
98
|
+
" forgerelay hooks check [--project <path>]",
|
|
99
|
+
].join("\n"));
|
|
100
|
+
}
|