@zhijiewang/openharness 2.34.0 → 2.36.0
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 +22 -0
- package/README.zh-CN.md +22 -0
- package/dist/acp/agent.d.ts +96 -0
- package/dist/acp/agent.js +246 -0
- package/dist/acp/server.d.ts +14 -0
- package/dist/acp/server.js +46 -0
- package/dist/main.js +22 -0
- package/dist/tools/AgentTool/index.d.ts +3 -0
- package/dist/tools/AgentTool/index.js +13 -2
- package/dist/types/permissions.d.ts +17 -0
- package/dist/types/permissions.js +50 -0
- package/package.json +2 -1
package/README.md
CHANGED
|
@@ -563,6 +563,17 @@ Agent({ subagent_type: 'architect', prompt: 'Plan a migration from option A to o
|
|
|
563
563
|
Agent({ subagent_type: 'editor', prompt: '<paste plan>' })
|
|
564
564
|
```
|
|
565
565
|
|
|
566
|
+
### Sub-agent permission isolation
|
|
567
|
+
|
|
568
|
+
Each `Agent` call accepts a `permission_mode` override that **narrows** the parent's permission mode (never loosens it). Useful when running in `trust` and you want a subagent's review/audit pass to stay strictly read-only:
|
|
569
|
+
|
|
570
|
+
```
|
|
571
|
+
Agent({ subagent_type: 'code-reviewer', prompt: '...', permission_mode: 'plan' })
|
|
572
|
+
Agent({ subagent_type: 'security-auditor', prompt: '...', permission_mode: 'deny' })
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
If a less-restrictive mode is requested (e.g. parent is `ask`, subagent requests `trust`), the harness silently clamps to the parent — a model can never use a sub-agent to escape user-approval gates.
|
|
576
|
+
|
|
566
577
|
## Headless Mode
|
|
567
578
|
|
|
568
579
|
Run a single prompt without interactive UI — perfect for CI/CD and scripting:
|
|
@@ -695,6 +706,17 @@ oh --model llamacpp/my-model
|
|
|
695
706
|
oh models # list available models
|
|
696
707
|
```
|
|
697
708
|
|
|
709
|
+
## ACP (Agent Client Protocol)
|
|
710
|
+
|
|
711
|
+
Speak [Agent Client Protocol](https://agentclientprotocol.com/) over stdin/stdout so editors that support ACP — Zed, JetBrains via the ACP plugin, Cline, OpenCode — can drive openHarness as the underlying agent. No bespoke IDE extension required:
|
|
712
|
+
|
|
713
|
+
```bash
|
|
714
|
+
oh acp # uses provider/model from .oh/config.yaml
|
|
715
|
+
oh acp --provider anthropic --model claude-sonnet-4-6
|
|
716
|
+
```
|
|
717
|
+
|
|
718
|
+
Configure your editor's ACP integration to launch `oh acp` as the agent command. The session-update events (text chunks, tool calls, tool results) are translated automatically from openHarness's stream protocol; permission prompts currently use openHarness's own flow rather than the ACP `requestPermission` path (filed for follow-up). The `@agentclientprotocol/sdk` package is an `optionalDependency` — if it didn't install, `oh acp` exits with a clear install hint rather than silently failing.
|
|
719
|
+
|
|
698
720
|
## Auth
|
|
699
721
|
|
|
700
722
|
Provider-agnostic credential management. Local LLMs (Ollama / llama.cpp / LM Studio) need no auth — configure them via `oh init`.
|
package/README.zh-CN.md
CHANGED
|
@@ -563,6 +563,17 @@ Agent({ subagent_type: 'architect', prompt: 'Plan a migration from option A to o
|
|
|
563
563
|
Agent({ subagent_type: 'editor', prompt: '<paste plan>' })
|
|
564
564
|
```
|
|
565
565
|
|
|
566
|
+
### 子代理的权限隔离
|
|
567
|
+
|
|
568
|
+
`Agent` 调用支持 `permission_mode` 参数,**只能收紧不能放宽**父级的权限模式。当父代理跑在 `trust` 但你希望某个评审/审计子代理保持只读时尤其有用:
|
|
569
|
+
|
|
570
|
+
```
|
|
571
|
+
Agent({ subagent_type: 'code-reviewer', prompt: '...', permission_mode: 'plan' })
|
|
572
|
+
Agent({ subagent_type: 'security-auditor', prompt: '...', permission_mode: 'deny' })
|
|
573
|
+
```
|
|
574
|
+
|
|
575
|
+
如果请求的模式比父级更宽松(比如父级 `ask`、子代理请求 `trust`),harness 会静默回退到父级的模式 —— 模型永远不能借助子代理绕过用户的批准门。
|
|
576
|
+
|
|
566
577
|
## 无头模式
|
|
567
578
|
|
|
568
579
|
跑一次提示词,不走交互 UI —— 适合 CI/CD 和脚本化:
|
|
@@ -694,6 +705,17 @@ oh --model llamacpp/my-model
|
|
|
694
705
|
oh models # 列出可用模型
|
|
695
706
|
```
|
|
696
707
|
|
|
708
|
+
## ACP(Agent Client Protocol)
|
|
709
|
+
|
|
710
|
+
通过 stdin/stdout 讲 [Agent Client Protocol](https://agentclientprotocol.com/),让支持 ACP 的编辑器 —— Zed、通过 ACP 插件接入的 JetBrains、Cline、OpenCode 等 —— 把 openHarness 当作底层 agent 来驱动,无需为每个 IDE 单独写扩展:
|
|
711
|
+
|
|
712
|
+
```bash
|
|
713
|
+
oh acp # 读取 .oh/config.yaml 的 provider/model
|
|
714
|
+
oh acp --provider anthropic --model claude-sonnet-4-6
|
|
715
|
+
```
|
|
716
|
+
|
|
717
|
+
在编辑器的 ACP 集成里把 `oh acp` 配成 agent 启动命令即可。session-update 事件(文本块、工具调用、工具结果)由 openHarness 的流式协议自动翻译过去;权限确认目前仍走 openHarness 自己的流程,没有走 ACP 的 `requestPermission`(已记入后续跟进)。`@agentclientprotocol/sdk` 是 `optionalDependency` —— 如果没装上,`oh acp` 会带着清楚的安装提示退出,不会静默失败。
|
|
718
|
+
|
|
697
719
|
## 鉴权(Auth)
|
|
698
720
|
|
|
699
721
|
提供商无关的凭据管理。本地 LLM(Ollama / llama.cpp / LM Studio)无需鉴权 —— 通过 `oh init` 配置即可。
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP (Agent Client Protocol) bridge — exposes openHarness as an agent that
|
|
3
|
+
* Zed / JetBrains / Cursor / Cline can talk to via JSON-RPC over stdio.
|
|
4
|
+
*
|
|
5
|
+
* Spec: https://agentclientprotocol.com/
|
|
6
|
+
* SDK: @agentclientprotocol/sdk (optional dependency)
|
|
7
|
+
*
|
|
8
|
+
* Responsibility split:
|
|
9
|
+
* - This module implements the `Agent` interface from the SDK.
|
|
10
|
+
* - The SDK handles JSON-RPC framing, schema validation, and notification
|
|
11
|
+
* dispatch; we only own the OH ↔ ACP event translation.
|
|
12
|
+
*
|
|
13
|
+
* Event translation (the only interesting part of this file):
|
|
14
|
+
*
|
|
15
|
+
* OH StreamEvent → ACP session/update
|
|
16
|
+
* --------------------------- ------------------------------------------------
|
|
17
|
+
* text_delta → agent_message_chunk { content: { type: text } }
|
|
18
|
+
* thinking_delta → agent_thought_chunk { content: { type: text } }
|
|
19
|
+
* tool_call_start → tool_call { status: pending, kind: <derived> }
|
|
20
|
+
* tool_call_end → tool_call_update { status: completed, content }
|
|
21
|
+
* tool_output_delta → tool_call_update { content: <appended> }
|
|
22
|
+
* error → end-of-turn with stopReason: refusal (logged)
|
|
23
|
+
* turn_complete → prompt response: { stopReason: end_turn }
|
|
24
|
+
*
|
|
25
|
+
* What's NOT bridged in v2.35:
|
|
26
|
+
* - permission_request → ACP requestPermission (uses OH's own permission flow today)
|
|
27
|
+
* - cost_update → ACP _meta passthrough (filed for follow-up)
|
|
28
|
+
* - rate_limited → currently surfaced via stderr only; an ACP
|
|
29
|
+
* session/update with retry hint is filed for follow-up.
|
|
30
|
+
*
|
|
31
|
+
* Why optional dep: the SDK ships ~750KB of generated zod schemas. Most OH
|
|
32
|
+
* users never hit the ACP path; they shouldn't pay that disk + import cost.
|
|
33
|
+
*/
|
|
34
|
+
import type { StreamEvent } from "../types/events.js";
|
|
35
|
+
type AcpConnection = {
|
|
36
|
+
sessionUpdate: (params: unknown) => Promise<void>;
|
|
37
|
+
};
|
|
38
|
+
export type AcpAgentConfig = {
|
|
39
|
+
/** OH provider name: "anthropic", "openai", "ollama", … */
|
|
40
|
+
provider: string;
|
|
41
|
+
/** OH model identifier (e.g. "claude-sonnet-4-6") */
|
|
42
|
+
model: string;
|
|
43
|
+
/** Working directory (defaults to process.cwd()) */
|
|
44
|
+
cwd?: string;
|
|
45
|
+
/** Inject API key (otherwise resolved via OH's normal credential chain) */
|
|
46
|
+
apiKey?: string;
|
|
47
|
+
};
|
|
48
|
+
/**
|
|
49
|
+
* Translate one OH StreamEvent into zero-or-more ACP `session/update`
|
|
50
|
+
* notifications. Pure function — no I/O, no SDK dependency. This is the
|
|
51
|
+
* load-bearing piece that the rest of the bridge orchestrates.
|
|
52
|
+
*
|
|
53
|
+
* Returns an array because some OH events map to no ACP update (cost_update,
|
|
54
|
+
* turn_complete) and we always want a uniform shape for callers.
|
|
55
|
+
*/
|
|
56
|
+
export declare function bridgeStreamEventToAcp(event: StreamEvent, sessionId: string): Array<{
|
|
57
|
+
sessionId: string;
|
|
58
|
+
update: Record<string, unknown>;
|
|
59
|
+
}>;
|
|
60
|
+
/**
|
|
61
|
+
* Concatenate the text blocks of an ACP PromptRequest's `prompt` array into
|
|
62
|
+
* the single string our `OhAgent.run/stream` expects. Resource-link blocks
|
|
63
|
+
* surface as `[resource: <uri>]` markers so the model is aware of them but
|
|
64
|
+
* doesn't try to inline-include the content (the spec wants us to optionally
|
|
65
|
+
* `readTextFile`-fetch them; that's a v2.36 follow-up).
|
|
66
|
+
*/
|
|
67
|
+
export declare function extractPromptText(prompt: ReadonlyArray<{
|
|
68
|
+
type: string;
|
|
69
|
+
[key: string]: unknown;
|
|
70
|
+
}>): string;
|
|
71
|
+
/**
|
|
72
|
+
* Construct an ACP Agent wired to OH's `OhAgent` SDK class.
|
|
73
|
+
*
|
|
74
|
+
* The connection is the AgentSideConnection from the SDK; we pass it in so
|
|
75
|
+
* tests can stub it without loading the SDK.
|
|
76
|
+
*/
|
|
77
|
+
export declare function createAcpAgent(connection: AcpConnection, config: AcpAgentConfig): {
|
|
78
|
+
initialize(_params: unknown): Promise<unknown>;
|
|
79
|
+
newSession(_params: unknown): Promise<unknown>;
|
|
80
|
+
authenticate(_params: unknown): Promise<Record<string, never>>;
|
|
81
|
+
setSessionMode(_params: unknown): Promise<Record<string, never>>;
|
|
82
|
+
prompt(params: {
|
|
83
|
+
sessionId: string;
|
|
84
|
+
prompt: ReadonlyArray<{
|
|
85
|
+
type: string;
|
|
86
|
+
[k: string]: unknown;
|
|
87
|
+
}>;
|
|
88
|
+
}): Promise<{
|
|
89
|
+
stopReason: "end_turn" | "cancelled" | "refusal";
|
|
90
|
+
}>;
|
|
91
|
+
cancel(params: {
|
|
92
|
+
sessionId: string;
|
|
93
|
+
}): Promise<void>;
|
|
94
|
+
};
|
|
95
|
+
export {};
|
|
96
|
+
//# sourceMappingURL=agent.d.ts.map
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP (Agent Client Protocol) bridge — exposes openHarness as an agent that
|
|
3
|
+
* Zed / JetBrains / Cursor / Cline can talk to via JSON-RPC over stdio.
|
|
4
|
+
*
|
|
5
|
+
* Spec: https://agentclientprotocol.com/
|
|
6
|
+
* SDK: @agentclientprotocol/sdk (optional dependency)
|
|
7
|
+
*
|
|
8
|
+
* Responsibility split:
|
|
9
|
+
* - This module implements the `Agent` interface from the SDK.
|
|
10
|
+
* - The SDK handles JSON-RPC framing, schema validation, and notification
|
|
11
|
+
* dispatch; we only own the OH ↔ ACP event translation.
|
|
12
|
+
*
|
|
13
|
+
* Event translation (the only interesting part of this file):
|
|
14
|
+
*
|
|
15
|
+
* OH StreamEvent → ACP session/update
|
|
16
|
+
* --------------------------- ------------------------------------------------
|
|
17
|
+
* text_delta → agent_message_chunk { content: { type: text } }
|
|
18
|
+
* thinking_delta → agent_thought_chunk { content: { type: text } }
|
|
19
|
+
* tool_call_start → tool_call { status: pending, kind: <derived> }
|
|
20
|
+
* tool_call_end → tool_call_update { status: completed, content }
|
|
21
|
+
* tool_output_delta → tool_call_update { content: <appended> }
|
|
22
|
+
* error → end-of-turn with stopReason: refusal (logged)
|
|
23
|
+
* turn_complete → prompt response: { stopReason: end_turn }
|
|
24
|
+
*
|
|
25
|
+
* What's NOT bridged in v2.35:
|
|
26
|
+
* - permission_request → ACP requestPermission (uses OH's own permission flow today)
|
|
27
|
+
* - cost_update → ACP _meta passthrough (filed for follow-up)
|
|
28
|
+
* - rate_limited → currently surfaced via stderr only; an ACP
|
|
29
|
+
* session/update with retry hint is filed for follow-up.
|
|
30
|
+
*
|
|
31
|
+
* Why optional dep: the SDK ships ~750KB of generated zod schemas. Most OH
|
|
32
|
+
* users never hit the ACP path; they shouldn't pay that disk + import cost.
|
|
33
|
+
*/
|
|
34
|
+
import { Agent as OhAgent } from "../sdk/index.js";
|
|
35
|
+
/**
|
|
36
|
+
* Translate one OH StreamEvent into zero-or-more ACP `session/update`
|
|
37
|
+
* notifications. Pure function — no I/O, no SDK dependency. This is the
|
|
38
|
+
* load-bearing piece that the rest of the bridge orchestrates.
|
|
39
|
+
*
|
|
40
|
+
* Returns an array because some OH events map to no ACP update (cost_update,
|
|
41
|
+
* turn_complete) and we always want a uniform shape for callers.
|
|
42
|
+
*/
|
|
43
|
+
export function bridgeStreamEventToAcp(event, sessionId) {
|
|
44
|
+
switch (event.type) {
|
|
45
|
+
case "text_delta":
|
|
46
|
+
return [
|
|
47
|
+
{
|
|
48
|
+
sessionId,
|
|
49
|
+
update: {
|
|
50
|
+
sessionUpdate: "agent_message_chunk",
|
|
51
|
+
content: { type: "text", text: event.content },
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
];
|
|
55
|
+
case "thinking_delta":
|
|
56
|
+
return [
|
|
57
|
+
{
|
|
58
|
+
sessionId,
|
|
59
|
+
update: {
|
|
60
|
+
sessionUpdate: "agent_thought_chunk",
|
|
61
|
+
content: { type: "text", text: event.content },
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
case "tool_call_start":
|
|
66
|
+
return [
|
|
67
|
+
{
|
|
68
|
+
sessionId,
|
|
69
|
+
update: {
|
|
70
|
+
sessionUpdate: "tool_call",
|
|
71
|
+
toolCallId: event.callId,
|
|
72
|
+
title: event.toolName,
|
|
73
|
+
kind: deriveToolKind(event.toolName),
|
|
74
|
+
status: "pending",
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
];
|
|
78
|
+
case "tool_call_complete":
|
|
79
|
+
// OH separates "args known" (tool_call_complete) from "result known"
|
|
80
|
+
// (tool_call_end). ACP folds both into tool_call_update. Surface the
|
|
81
|
+
// arguments now so editors can render them while the tool runs.
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
sessionId,
|
|
85
|
+
update: {
|
|
86
|
+
sessionUpdate: "tool_call_update",
|
|
87
|
+
toolCallId: event.callId,
|
|
88
|
+
status: "in_progress",
|
|
89
|
+
rawInput: event.arguments,
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
];
|
|
93
|
+
case "tool_call_end":
|
|
94
|
+
return [
|
|
95
|
+
{
|
|
96
|
+
sessionId,
|
|
97
|
+
update: {
|
|
98
|
+
sessionUpdate: "tool_call_update",
|
|
99
|
+
toolCallId: event.callId,
|
|
100
|
+
status: event.isError ? "failed" : "completed",
|
|
101
|
+
content: [
|
|
102
|
+
{
|
|
103
|
+
type: "content",
|
|
104
|
+
content: { type: "text", text: event.output },
|
|
105
|
+
},
|
|
106
|
+
],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
// Everything else has no ACP equivalent in the v2.35 surface.
|
|
111
|
+
case "tool_output_delta":
|
|
112
|
+
case "permission_request":
|
|
113
|
+
case "ask_user":
|
|
114
|
+
case "cost_update":
|
|
115
|
+
case "turn_complete":
|
|
116
|
+
case "error":
|
|
117
|
+
case "rate_limited":
|
|
118
|
+
return [];
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Map an OH tool name to an ACP tool kind. The kind drives editor UX —
|
|
123
|
+
* Zed colors "edit" tools differently from "read" or "execute" — so getting
|
|
124
|
+
* this approximately right is worth the if-ladder. Unknown tools fall back
|
|
125
|
+
* to "other" rather than guessing.
|
|
126
|
+
*/
|
|
127
|
+
function deriveToolKind(toolName) {
|
|
128
|
+
const name = toolName.toLowerCase();
|
|
129
|
+
if (name === "read" || name.endsWith("read") || name === "imageread")
|
|
130
|
+
return "read";
|
|
131
|
+
if (name === "edit" || name === "write" || name === "multiedit" || name === "notebookedit")
|
|
132
|
+
return "edit";
|
|
133
|
+
if (name === "bash" || name === "powershell" || name === "killprocess")
|
|
134
|
+
return "execute";
|
|
135
|
+
if (name === "glob" || name === "grep" || name === "ls")
|
|
136
|
+
return "search";
|
|
137
|
+
if (name === "webfetch" || name === "websearch" || name === "exasearch")
|
|
138
|
+
return "fetch";
|
|
139
|
+
if (name === "todowrite" || name === "memory")
|
|
140
|
+
return "think";
|
|
141
|
+
return "other";
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Concatenate the text blocks of an ACP PromptRequest's `prompt` array into
|
|
145
|
+
* the single string our `OhAgent.run/stream` expects. Resource-link blocks
|
|
146
|
+
* surface as `[resource: <uri>]` markers so the model is aware of them but
|
|
147
|
+
* doesn't try to inline-include the content (the spec wants us to optionally
|
|
148
|
+
* `readTextFile`-fetch them; that's a v2.36 follow-up).
|
|
149
|
+
*/
|
|
150
|
+
export function extractPromptText(prompt) {
|
|
151
|
+
const parts = [];
|
|
152
|
+
for (const block of prompt) {
|
|
153
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
154
|
+
parts.push(block.text);
|
|
155
|
+
}
|
|
156
|
+
else if (block.type === "resource_link" && typeof block.uri === "string") {
|
|
157
|
+
parts.push(`[resource: ${block.uri}]`);
|
|
158
|
+
}
|
|
159
|
+
else if (block.type === "resource") {
|
|
160
|
+
// Embedded resource — we don't fetch the content here; just surface
|
|
161
|
+
// a reference so the model doesn't ignore the attachment.
|
|
162
|
+
const uri = block.resource?.uri;
|
|
163
|
+
parts.push(uri ? `[resource: ${uri}]` : "[embedded resource]");
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return parts.join("\n\n");
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Construct an ACP Agent wired to OH's `OhAgent` SDK class.
|
|
170
|
+
*
|
|
171
|
+
* The connection is the AgentSideConnection from the SDK; we pass it in so
|
|
172
|
+
* tests can stub it without loading the SDK.
|
|
173
|
+
*/
|
|
174
|
+
export function createAcpAgent(connection, config) {
|
|
175
|
+
const sessions = new Map();
|
|
176
|
+
return {
|
|
177
|
+
async initialize(_params) {
|
|
178
|
+
return {
|
|
179
|
+
// SDK's PROTOCOL_VERSION constant is 1 today; hardcoded so this
|
|
180
|
+
// module doesn't import the SDK at type-check time.
|
|
181
|
+
protocolVersion: 1,
|
|
182
|
+
agentCapabilities: {
|
|
183
|
+
loadSession: false,
|
|
184
|
+
},
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
async newSession(_params) {
|
|
188
|
+
const sessionId = crypto.randomUUID();
|
|
189
|
+
const agent = new OhAgent({
|
|
190
|
+
provider: config.provider,
|
|
191
|
+
model: config.model,
|
|
192
|
+
...(config.apiKey ? { apiKey: config.apiKey } : {}),
|
|
193
|
+
...(config.cwd ? { cwd: config.cwd } : {}),
|
|
194
|
+
permissionMode: "trust",
|
|
195
|
+
});
|
|
196
|
+
sessions.set(sessionId, { abort: new AbortController(), agent });
|
|
197
|
+
return { sessionId };
|
|
198
|
+
},
|
|
199
|
+
async authenticate(_params) {
|
|
200
|
+
// OH resolves credentials from its own chain (env vars / keychain / config);
|
|
201
|
+
// we don't gate session creation on an explicit ACP authenticate call.
|
|
202
|
+
return {};
|
|
203
|
+
},
|
|
204
|
+
async setSessionMode(_params) {
|
|
205
|
+
// Modes (ask/architect/code) aren't exposed yet — return success so
|
|
206
|
+
// editors that try to set one don't error.
|
|
207
|
+
return {};
|
|
208
|
+
},
|
|
209
|
+
async prompt(params) {
|
|
210
|
+
const session = sessions.get(params.sessionId);
|
|
211
|
+
if (!session)
|
|
212
|
+
throw new Error(`Session ${params.sessionId} not found`);
|
|
213
|
+
// A new prompt cancels any prior in-flight prompt for this session.
|
|
214
|
+
session.abort.abort();
|
|
215
|
+
session.abort = new AbortController();
|
|
216
|
+
const promptText = extractPromptText(params.prompt);
|
|
217
|
+
try {
|
|
218
|
+
for await (const event of session.agent.stream(promptText)) {
|
|
219
|
+
if (session.abort.signal.aborted)
|
|
220
|
+
return { stopReason: "cancelled" };
|
|
221
|
+
for (const update of bridgeStreamEventToAcp(event, params.sessionId)) {
|
|
222
|
+
await connection.sessionUpdate(update);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return { stopReason: "end_turn" };
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
if (session.abort.signal.aborted)
|
|
229
|
+
return { stopReason: "cancelled" };
|
|
230
|
+
// Surface unexpected errors as a refusal so the editor stops the spinner.
|
|
231
|
+
await connection.sessionUpdate({
|
|
232
|
+
sessionId: params.sessionId,
|
|
233
|
+
update: {
|
|
234
|
+
sessionUpdate: "agent_message_chunk",
|
|
235
|
+
content: { type: "text", text: `[agent error] ${err.message}` },
|
|
236
|
+
},
|
|
237
|
+
});
|
|
238
|
+
return { stopReason: "refusal" };
|
|
239
|
+
}
|
|
240
|
+
},
|
|
241
|
+
async cancel(params) {
|
|
242
|
+
sessions.get(params.sessionId)?.abort.abort();
|
|
243
|
+
},
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
//# sourceMappingURL=agent.js.map
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP server entry point — `oh acp`.
|
|
3
|
+
*
|
|
4
|
+
* Loads the optional `@agentclientprotocol/sdk` package, wires stdin/stdout
|
|
5
|
+
* via its ndJsonStream to the bridge in `agent.ts`, and runs until the
|
|
6
|
+
* client disconnects.
|
|
7
|
+
*
|
|
8
|
+
* Dies cleanly with an install hint if the SDK isn't available — unlike
|
|
9
|
+
* sandbox-runtime where missing-dep is a silent no-op, here the user
|
|
10
|
+
* explicitly invoked `oh acp` so a clear error is the right UX.
|
|
11
|
+
*/
|
|
12
|
+
import { type AcpAgentConfig } from "./agent.js";
|
|
13
|
+
export declare function runAcpServer(config: AcpAgentConfig): Promise<void>;
|
|
14
|
+
//# sourceMappingURL=server.d.ts.map
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACP server entry point — `oh acp`.
|
|
3
|
+
*
|
|
4
|
+
* Loads the optional `@agentclientprotocol/sdk` package, wires stdin/stdout
|
|
5
|
+
* via its ndJsonStream to the bridge in `agent.ts`, and runs until the
|
|
6
|
+
* client disconnects.
|
|
7
|
+
*
|
|
8
|
+
* Dies cleanly with an install hint if the SDK isn't available — unlike
|
|
9
|
+
* sandbox-runtime where missing-dep is a silent no-op, here the user
|
|
10
|
+
* explicitly invoked `oh acp` so a clear error is the right UX.
|
|
11
|
+
*/
|
|
12
|
+
import { Readable, Writable } from "node:stream";
|
|
13
|
+
import { createAcpAgent } from "./agent.js";
|
|
14
|
+
export async function runAcpServer(config) {
|
|
15
|
+
let acp;
|
|
16
|
+
try {
|
|
17
|
+
acp = await import("@agentclientprotocol/sdk");
|
|
18
|
+
}
|
|
19
|
+
catch (err) {
|
|
20
|
+
process.stderr.write("ACP server requires @agentclientprotocol/sdk. Install with:\n npm install -g @agentclientprotocol/sdk\nor reinstall openHarness — the package ships as an optionalDependency.\n");
|
|
21
|
+
process.stderr.write(`Original error: ${err.message}\n`);
|
|
22
|
+
process.exit(1);
|
|
23
|
+
}
|
|
24
|
+
// ACP wire framing: NDJSON over stdio. The SDK gives us a ready-made stream
|
|
25
|
+
// adapter; we just hand it our process pipes.
|
|
26
|
+
// ndJsonStream(input, output) — input is the AGENT's input (= stdin), output
|
|
27
|
+
// is the AGENT's output (= stdout). Names are direction-from-the-stream's
|
|
28
|
+
// perspective, which inverts the Node convention (stdin/stdout are named
|
|
29
|
+
// from the process's perspective). That's why this looks backwards.
|
|
30
|
+
const input = Writable.toWeb(process.stdout);
|
|
31
|
+
const output = Readable.toWeb(process.stdin);
|
|
32
|
+
const stream = acp.ndJsonStream(input, output);
|
|
33
|
+
// The SDK constructs the connection and wires our handlers. We wrap our
|
|
34
|
+
// bridge in a factory because the SDK passes the connection into it; we
|
|
35
|
+
// need the connection to send sessionUpdate notifications back. The
|
|
36
|
+
// returned object retains the wiring internally — we don't need to keep
|
|
37
|
+
// a reference, but we still construct it so the side effects happen.
|
|
38
|
+
void new acp.AgentSideConnection((conn) => createAcpAgent(conn, config), stream);
|
|
39
|
+
// Block until stdin closes (client disconnected). Without this the process
|
|
40
|
+
// would exit immediately after wiring.
|
|
41
|
+
await new Promise((resolve) => {
|
|
42
|
+
process.stdin.on("end", resolve);
|
|
43
|
+
process.stdin.on("close", resolve);
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
//# sourceMappingURL=server.js.map
|
package/dist/main.js
CHANGED
|
@@ -1078,6 +1078,28 @@ program
|
|
|
1078
1078
|
}
|
|
1079
1079
|
console.log();
|
|
1080
1080
|
});
|
|
1081
|
+
// ── acp (Agent Client Protocol server) ──
|
|
1082
|
+
//
|
|
1083
|
+
// Speaks ACP (https://agentclientprotocol.com/) over stdin/stdout so editors
|
|
1084
|
+
// like Zed and JetBrains can drive openHarness as a coding agent. The SDK is
|
|
1085
|
+
// an optional dependency to keep the default install footprint small —
|
|
1086
|
+
// `oh acp` exits cleanly with an install hint if the SDK isn't present.
|
|
1087
|
+
program
|
|
1088
|
+
.command("acp")
|
|
1089
|
+
.description("Start an ACP (Agent Client Protocol) server over stdio. Usage: configure your editor (Zed/JetBrains/Cline) to launch `oh acp` as the agent command.")
|
|
1090
|
+
.option("-m, --model <model>", "Model to use (defaults to .oh/config.yaml's model)")
|
|
1091
|
+
.option("-p, --provider <provider>", "Provider name (defaults to .oh/config.yaml's provider)")
|
|
1092
|
+
.action(async (opts) => {
|
|
1093
|
+
const cfg = readOhConfig();
|
|
1094
|
+
const model = opts.model ?? cfg?.model;
|
|
1095
|
+
const provider = opts.provider ?? cfg?.provider;
|
|
1096
|
+
if (!model || !provider) {
|
|
1097
|
+
process.stderr.write("ACP server needs both a model and a provider. Pass --model and --provider, or run `oh init` first.\n");
|
|
1098
|
+
process.exit(1);
|
|
1099
|
+
}
|
|
1100
|
+
const { runAcpServer } = await import("./acp/server.js");
|
|
1101
|
+
await runAcpServer({ provider, model, cwd: process.cwd() });
|
|
1102
|
+
});
|
|
1081
1103
|
/**
|
|
1082
1104
|
* Run the interactive setup wizard. Used by both the `oh init` subcommand and
|
|
1083
1105
|
* the `--init` / `--init-only` flag added to chat / run / session (audit B5).
|
|
@@ -21,6 +21,7 @@ declare const inputSchema: z.ZodObject<{
|
|
|
21
21
|
model: z.ZodOptional<z.ZodString>;
|
|
22
22
|
subagent_type: z.ZodOptional<z.ZodString>;
|
|
23
23
|
allowed_tools: z.ZodOptional<z.ZodArray<z.ZodString, "many">>;
|
|
24
|
+
permission_mode: z.ZodOptional<z.ZodEnum<["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"]>>;
|
|
24
25
|
}, "strip", z.ZodTypeAny, {
|
|
25
26
|
prompt: string;
|
|
26
27
|
model?: string | undefined;
|
|
@@ -30,6 +31,7 @@ declare const inputSchema: z.ZodObject<{
|
|
|
30
31
|
run_in_background?: boolean | undefined;
|
|
31
32
|
subagent_type?: string | undefined;
|
|
32
33
|
allowed_tools?: string[] | undefined;
|
|
34
|
+
permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
|
|
33
35
|
}, {
|
|
34
36
|
prompt: string;
|
|
35
37
|
model?: string | undefined;
|
|
@@ -39,6 +41,7 @@ declare const inputSchema: z.ZodObject<{
|
|
|
39
41
|
run_in_background?: boolean | undefined;
|
|
40
42
|
subagent_type?: string | undefined;
|
|
41
43
|
allowed_tools?: string[] | undefined;
|
|
44
|
+
permission_mode?: "ask" | "deny" | "trust" | "acceptEdits" | "plan" | "auto" | "bypassPermissions" | undefined;
|
|
42
45
|
}>;
|
|
43
46
|
export declare const AgentTool: Tool<typeof inputSchema>;
|
|
44
47
|
export {};
|
|
@@ -2,6 +2,7 @@ import { z } from "zod";
|
|
|
2
2
|
import { createWorktree, hasWorktreeChanges, isGitRepo, removeWorktree } from "../../git/index.js";
|
|
3
3
|
import { emitHook } from "../../harness/hooks.js";
|
|
4
4
|
import { getMessageBus } from "../../services/agent-messaging.js";
|
|
5
|
+
import { clampSubagentPermissionMode } from "../../types/permissions.js";
|
|
5
6
|
/**
|
|
6
7
|
* Forward a single inner-query event to the outer stream via `context.emitChildEvent`,
|
|
7
8
|
* stamping it with `parentCallId = context.callId`.
|
|
@@ -36,6 +37,10 @@ const inputSchema = z.object({
|
|
|
36
37
|
model: z.string().optional(),
|
|
37
38
|
subagent_type: z.string().optional(),
|
|
38
39
|
allowed_tools: z.array(z.string()).optional(),
|
|
40
|
+
permission_mode: z
|
|
41
|
+
.enum(["ask", "trust", "deny", "acceptEdits", "plan", "auto", "bypassPermissions"])
|
|
42
|
+
.optional()
|
|
43
|
+
.describe("Restrict the sub-agent's permission mode. Narrowing-only: the harness clamps to the parent's mode if a less-restrictive value is requested. Useful for spawning read-only review/audit agents while the parent runs in 'trust'."),
|
|
39
44
|
});
|
|
40
45
|
export const AgentTool = {
|
|
41
46
|
name: "Agent",
|
|
@@ -101,11 +106,16 @@ export const AgentTool = {
|
|
|
101
106
|
}
|
|
102
107
|
// Model override for sub-agent
|
|
103
108
|
const agentModel = input.model ?? context.model;
|
|
109
|
+
// Permission mode override — narrowing-only. Subagent can be same-or-stricter
|
|
110
|
+
// than parent; a less-restrictive request silently clamps to the parent so a
|
|
111
|
+
// model in `ask` can't spawn a `trust`-mode subagent to bypass user approval.
|
|
112
|
+
const parentMode = context.permissionMode ?? "trust";
|
|
113
|
+
const subagentMode = clampSubagentPermissionMode(parentMode, input.permission_mode);
|
|
104
114
|
const config = {
|
|
105
115
|
provider: context.provider,
|
|
106
116
|
tools: agentTools,
|
|
107
117
|
systemPrompt,
|
|
108
|
-
permissionMode:
|
|
118
|
+
permissionMode: subagentMode,
|
|
109
119
|
model: agentModel,
|
|
110
120
|
maxTurns: 20,
|
|
111
121
|
abortSignal: context.abortSignal,
|
|
@@ -225,7 +235,8 @@ export const AgentTool = {
|
|
|
225
235
|
- run_in_background (boolean, optional): Run the agent in the background. Returns immediately; you will be notified when it completes.
|
|
226
236
|
- model (string, optional): Override the model for this sub-agent (e.g., use a faster model for exploration).
|
|
227
237
|
- subagent_type (string, optional): Specialize the agent behavior. Types: "Explore" (read-only codebase search), "Plan" (design implementation plans), "code-reviewer", "test-writer", "debugger", "refactorer", "security-auditor", "evaluator" (read-only evaluation), "planner" (implementation plans), "architect" (system design), "migrator" (codebase migrations).
|
|
228
|
-
- allowed_tools (string[], optional): Restrict the sub-agent to only these tools by name. If omitted and a role has suggested tools, those are used
|
|
238
|
+
- allowed_tools (string[], optional): Restrict the sub-agent to only these tools by name. If omitted and a role has suggested tools, those are used.
|
|
239
|
+
- permission_mode (string, optional): Override the sub-agent's permission mode (one of: ask, trust, deny, acceptEdits, plan, auto, bypassPermissions). Narrowing-only — a less-restrictive value silently clamps to the parent's mode. Use to spawn a read-only audit/review sub-agent in "plan" or "deny" while the parent runs in "trust".`;
|
|
229
240
|
},
|
|
230
241
|
};
|
|
231
242
|
//# sourceMappingURL=index.js.map
|
|
@@ -4,6 +4,23 @@
|
|
|
4
4
|
import type { ToolPermissionRule } from "../harness/config.js";
|
|
5
5
|
export type PermissionMode = "ask" | "trust" | "deny" | "acceptEdits" | "plan" | "auto" | "bypassPermissions";
|
|
6
6
|
export type RiskLevel = "low" | "medium" | "high";
|
|
7
|
+
/**
|
|
8
|
+
* Resolve a subagent's effective permission mode given the parent's mode and
|
|
9
|
+
* an optionally-requested override. The contract: a subagent may be the same
|
|
10
|
+
* strictness as its parent or stricter, never looser.
|
|
11
|
+
*
|
|
12
|
+
* Why this asymmetry: the AgentTool input schema lets the model pick a
|
|
13
|
+
* subagent's mode. Allowing a less-restrictive choice would mean a model in
|
|
14
|
+
* `ask` mode could spawn a `trust`-mode subagent and quietly bypass user
|
|
15
|
+
* approval on its own tool calls — exactly the kind of escalation the
|
|
16
|
+
* permission system exists to prevent. The same pattern as `allowed_tools`,
|
|
17
|
+
* which is also narrowing-only.
|
|
18
|
+
*
|
|
19
|
+
* Returns the parent's mode if the requested override is undefined or would
|
|
20
|
+
* loosen the gate. Returns the requested override only when it's the same or
|
|
21
|
+
* stricter than the parent.
|
|
22
|
+
*/
|
|
23
|
+
export declare function clampSubagentPermissionMode(parentMode: PermissionMode, requestedMode: PermissionMode | undefined): PermissionMode;
|
|
7
24
|
export type PermissionResult = {
|
|
8
25
|
readonly allowed: boolean;
|
|
9
26
|
readonly reason: string;
|
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
* Permission types — tool permission context and risk-based gating.
|
|
3
3
|
*/
|
|
4
4
|
import { analyzeBashCommand, isReadOnlyBashCommand, splitCommands, stripProcessWrappers, } from "../utils/bash-safety.js";
|
|
5
|
+
/**
|
|
6
|
+
* Strictness rank for permission modes. Lower = more permissive. Used by
|
|
7
|
+
* `clampSubagentPermissionMode` to enforce that a subagent can never run
|
|
8
|
+
* less restrictively than its parent — only the same or stricter.
|
|
9
|
+
*
|
|
10
|
+
* The ordering is deliberate, not lexical:
|
|
11
|
+
* bypassPermissions (0) — approves everything unconditionally
|
|
12
|
+
* trust (1) — approves everything user-trusted
|
|
13
|
+
* auto (2) — approves except dangerous bash
|
|
14
|
+
* acceptEdits (3) — auto-approves edit-safe tool subset
|
|
15
|
+
* plan (4) — read-only allowed; writes blocked
|
|
16
|
+
* ask (5) — every non-trivial call prompts the user
|
|
17
|
+
* deny (6) — denies everything
|
|
18
|
+
*
|
|
19
|
+
* Why a rank instead of a Set: most-restrictive comparisons are easier when
|
|
20
|
+
* the dimension is total-ordered, and adding a new mode means adding one row
|
|
21
|
+
* to this table rather than re-deriving the partial order across call sites.
|
|
22
|
+
*/
|
|
23
|
+
const PERMISSION_STRICTNESS_RANK = {
|
|
24
|
+
bypassPermissions: 0,
|
|
25
|
+
trust: 1,
|
|
26
|
+
auto: 2,
|
|
27
|
+
acceptEdits: 3,
|
|
28
|
+
plan: 4,
|
|
29
|
+
ask: 5,
|
|
30
|
+
deny: 6,
|
|
31
|
+
};
|
|
32
|
+
/**
|
|
33
|
+
* Resolve a subagent's effective permission mode given the parent's mode and
|
|
34
|
+
* an optionally-requested override. The contract: a subagent may be the same
|
|
35
|
+
* strictness as its parent or stricter, never looser.
|
|
36
|
+
*
|
|
37
|
+
* Why this asymmetry: the AgentTool input schema lets the model pick a
|
|
38
|
+
* subagent's mode. Allowing a less-restrictive choice would mean a model in
|
|
39
|
+
* `ask` mode could spawn a `trust`-mode subagent and quietly bypass user
|
|
40
|
+
* approval on its own tool calls — exactly the kind of escalation the
|
|
41
|
+
* permission system exists to prevent. The same pattern as `allowed_tools`,
|
|
42
|
+
* which is also narrowing-only.
|
|
43
|
+
*
|
|
44
|
+
* Returns the parent's mode if the requested override is undefined or would
|
|
45
|
+
* loosen the gate. Returns the requested override only when it's the same or
|
|
46
|
+
* stricter than the parent.
|
|
47
|
+
*/
|
|
48
|
+
export function clampSubagentPermissionMode(parentMode, requestedMode) {
|
|
49
|
+
if (!requestedMode)
|
|
50
|
+
return parentMode;
|
|
51
|
+
return PERMISSION_STRICTNESS_RANK[requestedMode] >= PERMISSION_STRICTNESS_RANK[parentMode]
|
|
52
|
+
? requestedMode
|
|
53
|
+
: parentMode;
|
|
54
|
+
}
|
|
5
55
|
/** Tools auto-approved in acceptEdits mode */
|
|
6
56
|
const EDIT_SAFE_TOOLS = new Set([
|
|
7
57
|
"FileRead",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhijiewang/openharness",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.36.0",
|
|
4
4
|
"description": "Open-source terminal coding agent. Works with any LLM.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -91,6 +91,7 @@
|
|
|
91
91
|
},
|
|
92
92
|
"homepage": "https://github.com/zhijiewong/openharness#readme",
|
|
93
93
|
"optionalDependencies": {
|
|
94
|
+
"@agentclientprotocol/sdk": "^0.21.0",
|
|
94
95
|
"@anthropic-ai/sandbox-runtime": "^0.0.49",
|
|
95
96
|
"@napi-rs/keyring": "^1.2.0",
|
|
96
97
|
"sharp": "^0.34.5"
|