harnery 0.46.1 → 0.47.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 +24 -0
- package/bin/README.md +10 -0
- package/bin/agent-coord.mjs +3 -0
- package/bin/agent-hook.mjs +3 -0
- package/bin/harn.mjs +3 -0
- package/bin/run.mjs +43 -0
- package/dist/commander.d.ts +18 -6
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +74 -2
- package/dist/commands/agents.d.ts +25 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +70 -27
- package/dist/commands/autoupdate.d.ts +12 -0
- package/dist/commands/autoupdate.d.ts.map +1 -0
- package/dist/commands/autoupdate.js +190 -0
- package/dist/commands/computer.d.ts +45 -0
- package/dist/commands/computer.d.ts.map +1 -0
- package/dist/commands/computer.js +509 -0
- package/dist/commands/docs.js +3 -1
- package/dist/commands/fetch.js +11 -1
- package/dist/commands/grep.d.ts +72 -0
- package/dist/commands/grep.d.ts.map +1 -1
- package/dist/commands/grep.js +200 -47
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +15 -9
- package/dist/commands/rm.js +2 -2
- package/dist/commands/web.d.ts.map +1 -1
- package/dist/commands/web.js +2 -0
- package/dist/core/agents/canonical-emit.d.ts.map +1 -1
- package/dist/core/agents/canonical-emit.js +2 -3
- package/dist/core/agents/coord-bin.d.ts +3 -0
- package/dist/core/agents/coord-bin.d.ts.map +1 -1
- package/dist/core/agents/coord-bin.js +6 -0
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +6 -2
- package/dist/core/agents/render/prompt-context.d.ts +55 -7
- package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
- package/dist/core/agents/render/prompt-context.js +122 -48
- package/dist/core/agents/state/proc-start.d.ts +3 -1
- package/dist/core/agents/state/proc-start.d.ts.map +1 -1
- package/dist/core/agents/state/proc-start.js +26 -4
- package/dist/core/autoupdate/engine.d.ts +97 -0
- package/dist/core/autoupdate/engine.d.ts.map +1 -0
- package/dist/core/autoupdate/engine.js +548 -0
- package/dist/core/autoupdate/fingerprint.d.ts +16 -0
- package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
- package/dist/core/autoupdate/fingerprint.js +73 -0
- package/dist/core/autoupdate/git.d.ts +30 -0
- package/dist/core/autoupdate/git.d.ts.map +1 -0
- package/dist/core/autoupdate/git.js +82 -0
- package/dist/core/autoupdate/index.d.ts +6 -0
- package/dist/core/autoupdate/index.d.ts.map +1 -0
- package/dist/core/autoupdate/index.js +4 -0
- package/dist/core/autoupdate/state.d.ts +59 -0
- package/dist/core/autoupdate/state.d.ts.map +1 -0
- package/dist/core/autoupdate/state.js +214 -0
- package/dist/core/autoupdate/types.d.ts +113 -0
- package/dist/core/autoupdate/types.d.ts.map +1 -0
- package/dist/core/autoupdate/types.js +35 -0
- package/dist/core/config.d.ts +53 -1
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +114 -1
- package/dist/core/events/v3/bootstrap.js +1 -0
- package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
- package/dist/core/events/v3/runtime-build.js +1 -0
- package/dist/core/hooks/adapter/wiring.d.ts +12 -2
- package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
- package/dist/core/hooks/adapter/wiring.js +38 -8
- package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
- package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
- package/dist/core/hooks/autoupdate-cue.js +53 -0
- package/dist/core/hooks/cli.js +28 -3
- package/dist/core/hooks/effects/index.js +4 -4
- package/dist/core/hooks/resolve/coord-root.js +1 -0
- package/dist/core/semantic/contract.d.ts +3 -3
- package/dist/core/semantic/service.d.ts.map +1 -1
- package/dist/core/semantic/service.js +4 -2
- package/dist/core/storage/modes.d.ts +1 -1
- package/dist/core/storage/modes.d.ts.map +1 -1
- package/dist/core/storage/modes.js +5 -1
- package/dist/core/workflow/durable-record.d.ts +2 -0
- package/dist/core/workflow/durable-record.d.ts.map +1 -1
- package/dist/core/workflow/durable-record.js +13 -1
- package/dist/core/workflow/engine.js +4 -0
- package/dist/core/workflow/spawn-claude.d.ts +7 -0
- package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
- package/dist/core/workflow/spawn-claude.js +16 -4
- package/dist/core/workflow/specialists.d.ts.map +1 -1
- package/dist/core/workflow/specialists.js +1 -0
- package/dist/core/workflow/types.d.ts +10 -0
- package/dist/core/workflow/types.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
- package/dist/core/workflow/workspaces/leases.js +1 -9
- package/dist/lib/computer/cua.d.ts +50 -0
- package/dist/lib/computer/cua.d.ts.map +1 -0
- package/dist/lib/computer/cua.js +474 -0
- package/dist/lib/computer/host.d.ts +19 -0
- package/dist/lib/computer/host.d.ts.map +1 -0
- package/dist/lib/computer/host.js +72 -0
- package/dist/lib/computer/lease.d.ts +13 -0
- package/dist/lib/computer/lease.d.ts.map +1 -0
- package/dist/lib/computer/lease.js +269 -0
- package/dist/lib/computer/types.d.ts +163 -0
- package/dist/lib/computer/types.d.ts.map +1 -0
- package/dist/lib/computer/types.js +10 -0
- package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
- package/dist/lib/docs-metadata-v2.js +6 -5
- package/dist/lib/exec.d.ts.map +1 -1
- package/dist/lib/exec.js +1 -0
- package/dist/lib/guarded-remove.d.ts.map +1 -1
- package/dist/lib/guarded-remove.js +5 -4
- package/dist/lib/headless/index.d.ts +33 -0
- package/dist/lib/headless/index.d.ts.map +1 -1
- package/dist/lib/headless/index.js +38 -2
- package/dist/lib/http/client.d.ts +2 -9
- package/dist/lib/http/client.d.ts.map +1 -1
- package/dist/lib/http/client.js +30 -9
- package/dist/lib/instructions/apply.d.ts +9 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -1
- package/dist/lib/instructions/apply.js +92 -14
- package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
- package/dist/lib/instructions/git-hooks.js +8 -4
- package/dist/lib/instructions/templates.d.ts +11 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -1
- package/dist/lib/instructions/templates.js +81 -1
- package/package.json +5 -5
- package/schemas/config.schema.json +89 -1
- package/src/commander.ts +98 -8
- package/src/commands/agents.ts +100 -32
- package/src/commands/autoupdate.ts +250 -0
- package/src/commands/computer.ts +682 -0
- package/src/commands/docs.ts +3 -1
- package/src/commands/fetch.ts +11 -1
- package/src/commands/grep.ts +274 -59
- package/src/commands/init.ts +17 -7
- package/src/commands/rm.ts +2 -2
- package/src/commands/web.ts +2 -0
- package/src/core/agents/canonical-emit.ts +2 -3
- package/src/core/agents/coord-bin.ts +30 -0
- package/src/core/agents/coord-client.ts +6 -2
- package/src/core/agents/render/prompt-context.ts +152 -53
- package/src/core/agents/state/proc-start.ts +29 -5
- package/src/core/autoupdate/engine.ts +691 -0
- package/src/core/autoupdate/fingerprint.ts +89 -0
- package/src/core/autoupdate/git.ts +120 -0
- package/src/core/autoupdate/index.ts +29 -0
- package/src/core/autoupdate/state.ts +269 -0
- package/src/core/autoupdate/types.ts +135 -0
- package/src/core/config.ts +142 -1
- package/src/core/events/v3/bootstrap.ts +1 -0
- package/src/core/events/v3/runtime-build.ts +1 -0
- package/src/core/hooks/adapter/wiring.ts +51 -6
- package/src/core/hooks/autoupdate-cue.ts +72 -0
- package/src/core/hooks/cli.ts +27 -1
- package/src/core/hooks/effects/index.ts +4 -4
- package/src/core/hooks/resolve/coord-root.ts +1 -0
- package/src/core/semantic/service.ts +6 -2
- package/src/core/storage/modes.ts +7 -1
- package/src/core/workflow/durable-record.ts +15 -1
- package/src/core/workflow/engine.ts +3 -0
- package/src/core/workflow/spawn-claude.ts +16 -4
- package/src/core/workflow/specialists.ts +1 -0
- package/src/core/workflow/types.ts +10 -0
- package/src/core/workflow/workspaces/leases.ts +1 -9
- package/src/lib/computer/cua.ts +572 -0
- package/src/lib/computer/host.ts +87 -0
- package/src/lib/computer/lease.ts +299 -0
- package/src/lib/computer/types.ts +177 -0
- package/src/lib/docs-metadata-v2.ts +24 -5
- package/src/lib/exec.ts +1 -0
- package/src/lib/guarded-remove.ts +5 -3
- package/src/lib/headless/index.ts +66 -2
- package/src/lib/http/client.ts +33 -19
- package/src/lib/instructions/apply.ts +100 -12
- package/src/lib/instructions/git-hooks.ts +8 -4
- package/src/lib/instructions/templates.ts +95 -1
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
* PATH" is not "working", and under parallel load the first backend can
|
|
27
27
|
* flake on a fraction of calls while the second runs clean.
|
|
28
28
|
*
|
|
29
|
+
* - A claude-code call loads NO MCP servers and no claude.ai connectors by
|
|
30
|
+
* default (`claudeMcpLaunch`). Connectors attach about 130 tool schemas to
|
|
31
|
+
* every turn of a one-shot call that never uses them; a caller that needs
|
|
32
|
+
* one opts in per request with `mcp: "inherit"` or an explicit server map.
|
|
33
|
+
*
|
|
29
34
|
* Env knobs (read through coordEnv, so the HARNERY_ prefix stays in one
|
|
30
35
|
* place): HARNERY_HEADLESS_BACKEND forces one backend and disables the
|
|
31
36
|
* fallback walk; HARNERY_HEADLESS_MODEL overrides the model passed to
|
|
@@ -77,6 +82,55 @@ export interface HeadlessRequest {
|
|
|
77
82
|
timeoutMs?: number;
|
|
78
83
|
/** Agentic turn budget where the backend supports one (default 4). */
|
|
79
84
|
maxTurns?: number;
|
|
85
|
+
/** MCP servers the claude-code backend may load (default `"none"`; see
|
|
86
|
+
* `ClaudeMcpMode`). The codex and cursor backends ignore it. */
|
|
87
|
+
mcp?: ClaudeMcpMode;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Which MCP servers a spawned `claude` child may load.
|
|
92
|
+
*
|
|
93
|
+
* - `"none"` (the default for every spawn Harnery owns): no MCP servers and no
|
|
94
|
+
* claude.ai connectors. Measured on a headless child, connectors add tens of
|
|
95
|
+
* thousands of tokens of tool schemas to every turn.
|
|
96
|
+
* - `"inherit"`: whatever Claude Code would load on its own, connectors
|
|
97
|
+
* included. The explicit opt-in for a workflow that really uses one.
|
|
98
|
+
* - `{ mcpServers }`: exactly these servers (the `--mcp-config` JSON shape),
|
|
99
|
+
* with claude.ai connectors still off.
|
|
100
|
+
*/
|
|
101
|
+
export type ClaudeMcpMode = "none" | "inherit" | { mcpServers: Record<string, unknown> };
|
|
102
|
+
|
|
103
|
+
/** The two guards that keep a `claude` child free of MCP servers: strict MCP
|
|
104
|
+
* config with an empty server map, plus the env switch that stops claude.ai
|
|
105
|
+
* connectors attaching mid-session (the flag alone does not cover them). */
|
|
106
|
+
export const CLAUDE_NO_MCP_ARGS: readonly string[] = [
|
|
107
|
+
"--strict-mcp-config",
|
|
108
|
+
"--mcp-config",
|
|
109
|
+
'{"mcpServers":{}}',
|
|
110
|
+
];
|
|
111
|
+
export const CLAUDE_NO_CONNECTORS_ENV: Readonly<Record<string, string>> = {
|
|
112
|
+
ENABLE_CLAUDEAI_MCP_SERVERS: "false",
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
/** Resolve an MCP mode to the argv and env additions for a `claude` child.
|
|
116
|
+
* Throws on a malformed server map rather than launching with a guess. */
|
|
117
|
+
export function claudeMcpLaunch(mode: ClaudeMcpMode = "none"): {
|
|
118
|
+
args: string[];
|
|
119
|
+
env: Record<string, string>;
|
|
120
|
+
} {
|
|
121
|
+
if (mode === "inherit") return { args: [], env: {} };
|
|
122
|
+
if (mode === "none")
|
|
123
|
+
return { args: [...CLAUDE_NO_MCP_ARGS], env: { ...CLAUDE_NO_CONNECTORS_ENV } };
|
|
124
|
+
const servers = (mode as { mcpServers?: unknown } | null)?.mcpServers;
|
|
125
|
+
if (!servers || typeof servers !== "object" || Array.isArray(servers)) {
|
|
126
|
+
throw new Error(
|
|
127
|
+
'mcp must be "none", "inherit", or { mcpServers: { <name>: <server config> } }',
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
args: ["--strict-mcp-config", "--mcp-config", JSON.stringify({ mcpServers: servers })],
|
|
132
|
+
env: { ...CLAUDE_NO_CONNECTORS_ENV },
|
|
133
|
+
};
|
|
80
134
|
}
|
|
81
135
|
|
|
82
136
|
export interface HeadlessResult {
|
|
@@ -135,6 +189,7 @@ interface StagedCall {
|
|
|
135
189
|
model?: string;
|
|
136
190
|
timeoutMs: number;
|
|
137
191
|
maxTurns: number;
|
|
192
|
+
mcp?: ClaudeMcpMode;
|
|
138
193
|
}
|
|
139
194
|
|
|
140
195
|
/** Write the request's images into a fresh neutral dir and resolve the
|
|
@@ -154,16 +209,23 @@ function stageCall(req: HeadlessRequest): StagedCall {
|
|
|
154
209
|
model: coordEnv("HEADLESS_MODEL") ?? req.model,
|
|
155
210
|
timeoutMs: req.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
|
156
211
|
maxTurns: req.maxTurns ?? DEFAULT_MAX_TURNS,
|
|
212
|
+
...(req.mcp ? { mcp: req.mcp } : {}),
|
|
157
213
|
};
|
|
158
214
|
}
|
|
159
215
|
|
|
160
216
|
/** Run a harness CLI from the neutral dir with stdin CLOSED (see module
|
|
161
217
|
* comment: an open stdin pipe deadlocks codex). */
|
|
162
|
-
function execHarness(
|
|
218
|
+
function execHarness(
|
|
219
|
+
bin: string,
|
|
220
|
+
args: string[],
|
|
221
|
+
call: StagedCall,
|
|
222
|
+
extraEnv: Record<string, string> = {},
|
|
223
|
+
) {
|
|
163
224
|
const pending = execFileAsync(bin, args, {
|
|
164
225
|
cwd: call.dir,
|
|
165
226
|
timeout: call.timeoutMs,
|
|
166
227
|
maxBuffer: MAX_BUFFER,
|
|
228
|
+
...(Object.keys(extraEnv).length ? { env: { ...process.env, ...extraEnv } } : {}),
|
|
167
229
|
});
|
|
168
230
|
pending.child.stdin?.end();
|
|
169
231
|
return pending;
|
|
@@ -178,7 +240,9 @@ async function runClaudeCode(bin: string, call: StagedCall): Promise<HeadlessRes
|
|
|
178
240
|
const args = ["-p", call.prompt, "--output-format", "json", "--max-turns", String(call.maxTurns)];
|
|
179
241
|
if (call.imagePaths.length) args.push("--allowedTools", "Read");
|
|
180
242
|
if (call.model) args.push("--model", call.model);
|
|
181
|
-
const
|
|
243
|
+
const mcp = claudeMcpLaunch(call.mcp);
|
|
244
|
+
args.push(...mcp.args);
|
|
245
|
+
const { stdout } = await execHarness(bin, args, call, mcp.env);
|
|
182
246
|
const envelope = JSON.parse(stdout) as { is_error?: boolean; result?: unknown };
|
|
183
247
|
if (envelope.is_error) {
|
|
184
248
|
throw new Error(
|
package/src/lib/http/client.ts
CHANGED
|
@@ -54,15 +54,6 @@ export interface FetchOptions<TResponseType extends FetchResponseType = "text">
|
|
|
54
54
|
extraCookies?: ExtraCookies;
|
|
55
55
|
/** Response body representation. Default `text`; use `bytes` for lossless file output. */
|
|
56
56
|
responseType?: TResponseType;
|
|
57
|
-
/**
|
|
58
|
-
* With `redirect: "follow"`, follow redirects in this client instead of the
|
|
59
|
-
* runtime's fetch. Every hop then gets its own cookie header, `extraHeaders`
|
|
60
|
-
* call, and Set-Cookie persistence, so a caller that inspects those can stop
|
|
61
|
-
* a chain between hops. Credential headers are dropped on a cross-origin hop.
|
|
62
|
-
* Default: on whenever a `jar` is passed (host-only cookies must follow the
|
|
63
|
-
* jar's exact-host rule on every hop), off without one.
|
|
64
|
-
*/
|
|
65
|
-
clientRedirects?: boolean;
|
|
66
57
|
}
|
|
67
58
|
|
|
68
59
|
type FetchBody<TResponseType extends FetchResponseType> = TResponseType extends "bytes"
|
|
@@ -86,23 +77,40 @@ export interface FetchResult<TBody = string> {
|
|
|
86
77
|
* Pass `responseType: "bytes"` when the caller must preserve the body exactly.
|
|
87
78
|
* Streams aren't supported; this is a CLI helper, not a streaming HTTP
|
|
88
79
|
* client. Use Bun's `fetch` directly for streaming workloads.
|
|
80
|
+
*
|
|
81
|
+
* With `redirect: "follow"` (the default) this client follows redirects
|
|
82
|
+
* itself instead of the runtime's fetch, with or without a jar, so one
|
|
83
|
+
* policy governs every hop:
|
|
84
|
+
* - Each hop gets its own jar Cookie header (exact-host for host-only
|
|
85
|
+
* cookies), its own `extraHeaders(url)` call for the hop's URL, and its
|
|
86
|
+
* own Set-Cookie persistence. A callback that throws stops the chain
|
|
87
|
+
* before the next request.
|
|
88
|
+
* - A same-origin hop keeps every caller header.
|
|
89
|
+
* - A cross-origin hop keeps only the caller headers in
|
|
90
|
+
* `CROSS_ORIGIN_REDIRECT_HEADERS`; credentials, bypass tokens, and custom
|
|
91
|
+
* `X-*` headers are dropped for the rest of the chain.
|
|
92
|
+
* - A cross-origin 307 or 308 on a request that is not GET or HEAD throws
|
|
93
|
+
* instead of resending the body to the new origin.
|
|
89
94
|
*/
|
|
90
95
|
const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
|
|
91
96
|
const MAX_CLIENT_REDIRECTS = 20;
|
|
92
97
|
|
|
98
|
+
/** Caller headers that survive a redirect to a different origin (lowercase). */
|
|
99
|
+
export const CROSS_ORIGIN_REDIRECT_HEADERS: ReadonlySet<string> = new Set([
|
|
100
|
+
"user-agent",
|
|
101
|
+
"accept",
|
|
102
|
+
"accept-language",
|
|
103
|
+
"range",
|
|
104
|
+
]);
|
|
105
|
+
|
|
93
106
|
export async function fetchWithJar<TResponseType extends FetchResponseType = "text">(
|
|
94
107
|
url: string,
|
|
95
108
|
opts: FetchOptions<TResponseType> = {},
|
|
96
109
|
): Promise<FetchResult<FetchBody<TResponseType>>> {
|
|
97
|
-
|
|
98
|
-
// header from the jar (exact-host for host-only cookies) and its own
|
|
99
|
-
// Set-Cookie persistence; the runtime would otherwise carry or drop the
|
|
100
|
-
// first hop's header on its own rules. Pass `clientRedirects: false` to opt out.
|
|
101
|
-
const clientRedirects = opts.clientRedirects ?? Boolean(opts.jar);
|
|
102
|
-
if (!clientRedirects || (opts.redirect ?? "follow") !== "follow") return fetchOnce(url, opts);
|
|
110
|
+
if ((opts.redirect ?? "follow") !== "follow") return fetchOnce(url, opts);
|
|
103
111
|
|
|
104
112
|
let current = url;
|
|
105
|
-
let method = opts.method ?? "GET";
|
|
113
|
+
let method = (opts.method ?? "GET").toUpperCase();
|
|
106
114
|
let body = opts.body;
|
|
107
115
|
let headers = opts.headers;
|
|
108
116
|
let cookiesSaved = 0;
|
|
@@ -115,17 +123,23 @@ export async function fetchWithJar<TResponseType extends FetchResponseType = "te
|
|
|
115
123
|
throw new Error(`Too many redirects (more than ${MAX_CLIENT_REDIRECTS}) starting at ${url}`);
|
|
116
124
|
}
|
|
117
125
|
const next = new URL(location, current);
|
|
126
|
+
const crossOrigin = next.origin !== new URL(current).origin;
|
|
118
127
|
if (
|
|
119
128
|
result.status === 303 ||
|
|
120
129
|
((result.status === 301 || result.status === 302) && method !== "GET" && method !== "HEAD")
|
|
121
130
|
) {
|
|
122
131
|
method = "GET";
|
|
123
132
|
body = undefined;
|
|
133
|
+
} else if (crossOrigin && method !== "GET" && method !== "HEAD") {
|
|
134
|
+
throw new Error(
|
|
135
|
+
`Refusing to follow a ${result.status} redirect of a ${method} request from ${new URL(current).origin} to ${next.origin}: ` +
|
|
136
|
+
"it would resend the request body to a different origin. Send the request to the new URL directly if that is intended.",
|
|
137
|
+
);
|
|
124
138
|
}
|
|
125
|
-
if (
|
|
139
|
+
if (crossOrigin && headers) {
|
|
126
140
|
headers = Object.fromEntries(
|
|
127
|
-
Object.entries(headers).filter(
|
|
128
|
-
|
|
141
|
+
Object.entries(headers).filter(([name]) =>
|
|
142
|
+
CROSS_ORIGIN_REDIRECT_HEADERS.has(name.toLowerCase()),
|
|
129
143
|
),
|
|
130
144
|
);
|
|
131
145
|
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* `.claude/skills/`; Cursor and Codex use `.agents/skills/`.
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
24
25
|
import {
|
|
25
26
|
existsSync,
|
|
26
27
|
mkdirSync,
|
|
@@ -31,7 +32,11 @@ import {
|
|
|
31
32
|
writeFileSync,
|
|
32
33
|
} from "node:fs";
|
|
33
34
|
import { dirname, join } from "node:path";
|
|
34
|
-
import {
|
|
35
|
+
import {
|
|
36
|
+
endOfTurnStatusCommand,
|
|
37
|
+
readComputerConfig,
|
|
38
|
+
stripJsonComments,
|
|
39
|
+
} from "../../core/config.ts";
|
|
35
40
|
import { readHostAddendum } from "./host-addendum.ts";
|
|
36
41
|
import {
|
|
37
42
|
checkOwnedSkill,
|
|
@@ -49,6 +54,7 @@ import {
|
|
|
49
54
|
INSTRUCTIONS_REGION,
|
|
50
55
|
renderInstructionsBlock,
|
|
51
56
|
SKILLS,
|
|
57
|
+
type SkillTemplate,
|
|
52
58
|
} from "./templates.ts";
|
|
53
59
|
|
|
54
60
|
const AGENTS_FILE = "AGENTS.md";
|
|
@@ -134,12 +140,35 @@ export function readSkillsExclude(projectRoot: string): Set<string> {
|
|
|
134
140
|
function blockSkills(projectRoot: string, adapter: string): BlockSkills {
|
|
135
141
|
const supported = adapterSkillsDir(adapter) !== null;
|
|
136
142
|
const exclude = readSkillsExclude(projectRoot);
|
|
137
|
-
|
|
143
|
+
const skills: BlockSkills = {
|
|
138
144
|
decide: supported && !exclude.has("harn-decide"),
|
|
139
145
|
council: supported && !exclude.has("harn-council"),
|
|
140
146
|
end: supported && !exclude.has("harn-end"),
|
|
141
147
|
team: supported && !exclude.has("harn-team"),
|
|
142
148
|
};
|
|
149
|
+
// Only an enabled project's block mentions the opt-in skill, so every other
|
|
150
|
+
// project's block stays byte-identical.
|
|
151
|
+
if (supported && !exclude.has("harn-computer") && readComputerConfig(projectRoot).enabled) {
|
|
152
|
+
skills.computer = true;
|
|
153
|
+
}
|
|
154
|
+
return skills;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** False for an opt-in skill whose config gate is off in this project. */
|
|
158
|
+
function skillOptedIn(projectRoot: string, skill: SkillTemplate): boolean {
|
|
159
|
+
if (skill.optIn === "computer") return readComputerConfig(projectRoot).enabled;
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/** Delete an owned skill file and its now-empty harn-* dir (adapter root stays). */
|
|
164
|
+
function removeOwnedSkillFile(skillPath: string): void {
|
|
165
|
+
rmSync(skillPath);
|
|
166
|
+
const dir = dirname(skillPath);
|
|
167
|
+
try {
|
|
168
|
+
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
169
|
+
} catch {
|
|
170
|
+
/* dir not empty or gone → leave it */
|
|
171
|
+
}
|
|
143
172
|
}
|
|
144
173
|
|
|
145
174
|
interface ApplyOpts {
|
|
@@ -170,6 +199,9 @@ export function applyInstructions(projectRoot: string, opts: ApplyOpts): ApplyRe
|
|
|
170
199
|
// aborts the whole run instead of leaving AGENTS.md half-updated.
|
|
171
200
|
const addendum = readHostAddendum(projectRoot);
|
|
172
201
|
|
|
202
|
+
const parentScope = parentScopeWarning(projectRoot);
|
|
203
|
+
if (parentScope) warnings.push(parentScope);
|
|
204
|
+
|
|
173
205
|
// ── AGENTS.md orientation block ─────────────────────────────────────────
|
|
174
206
|
const agentsPath = join(projectRoot, AGENTS_FILE);
|
|
175
207
|
const agentsExisted = existsSync(agentsPath);
|
|
@@ -272,6 +304,16 @@ export function applyInstructions(projectRoot: string, opts: ApplyOpts): ApplyRe
|
|
|
272
304
|
continue;
|
|
273
305
|
}
|
|
274
306
|
const skillPath = join(projectRoot, skillsDir, skill.relPath);
|
|
307
|
+
if (!skillOptedIn(projectRoot, skill)) {
|
|
308
|
+
// Turning the gate off takes back a skill an earlier init wrote.
|
|
309
|
+
if (existsSync(skillPath) && isOwnedFile(readFileSync(skillPath, "utf8"))) {
|
|
310
|
+
if (!opts.dryRun) removeOwnedSkillFile(skillPath);
|
|
311
|
+
actions.push(
|
|
312
|
+
`+ ${verbed("remove", "removed")} skill ${skill.id} (${skill.optIn}.enabled is off)`,
|
|
313
|
+
);
|
|
314
|
+
}
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
275
317
|
const content = skill.render(opts.binName);
|
|
276
318
|
const before = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : null;
|
|
277
319
|
if (before === content) {
|
|
@@ -292,6 +334,53 @@ export function applyInstructions(projectRoot: string, opts: ApplyOpts): ApplyRe
|
|
|
292
334
|
return { actions, warnings };
|
|
293
335
|
}
|
|
294
336
|
|
|
337
|
+
/** True when `dir` is inside a real Git work tree (an empty `.git` folder does not count). */
|
|
338
|
+
function insideGitWorkTree(dir: string): boolean {
|
|
339
|
+
const r = spawnSync("git", ["-C", dir, "rev-parse", "--is-inside-work-tree"], {
|
|
340
|
+
encoding: "utf8",
|
|
341
|
+
});
|
|
342
|
+
return r.status === 0 && r.stdout.trim() === "true";
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
const PARENT_SCAN_SKIP = new Set(["node_modules"]);
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* Warn when instructions are being installed in a folder that is not a Git
|
|
349
|
+
* work tree but holds projects carrying their own Harnery instructions block.
|
|
350
|
+
* Harnesses that read instruction files from parent folders (Claude Code walks
|
|
351
|
+
* up for CLAUDE.md) would load this block on top of each project's own, and
|
|
352
|
+
* with the wrong command name when a project pins a different one. Installing
|
|
353
|
+
* in a non-Git folder stays supported; this only names the overlap.
|
|
354
|
+
*/
|
|
355
|
+
export function parentScopeWarning(projectRoot: string): string | null {
|
|
356
|
+
if (insideGitWorkTree(projectRoot)) return null;
|
|
357
|
+
let entries: string[];
|
|
358
|
+
try {
|
|
359
|
+
entries = readdirSync(projectRoot, { withFileTypes: true })
|
|
360
|
+
.filter((e) => e.isDirectory() && !e.name.startsWith(".") && !PARENT_SCAN_SKIP.has(e.name))
|
|
361
|
+
.map((e) => e.name)
|
|
362
|
+
.sort();
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
const marker = `harnery:begin ${INSTRUCTIONS_REGION}`;
|
|
367
|
+
const children = entries.filter((name) => {
|
|
368
|
+
try {
|
|
369
|
+
return readFileSync(join(projectRoot, name, AGENTS_FILE), "utf8").includes(marker);
|
|
370
|
+
} catch {
|
|
371
|
+
return false;
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
if (children.length === 0) return null;
|
|
375
|
+
const listed =
|
|
376
|
+
children.slice(0, 5).join(", ") + (children.length > 5 ? `, +${children.length - 5} more` : "");
|
|
377
|
+
return (
|
|
378
|
+
`${projectRoot} is not a Git work tree and contains ${children.length} project(s) with their own ` +
|
|
379
|
+
`Harnery instructions (${listed}). Harnesses that read parent folders, such as Claude Code, will load ` +
|
|
380
|
+
`this block on top of theirs. Run init inside each project instead, or pass --project-root <project>.`
|
|
381
|
+
);
|
|
382
|
+
}
|
|
383
|
+
|
|
295
384
|
interface RemoveOpts {
|
|
296
385
|
adapter: string;
|
|
297
386
|
dryRun: boolean;
|
|
@@ -382,16 +471,7 @@ export function removeInstructions(projectRoot: string, opts: RemoveOpts): Apply
|
|
|
382
471
|
warnings.push(`left ${skill.relPath} (hand-edited; no harnery ownership marker)`);
|
|
383
472
|
continue;
|
|
384
473
|
}
|
|
385
|
-
if (!opts.dryRun)
|
|
386
|
-
rmSync(skillPath);
|
|
387
|
-
// Drop the now-empty harn-* dir, leaving the adapter skills root intact.
|
|
388
|
-
const dir = dirname(skillPath);
|
|
389
|
-
try {
|
|
390
|
-
if (readdirSync(dir).length === 0) rmdirSync(dir);
|
|
391
|
-
} catch {
|
|
392
|
-
/* dir not empty or gone → leave it */
|
|
393
|
-
}
|
|
394
|
-
}
|
|
474
|
+
if (!opts.dryRun) removeOwnedSkillFile(skillPath);
|
|
395
475
|
actions.push(`+ ${opts.dryRun ? "would delete" : "deleted"} skill ${skill.id}`);
|
|
396
476
|
}
|
|
397
477
|
}
|
|
@@ -453,6 +533,14 @@ export function checkInstructions(
|
|
|
453
533
|
if (exclude.has(skill.id)) continue;
|
|
454
534
|
const skillPath = join(projectRoot, skillsDir, skill.relPath);
|
|
455
535
|
const c = existsSync(skillPath) ? readFileSync(skillPath, "utf8") : "";
|
|
536
|
+
if (!skillOptedIn(projectRoot, skill)) {
|
|
537
|
+
if (isOwnedFile(c)) {
|
|
538
|
+
issues.push(
|
|
539
|
+
`skill ${skill.id}: present but ${skill.optIn}.enabled is off (re-run init)`,
|
|
540
|
+
);
|
|
541
|
+
}
|
|
542
|
+
continue;
|
|
543
|
+
}
|
|
456
544
|
note(`skill ${skill.id}`, checkOwnedSkill(c, skillBody(skill.render, opts.binName)));
|
|
457
545
|
}
|
|
458
546
|
}
|
|
@@ -53,18 +53,21 @@ function regionName(event: GitHookEvent): string {
|
|
|
53
53
|
*/
|
|
54
54
|
export function renderGitHookBody(event: GitHookEvent): string {
|
|
55
55
|
const blocking = event === "pre-commit";
|
|
56
|
+
const native = process.platform === "win32";
|
|
57
|
+
const command = `${native ? "node " : ""}"$harnery_hook_bin"`;
|
|
58
|
+
const extension = native ? ".mjs" : "";
|
|
56
59
|
const invoke = blocking
|
|
57
|
-
? `
|
|
60
|
+
? ` ${command} git-hook ${event} "$@"\n` +
|
|
58
61
|
` harnery_hook_rc=$?\n` +
|
|
59
62
|
` [ "$harnery_hook_rc" -ne 0 ] && exit "$harnery_hook_rc"\n`
|
|
60
|
-
: `
|
|
63
|
+
: ` ${command} git-hook ${event} "$@" >/dev/null 2>&1 || true\n`;
|
|
61
64
|
return (
|
|
62
65
|
`# Coordination for this hook lives in harnery (agent-coord git-hook ${event});\n` +
|
|
63
66
|
`# upgrading harnery upgrades the behavior. Managed region: do not edit by hand.\n` +
|
|
64
67
|
`harnery_hook_root=$(git rev-parse --show-superproject-working-tree 2>/dev/null)\n` +
|
|
65
68
|
`[ -n "$harnery_hook_root" ] || harnery_hook_root=$(git rev-parse --show-toplevel 2>/dev/null)\n` +
|
|
66
|
-
`for harnery_hook_bin in "$harnery_hook_root/harnery/bin/agent-coord" "$harnery_hook_root/node_modules/harnery/bin/agent-coord"; do\n` +
|
|
67
|
-
` [
|
|
69
|
+
`for harnery_hook_bin in "$harnery_hook_root/harnery/bin/agent-coord${extension}" "$harnery_hook_root/node_modules/harnery/bin/agent-coord${extension}"; do\n` +
|
|
70
|
+
` [ -${native ? "f" : "x"} "$harnery_hook_bin" ] || continue\n` +
|
|
68
71
|
invoke +
|
|
69
72
|
` break\n` +
|
|
70
73
|
`done`
|
|
@@ -79,6 +82,7 @@ export function renderGitHookBody(event: GitHookEvent): string {
|
|
|
79
82
|
*/
|
|
80
83
|
export function resolveHooksDir(projectRoot: string): string | null {
|
|
81
84
|
const r = spawnSync("git", ["rev-parse", "--git-path", "hooks"], {
|
|
85
|
+
windowsHide: true,
|
|
82
86
|
cwd: projectRoot,
|
|
83
87
|
encoding: "utf8",
|
|
84
88
|
timeout: 5000,
|
|
@@ -36,6 +36,11 @@ export interface BlockSkills {
|
|
|
36
36
|
end: boolean;
|
|
37
37
|
/** the `harn-team` skill file is present */
|
|
38
38
|
team: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* the opt-in `harn-computer` skill file is present (only when the project
|
|
41
|
+
* sets `computer.enabled`); absent from the default block entirely
|
|
42
|
+
*/
|
|
43
|
+
computer?: boolean;
|
|
39
44
|
}
|
|
40
45
|
|
|
41
46
|
/**
|
|
@@ -78,6 +83,11 @@ export function renderInstructionsBlock(
|
|
|
78
83
|
const teamPointer = skills.team
|
|
79
84
|
? "The `harn-team` skill owns the tier choice, the governor artifacts, and the drive loop."
|
|
80
85
|
: `See \`${b} governor --help\` for the tier choice and the drive loop.`;
|
|
86
|
+
// Opt-in surface: the default block stays byte-identical unless the project
|
|
87
|
+
// enabled computer use and the skill is actually installed.
|
|
88
|
+
const computerPointer = skills.computer
|
|
89
|
+
? `\n\n**Desktop control.** This project enables \`${b} computer\` for driving native desktop apps and real browsers as a last resort. The \`harn-computer\` skill has the loop, and the desktop is one shared resource held by one agent at a time.`
|
|
90
|
+
: "";
|
|
81
91
|
// Render the journal categories from the canonical enum so this prose can
|
|
82
92
|
// never drift from what `journal add` actually accepts (the "note, plan…" list
|
|
83
93
|
// silently lagged the tool by two categories before this).
|
|
@@ -165,7 +175,7 @@ can't resolve from the repo, file it instead. \`${b} decision file "<question>"\
|
|
|
165
175
|
records it and lets you proceed on a stated default; \`${b} decision search "<terms>"\`
|
|
166
176
|
surfaces prior decisions, so check for precedent before re-deciding. ${decidePointer}
|
|
167
177
|
|
|
168
|
-
**Councils.** For a hard or contested decision, convene a council of agents. \`${b} council create "<objective>"\` runs structured rounds toward a decision. ${councilPointer}`;
|
|
178
|
+
**Councils.** For a hard or contested decision, convene a council of agents. \`${b} council create "<objective>"\` runs structured rounds toward a decision. ${councilPointer}${computerPointer}`;
|
|
169
179
|
}
|
|
170
180
|
|
|
171
181
|
// ── Skills ──────────────────────────────────────────────────────────────────
|
|
@@ -176,6 +186,12 @@ export interface SkillTemplate {
|
|
|
176
186
|
/** path under the adapter skill dir, e.g. `harn-decide/SKILL.md` */
|
|
177
187
|
relPath: string;
|
|
178
188
|
render: (binName: string) => string;
|
|
189
|
+
/**
|
|
190
|
+
* Config gate for an opt-in skill. `"computer"` renders only when the project
|
|
191
|
+
* sets `computer.enabled: true`; ungated skills always render (minus
|
|
192
|
+
* `skills.exclude`).
|
|
193
|
+
*/
|
|
194
|
+
optIn?: "computer";
|
|
179
195
|
}
|
|
180
196
|
|
|
181
197
|
function decideBody(b: string): string {
|
|
@@ -518,6 +534,71 @@ single next action. If you declined to build a team because one already existed,
|
|
|
518
534
|
say that first; it is the most useful sentence in the reply.`;
|
|
519
535
|
}
|
|
520
536
|
|
|
537
|
+
function computerBody(b: string): string {
|
|
538
|
+
return `\`${b} computer\` drives this machine's desktop through a pinned desktop driver.
|
|
539
|
+
It is the last resort: use a vendor API, a CLI, or the browser tools first.
|
|
540
|
+
Reach for it only for a native desktop app, or a page that needs a real
|
|
541
|
+
browser you cannot drive any other way.
|
|
542
|
+
|
|
543
|
+
## Setup check
|
|
544
|
+
|
|
545
|
+
\`\`\`bash
|
|
546
|
+
${b} computer status
|
|
547
|
+
\`\`\`
|
|
548
|
+
|
|
549
|
+
It shows the host, the pinned driver and whether it is installed, the daemon,
|
|
550
|
+
and who holds the desktop. When the driver is missing, the project must enable
|
|
551
|
+
computer use and run \`${b} computer install --yes\`. \`${b} computer doctor\`
|
|
552
|
+
reports the driver's own health checks.
|
|
553
|
+
|
|
554
|
+
## Core loop
|
|
555
|
+
|
|
556
|
+
Every step is one call: \`${b} computer call <tool> '<json>'\`. Look up a tool's
|
|
557
|
+
arguments with \`${b} computer describe <tool>\` rather than guessing; \`${b} computer tools\`
|
|
558
|
+
lists them.
|
|
559
|
+
|
|
560
|
+
1. **Launch** the app in the background with \`launch_app\`.
|
|
561
|
+
2. **Read state** with \`get_window_state\` and \`include_screenshot: false\`. When
|
|
562
|
+
you need to see the window, pass \`screenshot_out_file\` with a file path
|
|
563
|
+
and read that file. Inline screenshots are redacted from the output unless
|
|
564
|
+
you pass \`--keep-screenshot\`, because they flood your context.
|
|
565
|
+
3. **Act by element token** \`"<snapshot_id>:<row>"\` from the latest state:
|
|
566
|
+
click, \`set_value\`, or invoke, batched through \`run_steps\` when several
|
|
567
|
+
actions belong together.
|
|
568
|
+
4. **Read back** the state after acting. Confirm the change landed before you
|
|
569
|
+
retry anything; never repeat an action blind.
|
|
570
|
+
|
|
571
|
+
## Rules
|
|
572
|
+
|
|
573
|
+
- Prefer accessibility routes (\`set_value\`, invoke) over \`type_text\` and
|
|
574
|
+
coordinate clicks.
|
|
575
|
+
- Never pass \`delivery_mode: "foreground"\` unless the driver returned
|
|
576
|
+
\`background_unavailable\` or \`delivery_failed\` for that action.
|
|
577
|
+
- For a browser, call \`browser_prepare\` with \`allow_launch: true\` and
|
|
578
|
+
\`profile: {"mode": "isolated_new"}\`. Never attach to the user's real
|
|
579
|
+
profile unless the operator asks for it.
|
|
580
|
+
- Never type a password, username, or one-time code into a sign-in page. Stop
|
|
581
|
+
and ask the operator to sign in.
|
|
582
|
+
- Only \`kill_app\` processes you launched.
|
|
583
|
+
|
|
584
|
+
## One desktop, one holder
|
|
585
|
+
|
|
586
|
+
Every agent on this machine shares one desktop. \`call\` takes a lease for you
|
|
587
|
+
and refreshes it on every call; it lapses after a short idle period.
|
|
588
|
+
|
|
589
|
+
- A refusal naming another holder means **wait**, then try once more later. Do
|
|
590
|
+
not retry in a loop. \`--wait <seconds>\` waits longer on one call.
|
|
591
|
+
- Check the holder with \`${b} computer lease status\`.
|
|
592
|
+
- Release the desktop as soon as you finish:
|
|
593
|
+
|
|
594
|
+
\`\`\`bash
|
|
595
|
+
${b} computer lease release
|
|
596
|
+
\`\`\`
|
|
597
|
+
|
|
598
|
+
Report what you did on the desktop, which apps you launched and closed, and
|
|
599
|
+
anything you left open.`;
|
|
600
|
+
}
|
|
601
|
+
|
|
521
602
|
export const SKILLS: SkillTemplate[] = [
|
|
522
603
|
{
|
|
523
604
|
id: "harn-decide",
|
|
@@ -567,4 +648,17 @@ export const SKILLS: SkillTemplate[] = [
|
|
|
567
648
|
body: teamBody(binName),
|
|
568
649
|
}),
|
|
569
650
|
},
|
|
651
|
+
{
|
|
652
|
+
id: "harn-computer",
|
|
653
|
+
relPath: "harn-computer/SKILL.md",
|
|
654
|
+
optIn: "computer",
|
|
655
|
+
render: (binName) =>
|
|
656
|
+
buildOwnedSkill({
|
|
657
|
+
name: "harn-computer",
|
|
658
|
+
description:
|
|
659
|
+
"Drive a native desktop app or a real browser through the project's pinned desktop driver, as a last resort after APIs, CLIs, and browser tools. Covers the setup check, the launch / read state / act by element token / read back loop, safety rules, and the shared desktop lease.",
|
|
660
|
+
binName,
|
|
661
|
+
body: computerBody(binName),
|
|
662
|
+
}),
|
|
663
|
+
},
|
|
570
664
|
];
|