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.
Files changed (176) hide show
  1. package/README.md +24 -0
  2. package/bin/README.md +10 -0
  3. package/bin/agent-coord.mjs +3 -0
  4. package/bin/agent-hook.mjs +3 -0
  5. package/bin/harn.mjs +3 -0
  6. package/bin/run.mjs +43 -0
  7. package/dist/commander.d.ts +18 -6
  8. package/dist/commander.d.ts.map +1 -1
  9. package/dist/commander.js +74 -2
  10. package/dist/commands/agents.d.ts +25 -0
  11. package/dist/commands/agents.d.ts.map +1 -1
  12. package/dist/commands/agents.js +70 -27
  13. package/dist/commands/autoupdate.d.ts +12 -0
  14. package/dist/commands/autoupdate.d.ts.map +1 -0
  15. package/dist/commands/autoupdate.js +190 -0
  16. package/dist/commands/computer.d.ts +45 -0
  17. package/dist/commands/computer.d.ts.map +1 -0
  18. package/dist/commands/computer.js +509 -0
  19. package/dist/commands/docs.js +3 -1
  20. package/dist/commands/fetch.js +11 -1
  21. package/dist/commands/grep.d.ts +72 -0
  22. package/dist/commands/grep.d.ts.map +1 -1
  23. package/dist/commands/grep.js +200 -47
  24. package/dist/commands/init.d.ts.map +1 -1
  25. package/dist/commands/init.js +15 -9
  26. package/dist/commands/rm.js +2 -2
  27. package/dist/commands/web.d.ts.map +1 -1
  28. package/dist/commands/web.js +2 -0
  29. package/dist/core/agents/canonical-emit.d.ts.map +1 -1
  30. package/dist/core/agents/canonical-emit.js +2 -3
  31. package/dist/core/agents/coord-bin.d.ts +3 -0
  32. package/dist/core/agents/coord-bin.d.ts.map +1 -1
  33. package/dist/core/agents/coord-bin.js +6 -0
  34. package/dist/core/agents/coord-client.d.ts.map +1 -1
  35. package/dist/core/agents/coord-client.js +6 -2
  36. package/dist/core/agents/render/prompt-context.d.ts +55 -7
  37. package/dist/core/agents/render/prompt-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/prompt-context.js +122 -48
  39. package/dist/core/agents/state/proc-start.d.ts +3 -1
  40. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  41. package/dist/core/agents/state/proc-start.js +26 -4
  42. package/dist/core/autoupdate/engine.d.ts +97 -0
  43. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  44. package/dist/core/autoupdate/engine.js +548 -0
  45. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  46. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  47. package/dist/core/autoupdate/fingerprint.js +73 -0
  48. package/dist/core/autoupdate/git.d.ts +30 -0
  49. package/dist/core/autoupdate/git.d.ts.map +1 -0
  50. package/dist/core/autoupdate/git.js +82 -0
  51. package/dist/core/autoupdate/index.d.ts +6 -0
  52. package/dist/core/autoupdate/index.d.ts.map +1 -0
  53. package/dist/core/autoupdate/index.js +4 -0
  54. package/dist/core/autoupdate/state.d.ts +59 -0
  55. package/dist/core/autoupdate/state.d.ts.map +1 -0
  56. package/dist/core/autoupdate/state.js +214 -0
  57. package/dist/core/autoupdate/types.d.ts +113 -0
  58. package/dist/core/autoupdate/types.d.ts.map +1 -0
  59. package/dist/core/autoupdate/types.js +35 -0
  60. package/dist/core/config.d.ts +53 -1
  61. package/dist/core/config.d.ts.map +1 -1
  62. package/dist/core/config.js +114 -1
  63. package/dist/core/events/v3/bootstrap.js +1 -0
  64. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  65. package/dist/core/events/v3/runtime-build.js +1 -0
  66. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  67. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  68. package/dist/core/hooks/adapter/wiring.js +38 -8
  69. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  70. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  71. package/dist/core/hooks/autoupdate-cue.js +53 -0
  72. package/dist/core/hooks/cli.js +28 -3
  73. package/dist/core/hooks/effects/index.js +4 -4
  74. package/dist/core/hooks/resolve/coord-root.js +1 -0
  75. package/dist/core/semantic/contract.d.ts +3 -3
  76. package/dist/core/semantic/service.d.ts.map +1 -1
  77. package/dist/core/semantic/service.js +4 -2
  78. package/dist/core/storage/modes.d.ts +1 -1
  79. package/dist/core/storage/modes.d.ts.map +1 -1
  80. package/dist/core/storage/modes.js +5 -1
  81. package/dist/core/workflow/durable-record.d.ts +2 -0
  82. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  83. package/dist/core/workflow/durable-record.js +13 -1
  84. package/dist/core/workflow/engine.js +4 -0
  85. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  86. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  87. package/dist/core/workflow/spawn-claude.js +16 -4
  88. package/dist/core/workflow/specialists.d.ts.map +1 -1
  89. package/dist/core/workflow/specialists.js +1 -0
  90. package/dist/core/workflow/types.d.ts +10 -0
  91. package/dist/core/workflow/types.d.ts.map +1 -1
  92. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  93. package/dist/core/workflow/workspaces/leases.js +1 -9
  94. package/dist/lib/computer/cua.d.ts +50 -0
  95. package/dist/lib/computer/cua.d.ts.map +1 -0
  96. package/dist/lib/computer/cua.js +474 -0
  97. package/dist/lib/computer/host.d.ts +19 -0
  98. package/dist/lib/computer/host.d.ts.map +1 -0
  99. package/dist/lib/computer/host.js +72 -0
  100. package/dist/lib/computer/lease.d.ts +13 -0
  101. package/dist/lib/computer/lease.d.ts.map +1 -0
  102. package/dist/lib/computer/lease.js +269 -0
  103. package/dist/lib/computer/types.d.ts +163 -0
  104. package/dist/lib/computer/types.d.ts.map +1 -0
  105. package/dist/lib/computer/types.js +10 -0
  106. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  107. package/dist/lib/docs-metadata-v2.js +6 -5
  108. package/dist/lib/exec.d.ts.map +1 -1
  109. package/dist/lib/exec.js +1 -0
  110. package/dist/lib/guarded-remove.d.ts.map +1 -1
  111. package/dist/lib/guarded-remove.js +5 -4
  112. package/dist/lib/headless/index.d.ts +33 -0
  113. package/dist/lib/headless/index.d.ts.map +1 -1
  114. package/dist/lib/headless/index.js +38 -2
  115. package/dist/lib/http/client.d.ts +2 -9
  116. package/dist/lib/http/client.d.ts.map +1 -1
  117. package/dist/lib/http/client.js +30 -9
  118. package/dist/lib/instructions/apply.d.ts +9 -0
  119. package/dist/lib/instructions/apply.d.ts.map +1 -1
  120. package/dist/lib/instructions/apply.js +92 -14
  121. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  122. package/dist/lib/instructions/git-hooks.js +8 -4
  123. package/dist/lib/instructions/templates.d.ts +11 -0
  124. package/dist/lib/instructions/templates.d.ts.map +1 -1
  125. package/dist/lib/instructions/templates.js +81 -1
  126. package/package.json +5 -5
  127. package/schemas/config.schema.json +89 -1
  128. package/src/commander.ts +98 -8
  129. package/src/commands/agents.ts +100 -32
  130. package/src/commands/autoupdate.ts +250 -0
  131. package/src/commands/computer.ts +682 -0
  132. package/src/commands/docs.ts +3 -1
  133. package/src/commands/fetch.ts +11 -1
  134. package/src/commands/grep.ts +274 -59
  135. package/src/commands/init.ts +17 -7
  136. package/src/commands/rm.ts +2 -2
  137. package/src/commands/web.ts +2 -0
  138. package/src/core/agents/canonical-emit.ts +2 -3
  139. package/src/core/agents/coord-bin.ts +30 -0
  140. package/src/core/agents/coord-client.ts +6 -2
  141. package/src/core/agents/render/prompt-context.ts +152 -53
  142. package/src/core/agents/state/proc-start.ts +29 -5
  143. package/src/core/autoupdate/engine.ts +691 -0
  144. package/src/core/autoupdate/fingerprint.ts +89 -0
  145. package/src/core/autoupdate/git.ts +120 -0
  146. package/src/core/autoupdate/index.ts +29 -0
  147. package/src/core/autoupdate/state.ts +269 -0
  148. package/src/core/autoupdate/types.ts +135 -0
  149. package/src/core/config.ts +142 -1
  150. package/src/core/events/v3/bootstrap.ts +1 -0
  151. package/src/core/events/v3/runtime-build.ts +1 -0
  152. package/src/core/hooks/adapter/wiring.ts +51 -6
  153. package/src/core/hooks/autoupdate-cue.ts +72 -0
  154. package/src/core/hooks/cli.ts +27 -1
  155. package/src/core/hooks/effects/index.ts +4 -4
  156. package/src/core/hooks/resolve/coord-root.ts +1 -0
  157. package/src/core/semantic/service.ts +6 -2
  158. package/src/core/storage/modes.ts +7 -1
  159. package/src/core/workflow/durable-record.ts +15 -1
  160. package/src/core/workflow/engine.ts +3 -0
  161. package/src/core/workflow/spawn-claude.ts +16 -4
  162. package/src/core/workflow/specialists.ts +1 -0
  163. package/src/core/workflow/types.ts +10 -0
  164. package/src/core/workflow/workspaces/leases.ts +1 -9
  165. package/src/lib/computer/cua.ts +572 -0
  166. package/src/lib/computer/host.ts +87 -0
  167. package/src/lib/computer/lease.ts +299 -0
  168. package/src/lib/computer/types.ts +177 -0
  169. package/src/lib/docs-metadata-v2.ts +24 -5
  170. package/src/lib/exec.ts +1 -0
  171. package/src/lib/guarded-remove.ts +5 -3
  172. package/src/lib/headless/index.ts +66 -2
  173. package/src/lib/http/client.ts +33 -19
  174. package/src/lib/instructions/apply.ts +100 -12
  175. package/src/lib/instructions/git-hooks.ts +8 -4
  176. 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(bin: string, args: string[], call: StagedCall) {
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 { stdout } = await execHarness(bin, args, call);
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(
@@ -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
- // With a jar, redirects are followed here so every hop gets its own Cookie
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 (next.origin !== new URL(current).origin && headers) {
139
+ if (crossOrigin && headers) {
126
140
  headers = Object.fromEntries(
127
- Object.entries(headers).filter(
128
- ([name]) => !["authorization", "cookie"].includes(name.toLowerCase()),
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 { endOfTurnStatusCommand, stripJsonComments } from "../../core/config.ts";
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
- return {
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
- ? ` "$harnery_hook_bin" git-hook ${event} "$@"\n` +
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
- : ` "$harnery_hook_bin" git-hook ${event} "$@" >/dev/null 2>&1 || true\n`;
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
- ` [ -x "$harnery_hook_bin" ] || continue\n` +
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
  ];