@sweny-ai/core 0.1.148 → 0.1.149

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.
@@ -1,14 +1,14 @@
1
1
  import { execSync } from "node:child_process";
2
2
  import * as fs from "node:fs";
3
3
  import { hasCodexLogin } from "../agent-env.js";
4
- import { unsupportedAgentError } from "../harness/agents.js";
4
+ import { parseAcpAgent, unsupportedAgentError } from "../harness/agents.js";
5
5
  export function registerTriageCommand(program) {
6
6
  // NOTE: File-eligible options omit Commander defaults so parseCliInputs() can
7
7
  // distinguish "user passed flag" from "not passed" and fall through to .sweny.yml.
8
8
  return program
9
9
  .command("triage")
10
10
  .description("Run the SWEny triage workflow")
11
- .option("--agent <provider>", "Coding agent: claude (default), codex, or pi (experimental)")
11
+ .option("--agent <provider>", "Coding agent: claude (default), codex, pi (experimental), or acp:<command> for any ACP agent (experimental)")
12
12
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
13
13
  .option("--observability-provider <provider>", "Observability provider (default: none)")
14
14
  .option("--issue-tracker-provider <provider>", "Issue tracker provider (default: github-issues)")
@@ -243,6 +243,9 @@ export function validateInputs(config) {
243
243
  // there is no single credential to check here; pi reports its own auth error.
244
244
  break;
245
245
  default:
246
+ // An ACP agent (`acp:<command>`, #416) brings its own auth; it says so itself when it is missing.
247
+ if (parseAcpAgent(config.codingAgentProvider) !== undefined)
248
+ break;
246
249
  // Honest --agent (#330): only agents with an adapter are accepted.
247
250
  // Anything else would silently run a different agent under that name.
248
251
  errors.push(unsupportedAgentError(config.codingAgentProvider));
@@ -662,7 +665,7 @@ export function registerImplementCommand(program) {
662
665
  return program
663
666
  .command("implement <issueId>")
664
667
  .description("Implement a fix for a specific issue and open a PR")
665
- .option("--agent <provider>", "Coding agent: claude (default), codex, or pi (experimental)")
668
+ .option("--agent <provider>", "Coding agent: claude (default), codex, pi (experimental), or acp:<command> for any ACP agent (experimental)")
666
669
  .option("--coding-agent-provider <provider>", "Coding agent provider (alias for --agent)")
667
670
  .option("--issue-tracker-provider <provider>", "Issue tracker (linear|jira|github-issues|file)")
668
671
  .option("--source-control-provider <provider>", "Source control (github|gitlab|file)")
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Newline-delimited JSON-RPC 2.0 over a child process's stdio: the ACP stdio
3
+ * transport (agentclientprotocol.com/protocol/v1/transports, schema v1.24.1).
4
+ *
5
+ * - The client launches the agent as a subprocess and writes one JSON-RPC
6
+ * message per line to its stdin; the agent answers on stdout.
7
+ * - Messages MUST NOT contain embedded newlines (`JSON.stringify` never emits
8
+ * one).
9
+ * - The agent MAY log on stderr. It MUST NOT write anything but ACP messages
10
+ * to stdout; real agents sometimes do, so a stdout line that is not JSON is
11
+ * logged and skipped instead of ending the run.
12
+ *
13
+ * Hand-rolled on purpose: the wire is four message shapes, so sweny takes no
14
+ * SDK dependency for it, and the contract fake speaks the raw wire, which means
15
+ * the suite checks the protocol itself and not an SDK's reading of it.
16
+ *
17
+ * No agent SDK imports.
18
+ */
19
+ import type { ChildProcess } from "node:child_process";
20
+ /** JSON-RPC / ACP error codes (schema `ErrorCode`). */
21
+ export declare const RPC_ERROR: {
22
+ readonly parse: -32700;
23
+ readonly invalidRequest: -32600;
24
+ readonly methodNotFound: -32601;
25
+ readonly invalidParams: -32602;
26
+ readonly internal: -32603;
27
+ readonly requestCancelled: -32800;
28
+ readonly authRequired: -32000;
29
+ readonly resourceNotFound: -32002;
30
+ };
31
+ /** An error response from the peer (or a local failure reaching it). */
32
+ export declare class RpcError extends Error {
33
+ readonly code: number;
34
+ readonly data?: unknown | undefined;
35
+ constructor(message: string, code: number, data?: unknown | undefined);
36
+ }
37
+ export interface RpcHandlers {
38
+ /** A request from the peer. Resolve with the `result`, or throw {@link RpcError} for an error response. */
39
+ onRequest(method: string, params: unknown): Promise<unknown>;
40
+ /** A notification from the peer. */
41
+ onNotification(method: string, params: unknown): void;
42
+ /** A stdout line that was not a JSON-RPC message (debug only). */
43
+ onJunk?(line: string): void;
44
+ }
45
+ /**
46
+ * One JSON-RPC connection. Requests from us are matched to responses by id;
47
+ * requests from the peer are answered through {@link RpcHandlers.onRequest}.
48
+ * When the peer goes away every pending request rejects.
49
+ */
50
+ export declare class RpcPeer {
51
+ private readonly child;
52
+ private readonly handlers;
53
+ private nextId;
54
+ private pending;
55
+ private buf;
56
+ private ended;
57
+ constructor(child: Pick<ChildProcess, "stdin" | "stdout">, handlers: RpcHandlers);
58
+ private onData;
59
+ private onLine;
60
+ private answer;
61
+ private write;
62
+ /** Send a request and wait for its response. */
63
+ request<T = unknown>(method: string, params: unknown): Promise<T>;
64
+ /** Send a notification (no response). */
65
+ notify(method: string, params: unknown): void;
66
+ /** The peer is gone: reject everything still waiting. Idempotent. */
67
+ end(reason: string): void;
68
+ }
69
+ /**
70
+ * Split a command line into argv: whitespace separates words, single and
71
+ * double quotes group, backslash escapes the next character outside single
72
+ * quotes. No expansion of any kind (no globbing, no variables, no shell), so
73
+ * the string after `acp:` can never run anything but the program it names.
74
+ */
75
+ export declare function splitCommandLine(input: string): string[];
@@ -0,0 +1,204 @@
1
+ /**
2
+ * Newline-delimited JSON-RPC 2.0 over a child process's stdio: the ACP stdio
3
+ * transport (agentclientprotocol.com/protocol/v1/transports, schema v1.24.1).
4
+ *
5
+ * - The client launches the agent as a subprocess and writes one JSON-RPC
6
+ * message per line to its stdin; the agent answers on stdout.
7
+ * - Messages MUST NOT contain embedded newlines (`JSON.stringify` never emits
8
+ * one).
9
+ * - The agent MAY log on stderr. It MUST NOT write anything but ACP messages
10
+ * to stdout; real agents sometimes do, so a stdout line that is not JSON is
11
+ * logged and skipped instead of ending the run.
12
+ *
13
+ * Hand-rolled on purpose: the wire is four message shapes, so sweny takes no
14
+ * SDK dependency for it, and the contract fake speaks the raw wire, which means
15
+ * the suite checks the protocol itself and not an SDK's reading of it.
16
+ *
17
+ * No agent SDK imports.
18
+ */
19
+ /** JSON-RPC / ACP error codes (schema `ErrorCode`). */
20
+ export const RPC_ERROR = {
21
+ parse: -32700,
22
+ invalidRequest: -32600,
23
+ methodNotFound: -32601,
24
+ invalidParams: -32602,
25
+ internal: -32603,
26
+ requestCancelled: -32800,
27
+ authRequired: -32000,
28
+ resourceNotFound: -32002,
29
+ };
30
+ /** An error response from the peer (or a local failure reaching it). */
31
+ export class RpcError extends Error {
32
+ code;
33
+ data;
34
+ constructor(message, code, data) {
35
+ super(message);
36
+ this.code = code;
37
+ this.data = data;
38
+ this.name = "RpcError";
39
+ }
40
+ }
41
+ /**
42
+ * One JSON-RPC connection. Requests from us are matched to responses by id;
43
+ * requests from the peer are answered through {@link RpcHandlers.onRequest}.
44
+ * When the peer goes away every pending request rejects.
45
+ */
46
+ export class RpcPeer {
47
+ child;
48
+ handlers;
49
+ nextId = 1;
50
+ pending = new Map();
51
+ buf = "";
52
+ ended;
53
+ constructor(child, handlers) {
54
+ this.child = child;
55
+ this.handlers = handlers;
56
+ child.stdout?.setEncoding("utf8");
57
+ child.stdout?.on("data", (chunk) => this.onData(chunk));
58
+ child.stdin?.on("error", () => {
59
+ // The agent exited before reading; the exit handling reports why.
60
+ });
61
+ }
62
+ onData(chunk) {
63
+ this.buf += chunk;
64
+ let nl;
65
+ while ((nl = this.buf.indexOf("\n")) !== -1) {
66
+ const line = this.buf.slice(0, nl);
67
+ this.buf = this.buf.slice(nl + 1);
68
+ this.onLine(line);
69
+ }
70
+ }
71
+ onLine(raw) {
72
+ const line = raw.trim();
73
+ if (!line)
74
+ return;
75
+ let msg;
76
+ try {
77
+ const parsed = JSON.parse(line);
78
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
79
+ throw new Error("not an object");
80
+ msg = parsed;
81
+ }
82
+ catch {
83
+ this.handlers.onJunk?.(line.length > 200 ? line.slice(0, 199) + "..." : line);
84
+ return;
85
+ }
86
+ const id = msg.id;
87
+ const hasId = typeof id === "number" || typeof id === "string";
88
+ if (typeof msg.method === "string") {
89
+ if (hasId)
90
+ void this.answer(id, msg.method, msg.params);
91
+ else
92
+ this.handlers.onNotification(msg.method, msg.params);
93
+ return;
94
+ }
95
+ if (!hasId)
96
+ return;
97
+ const p = this.pending.get(id);
98
+ if (!p)
99
+ return;
100
+ this.pending.delete(id);
101
+ const err = msg.error;
102
+ if (err && typeof err === "object") {
103
+ p.reject(new RpcError(typeof err.message === "string" ? err.message : `${p.method} failed`, typeof err.code === "number" ? err.code : RPC_ERROR.internal, err.data));
104
+ }
105
+ else {
106
+ p.resolve(msg.result);
107
+ }
108
+ }
109
+ async answer(id, method, params) {
110
+ try {
111
+ const result = await this.handlers.onRequest(method, params);
112
+ this.write({ jsonrpc: "2.0", id, result: result ?? null });
113
+ }
114
+ catch (err) {
115
+ const e = err instanceof RpcError
116
+ ? err
117
+ : new RpcError(err instanceof Error ? err.message : String(err), RPC_ERROR.internal);
118
+ this.write({ jsonrpc: "2.0", id, error: { code: e.code, message: e.message } });
119
+ }
120
+ }
121
+ write(msg) {
122
+ const stdin = this.child.stdin;
123
+ if (!stdin || stdin.destroyed || !stdin.writable)
124
+ return;
125
+ stdin.write(JSON.stringify(msg) + "\n");
126
+ }
127
+ /** Send a request and wait for its response. */
128
+ request(method, params) {
129
+ if (this.ended !== undefined)
130
+ return Promise.reject(new RpcError(this.ended, RPC_ERROR.internal));
131
+ const id = this.nextId++;
132
+ return new Promise((resolve, reject) => {
133
+ this.pending.set(id, { resolve: (v) => resolve(v), reject, method });
134
+ this.write({ jsonrpc: "2.0", id, method, params });
135
+ });
136
+ }
137
+ /** Send a notification (no response). */
138
+ notify(method, params) {
139
+ if (this.ended !== undefined)
140
+ return;
141
+ this.write({ jsonrpc: "2.0", method, params });
142
+ }
143
+ /** The peer is gone: reject everything still waiting. Idempotent. */
144
+ end(reason) {
145
+ if (this.ended !== undefined)
146
+ return;
147
+ this.ended = reason;
148
+ // A final line without a newline is complete only if it parses.
149
+ if (this.buf.trim())
150
+ this.onLine(this.buf);
151
+ this.buf = "";
152
+ const pending = [...this.pending.values()];
153
+ this.pending.clear();
154
+ for (const p of pending)
155
+ p.reject(new RpcError(reason, RPC_ERROR.internal));
156
+ }
157
+ }
158
+ /**
159
+ * Split a command line into argv: whitespace separates words, single and
160
+ * double quotes group, backslash escapes the next character outside single
161
+ * quotes. No expansion of any kind (no globbing, no variables, no shell), so
162
+ * the string after `acp:` can never run anything but the program it names.
163
+ */
164
+ export function splitCommandLine(input) {
165
+ const out = [];
166
+ let cur = "";
167
+ let started = false;
168
+ let quote = null;
169
+ for (let i = 0; i < input.length; i++) {
170
+ const ch = input[i];
171
+ if (quote) {
172
+ if (ch === quote)
173
+ quote = null;
174
+ else if (ch === "\\" && quote === '"' && i + 1 < input.length)
175
+ cur += input[++i];
176
+ else
177
+ cur += ch;
178
+ }
179
+ else if (ch === '"' || ch === "'") {
180
+ quote = ch;
181
+ started = true;
182
+ }
183
+ else if (ch === "\\" && i + 1 < input.length) {
184
+ cur += input[++i];
185
+ started = true;
186
+ }
187
+ else if (/\s/.test(ch)) {
188
+ if (started) {
189
+ out.push(cur);
190
+ cur = "";
191
+ started = false;
192
+ }
193
+ }
194
+ else {
195
+ cur += ch;
196
+ started = true;
197
+ }
198
+ }
199
+ if (quote)
200
+ throw new Error(`unterminated ${quote} quote in "${input}"`);
201
+ if (started)
202
+ out.push(cur);
203
+ return out;
204
+ }
@@ -0,0 +1,189 @@
1
+ /**
2
+ * ACP harness (#416): runs a node on any Agent Client Protocol agent
3
+ * (`--agent acp:<command>`, for example `acp:opencode acp`,
4
+ * `acp:hermes acp`, `acp:goose acp`, `acp:gemini --experimental-acp`).
5
+ *
6
+ * ACP is the long-tail adapter. It is JSON-RPC 2.0 over the agent's stdio and
7
+ * carries a prompt and a stream of updates; it does not carry sweny's
8
+ * opinions. Read from agentclientprotocol/agent-client-protocol at tag
9
+ * schema-v1.24.1 (`schema/v1/schema.json`, `docs/protocol/v1/*.mdx`):
10
+ * - transports: newline-delimited JSON over stdio (`transports.mdx`).
11
+ * - `initialize` negotiates `protocolVersion` (1) and capabilities
12
+ * (`initialization.mdx`); `InitializeRequest` / `InitializeResponse`.
13
+ * - `session/new {cwd, mcpServers}` returns `{sessionId}`; every agent MUST
14
+ * support stdio MCP servers, http and sse are optional
15
+ * (`session-setup.mdx`, `McpServerStdio`, `McpServerHttp`).
16
+ * - `session/prompt {sessionId, prompt: ContentBlock[]}` streams
17
+ * `session/update` notifications (`agent_message_chunk`, `tool_call`,
18
+ * `tool_call_update`, `usage_update`, ...) and ends with
19
+ * `{stopReason}`; `PromptResponse` has no other field (`prompt-turn.mdx`).
20
+ * - `session/cancel` (notification) ends a turn with stopReason `cancelled`
21
+ * (`prompt-turn.mdx`, Cancellation); pending permission requests get the
22
+ * `cancelled` outcome.
23
+ * - `session/request_permission {toolCall, options}` is how an agent MAY ask
24
+ * before a tool runs; the client picks an option or answers `cancelled`
25
+ * (`tool-calls.mdx`, `RequestPermissionResponse`).
26
+ * - `fs/read_text_file` and `fs/write_text_file` are client methods an agent
27
+ * may use only when the client advertised them (`file-system.mdx`).
28
+ *
29
+ * Every sweny opinion is enforced by sweny here, refused or reported as
30
+ * `degraded` by `policyGate` (see ACP_CAPABILITIES for the honest list):
31
+ * - Structured output: the schema rides in the prompt; sweny parses the last
32
+ * JSON object, checks it and asks once more before giving up.
33
+ * - Tool deny and read-only: best effort through `session/request_permission`
34
+ * (denied classes and every non-read tool under `readOnly` are rejected),
35
+ * plus a refusal of every `fs/write_text_file`. The protocol has no deny list
36
+ * and an agent that does not ask is not stopped, so `policyGate` still
37
+ * reports deny and read-only as unenforced; strict refuses the node unless
38
+ * the process wrapper provides the read-only mount.
39
+ * - Sandbox and egress: none in the protocol. The whole agent process runs
40
+ * inside the sandbox wrapper (sandbox-wrapper.ts); strict refuses without it.
41
+ * - Env: sweny spawns the agent with the scoped env (agent-env.ts).
42
+ * - Skill tools: passed in `session/new` as the stdio MCP server of the tool
43
+ * bridge (#414), so the executor still observes every call.
44
+ * - Turn limit: a watchdog counts tool calls and cancels past the budget.
45
+ * - Timeout: `session/cancel`, a grace period, then the process is killed.
46
+ */
47
+ import type { Logger, McpServerConfig, ToolContext } from "../types.js";
48
+ import { type SandboxMode } from "../agent-env.js";
49
+ import type { AgentHarness, HarnessCapabilities, HarnessCompleteRequest, HarnessId, HarnessInfo, HarnessRunRequest, HarnessRunResult, ToolClass } from "./types.js";
50
+ import { ACP_CAPABILITIES } from "./capabilities.js";
51
+ import { type HarnessPolicyMode } from "./policy.js";
52
+ import { type SandboxWrapper } from "./sandbox-wrapper.js";
53
+ export { ACP_CAPABILITIES };
54
+ /** The ACP major version sweny speaks (`InitializeRequest.protocolVersion`). */
55
+ export declare const ACP_PROTOCOL_VERSION = 1;
56
+ export interface AcpHarnessOptions {
57
+ /**
58
+ * The agent to run: a command line (`"opencode acp"`, the part after `acp:`)
59
+ * or an explicit `{command, args}`. Never run through a shell.
60
+ */
61
+ acpCommand?: string | {
62
+ command: string;
63
+ args: string[];
64
+ };
65
+ /** Tool-call budget the watchdog enforces when a node sets no `max_turns` (default: 20). */
66
+ maxTurns?: number;
67
+ /** Working directory (default: process.cwd()). */
68
+ cwd?: string;
69
+ logger?: Logger;
70
+ /** Default tool context for standalone usage (not via the executor). */
71
+ defaultContext?: ToolContext;
72
+ /** Catalog defaults, overridden by per-run skill servers and explicit mcpServers. */
73
+ defaultMcpServers?: Record<string, McpServerConfig>;
74
+ /** Explicit external MCP servers, overriding defaults and per-run skill servers. */
75
+ mcpServers?: Record<string, McpServerConfig>;
76
+ /** Extra env var names passed through (see agent-env.ts). Default: `SWENY_ENV_PASSTHROUGH`. */
77
+ envPassthrough?: string[];
78
+ /** The agent's own auth env var names, always passed through when scoping (none are assumed). */
79
+ authVars?: string[];
80
+ /** Scope the agent env to the allowlist. Default: `SWENY_ENV_SCOPE`, else on in CI. */
81
+ envScope?: boolean;
82
+ /** Process sandbox mode. Default: `SWENY_SANDBOX`, else `auto` in CI and `off` locally. */
83
+ sandbox?: SandboxMode;
84
+ /** The host's process sandbox wrapper (#360 step 2). `undefined` = detect srt once per process; `null` = none (test seam). */
85
+ sandboxWrapper?: SandboxWrapper | null;
86
+ /** `strict` refuses a node with unenforceable opinions. Default: {@link resolveHarnessPolicy}. */
87
+ policy?: HarnessPolicyMode;
88
+ /** Hosts the agent itself needs (its model API), allowed through the sandbox wrapper. */
89
+ egress?: string[];
90
+ /** Shim command override for the tool bridge (test seam). */
91
+ toolBridgeShim?: {
92
+ command: string;
93
+ args: string[];
94
+ };
95
+ /** Grace period between SIGTERM and SIGKILL, and for the agent to exit on its own (default: 2000 ms). */
96
+ killGraceMs?: number;
97
+ /** How long the agent gets to answer `session/cancel` before it is killed (default: 5000 ms). */
98
+ cancelGraceMs?: number;
99
+ /** How long `initialize` and `session/new` may take (default: 60000 ms). */
100
+ startupTimeoutMs?: number;
101
+ }
102
+ /** Portable tool classes an ACP tool call can be, from its `kind` (schema `ToolKind`) and name. */
103
+ export declare function classifyAcpTool(kind: string | null | undefined, name: string | null | undefined): {
104
+ classes: ToolClass[];
105
+ readOnlySafe: boolean;
106
+ };
107
+ /** Does this tool call name one of sweny's skill tools (they reach the agent as MCP tools of the tool bridge)? */
108
+ export declare function isSkillToolCall(names: readonly (string | null | undefined)[], skillTools: readonly string[]): boolean;
109
+ interface PermissionOption {
110
+ optionId: string;
111
+ kind: string;
112
+ }
113
+ /** The permission outcome for an option list: the first option of a wanted kind, else `cancelled`. */
114
+ export declare function pickPermissionOutcome(options: readonly PermissionOption[], allow: boolean): {
115
+ outcome: "selected";
116
+ optionId: string;
117
+ } | {
118
+ outcome: "cancelled";
119
+ };
120
+ /** A loosely typed JSON object, for reading what the agent sends. */
121
+ type Obj = Record<string, any>;
122
+ /** `McpServerConfig` map to the ACP `mcpServers` array (schema `McpServer`). Returns what the agent cannot load too. */
123
+ export declare function toAcpMcpServers(servers: Record<string, McpServerConfig>, agentHttp: boolean): {
124
+ list: Obj[];
125
+ unsupported: string[];
126
+ };
127
+ export declare class AcpHarness implements AgentHarness {
128
+ readonly id: HarnessId;
129
+ readonly capabilities: HarnessCapabilities;
130
+ /** Empty on purpose: judges use the agent's own default model. */
131
+ readonly defaultJudgeModel = "";
132
+ readonly logger: Logger;
133
+ private agent;
134
+ private maxTurns;
135
+ private cwd;
136
+ private defaultContext;
137
+ private mcpServers;
138
+ private defaultMcpServers;
139
+ private envPassthrough;
140
+ private authVars;
141
+ private envScope;
142
+ private sandboxMode;
143
+ private sandboxWrapper;
144
+ private policyMode;
145
+ private egress;
146
+ private toolBridgeShim;
147
+ private killGraceMs;
148
+ private cancelGraceMs;
149
+ private startupTimeoutMs;
150
+ private agentVersion;
151
+ constructor(opts?: AcpHarnessOptions);
152
+ info(): HarnessInfo;
153
+ /**
154
+ * Is the agent command there? Deliberately does not start it: it would run
155
+ * outside the sandbox wrapper. The version comes from `initialize` on the
156
+ * first run (`agentInfo.version`).
157
+ */
158
+ preflight(): Promise<{
159
+ ok: true;
160
+ version: string;
161
+ } | {
162
+ ok: false;
163
+ reason: string;
164
+ }>;
165
+ /** Env for the agent process: scoped like Claude Code's, with only the auth vars the operator named. */
166
+ private buildEnv;
167
+ /** The node policy, with the harness policy mode and the watchdog budget applied. */
168
+ private compilePolicy;
169
+ /**
170
+ * Talk to one agent process from `initialize` to the last `session/prompt`
171
+ * response, then stop it. Never throws: every failure is on the result.
172
+ */
173
+ private converse;
174
+ /**
175
+ * Run a node. The gate runs first: in strict mode a node whose opinions an
176
+ * ACP agent cannot honor is refused before the agent starts. The agent
177
+ * process runs inside the host's sandbox wrapper (#360 step 2) when the
178
+ * sandbox mode asks for one and the host has one.
179
+ */
180
+ run(req: HarnessRunRequest): Promise<HarnessRunResult>;
181
+ /** Map one conversation onto a NodeResult with Claude Code's semantics. */
182
+ private toResult;
183
+ /**
184
+ * One completion: a fresh session with no MCP servers, every permission
185
+ * request rejected, and any tool call over budget. null when it failed
186
+ * (callers fail closed).
187
+ */
188
+ complete(req: HarnessCompleteRequest): Promise<string | null>;
189
+ }