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
@@ -11,7 +11,8 @@
11
11
  * `coord` (heartbeat freshness), `logs` (structured-log storage budgets),
12
12
  * `artifacts` (working-file retention),
13
13
  * `backup` (restic repo/password/prune policy), `sync` (rclone
14
- * remote/prefix), and `web` (dashboard port) sections. The `files` deny/override section
14
+ * remote/prefix), `computer` (opt-in desktop control), `autoUpdate` (activity-fired
15
+ * project self-update), and `web` (dashboard port) sections. The `files` deny/override section
15
16
  * is parsed separately by `web/lib/files.ts`.
16
17
  *
17
18
  * Env vars and CLI flags override any config value per invocation (each accessor
@@ -24,6 +25,7 @@ import { homedir } from "node:os";
24
25
  import { isAbsolute, join, resolve } from "node:path";
25
26
  import { coordEnv } from "../lib/env.ts";
26
27
  import { DEFAULT_WEB_PORT } from "../lib/local-file-url.ts";
28
+ import { AUTOUPDATE_DEFAULTS, type AutoUpdateConfig } from "./autoupdate/types.ts";
27
29
  import { resolveEventLedgerRotateActiveBytesV3 } from "./events/v3/rotation-config.ts";
28
30
  import {
29
31
  type PromptContextConfig,
@@ -82,6 +84,8 @@ interface HarneryConfig {
82
84
  promptContext?: unknown;
83
85
  /** Project-only opt-in; read via `codexWindowsBridgeConfig()`. */
84
86
  codexWindowsBridge?: unknown;
87
+ /** Project-only opt-in; read via `portableHookCommands()`. */
88
+ portableCommands?: unknown;
85
89
  };
86
90
  /**
87
91
  * Agent-ritual policy owned by the host project. Git finalization is opt-in:
@@ -220,6 +224,24 @@ interface HarneryConfig {
220
224
  sync?: { remote?: string; prefix?: string };
221
225
  /** Standalone dashboard defaults. */
222
226
  web?: { port?: number; bind?: string };
227
+ /**
228
+ * Opt-in desktop control (`computer` command, ADR 0228). Inert unless
229
+ * `enabled` is true. Project config only; read via `readComputerConfig()`.
230
+ */
231
+ computer?: { enabled?: boolean; idle_seconds?: number; wait_seconds?: number };
232
+ /**
233
+ * Activity-fired project self-update (ADR 0229). Inert unless `enabled` is
234
+ * true. Project config only; read via `readAutoUpdateConfig()`.
235
+ */
236
+ autoUpdate?: {
237
+ enabled?: boolean;
238
+ checkMinutes?: number;
239
+ steps?: unknown;
240
+ dailySteps?: unknown;
241
+ watch?: unknown;
242
+ timeoutMinutes?: number;
243
+ cleanup?: boolean;
244
+ };
223
245
  [k: string]: unknown;
224
246
  }
225
247
 
@@ -545,6 +567,18 @@ export function codexWindowsBridgeConfig(projectRoot: string): CodexWindowsBridg
545
567
  return { entryPoint, error: null };
546
568
  }
547
569
 
570
+ /**
571
+ * Per-project opt-in that makes `init` write the same adapter hook commands on
572
+ * every OS: `agent-hook` from PATH, plus a native Codex `commandWindows`. A
573
+ * project that commits its settings files and is cloned on Windows and POSIX
574
+ * needs it, because the default form depends on the OS that ran `init`. Read
575
+ * from the project config only, for the same reason as the Windows bridge.
576
+ * Anything but `true` keeps the default.
577
+ */
578
+ export function portableHookCommands(projectRoot: string): boolean {
579
+ return readProjectConfig(projectRoot).hooks?.portableCommands === true;
580
+ }
581
+
548
582
  export function hostPromptReminder(coordRoot?: string | null): string | null {
549
583
  const root = coordRoot ?? findCoordRoot();
550
584
  if (!root) return null;
@@ -718,6 +752,113 @@ export function readAdmissionConfig(coordRoot?: string | null): AdmissionProject
718
752
  };
719
753
  }
720
754
 
755
+ /** Effective `computer` settings; unset tunables fall back to the command's defaults. */
756
+ export interface ComputerProjectConfig {
757
+ enabled: boolean;
758
+ /** Desktop lease idle expiry in seconds. */
759
+ idle_seconds?: number;
760
+ /** How long `computer call` waits for another agent's lease, in seconds. */
761
+ wait_seconds?: number;
762
+ }
763
+
764
+ /**
765
+ * Desktop-control settings from `.harnery/config.jsonc`:
766
+ *
767
+ * ```jsonc
768
+ * "computer": { "enabled": true, "idle_seconds": 120, "wait_seconds": 30 }
769
+ * ```
770
+ *
771
+ * Project config only: enabling desktop control is a per-project decision, so a
772
+ * user-global value never turns it on. Malformed values are dropped one by one;
773
+ * `enabled` is true only when the project sets it to exactly `true`.
774
+ */
775
+ export function readComputerConfig(coordRoot?: string | null): ComputerProjectConfig {
776
+ const root = coordRoot ?? findCoordRoot();
777
+ if (!root) return { enabled: false };
778
+ const raw = readProjectConfig(root).computer;
779
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { enabled: false };
780
+ const record = raw as Record<string, unknown>;
781
+ const idle = positiveNumber(record.idle_seconds, 86_400);
782
+ const wait = record.wait_seconds;
783
+ const waitOk = typeof wait === "number" && Number.isFinite(wait) && wait >= 0 && wait <= 3600;
784
+ return {
785
+ enabled: record.enabled === true,
786
+ ...(idle !== undefined ? { idle_seconds: idle } : {}),
787
+ ...(waitOk ? { wait_seconds: wait } : {}),
788
+ };
789
+ }
790
+
791
+ // Project-only parse cache keyed by the file's stat signature. Hooks call the
792
+ // auto-update reader on every prompt, so an unchanged file costs one stat.
793
+ let projectCache: { path: string; signature: string; cfg: HarneryConfig } | null = null;
794
+
795
+ function readProjectConfigCached(root: string): HarneryConfig {
796
+ const p = join(root, ".harnery", "config.jsonc");
797
+ const signature = statSignature(p);
798
+ if (signature === null) return {};
799
+ if (projectCache && projectCache.path === p && projectCache.signature === signature) {
800
+ return projectCache.cfg;
801
+ }
802
+ const cfg = parseConfigFile(p);
803
+ projectCache = { path: p, signature, cfg };
804
+ return cfg;
805
+ }
806
+
807
+ function clampInteger(value: unknown, min: number, max: number, fallback: number): number {
808
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
809
+ return Math.min(max, Math.max(min, Math.round(value)));
810
+ }
811
+
812
+ /** Keep each argv that is a non-empty array of non-empty strings; drop the rest. */
813
+ function argvList(value: unknown): string[][] {
814
+ if (!Array.isArray(value)) return [];
815
+ const out: string[][] = [];
816
+ for (const entry of value) {
817
+ if (!Array.isArray(entry) || entry.length === 0) continue;
818
+ if (!entry.every((part) => typeof part === "string" && part.length > 0)) continue;
819
+ out.push([...(entry as string[])]);
820
+ }
821
+ return out;
822
+ }
823
+
824
+ /**
825
+ * Project self-update settings from `.harnery/config.jsonc` (ADR 0229):
826
+ *
827
+ * ```jsonc
828
+ * "autoUpdate": {
829
+ * "enabled": true,
830
+ * "steps": [["bin/pull-all"]],
831
+ * "dailySteps": [["bin/doctor", "--quiet"]],
832
+ * "watch": ["~/.local/share/example/hooks.json"],
833
+ * "checkMinutes": 15,
834
+ * "timeoutMinutes": 20,
835
+ * "cleanup": true
836
+ * }
837
+ * ```
838
+ *
839
+ * Project config only: running commands on activity is a per-project
840
+ * decision, so a user-global value never turns it on. Null when the project
841
+ * has no `autoUpdate` section. Malformed values are dropped one by one;
842
+ * `enabled` is true only when the project sets it to exactly `true`.
843
+ */
844
+ export function readAutoUpdateConfig(coordRoot: string): AutoUpdateConfig | null {
845
+ const raw = readProjectConfigCached(coordRoot).autoUpdate;
846
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
847
+ const record = raw as Record<string, unknown>;
848
+ const watch = Array.isArray(record.watch)
849
+ ? record.watch.filter((p): p is string => typeof p === "string" && p.trim().length > 0)
850
+ : [];
851
+ return {
852
+ enabled: record.enabled === true,
853
+ checkMinutes: clampInteger(record.checkMinutes, 1, 1440, AUTOUPDATE_DEFAULTS.checkMinutes),
854
+ steps: argvList(record.steps),
855
+ dailySteps: argvList(record.dailySteps),
856
+ watch,
857
+ timeoutMinutes: clampInteger(record.timeoutMinutes, 1, 240, AUTOUPDATE_DEFAULTS.timeoutMinutes),
858
+ cleanup: record.cleanup !== false,
859
+ };
860
+ }
861
+
721
862
  /**
722
863
  * Whether this repo pins workflow runs to subscription billing:
723
864
  * `.harnery/config.jsonc` `{ "workflow": { "subscriptionOnly": true } }`.
@@ -568,6 +568,7 @@ function rootOfHarnery(): string {
568
568
 
569
569
  function repositoryBuild(root: string): string {
570
570
  const result = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
571
+ windowsHide: true,
571
572
  encoding: "utf8",
572
573
  timeout: 5000,
573
574
  });
@@ -16,6 +16,7 @@ export function currentHarneryRuntimeBuild(): string {
16
16
  const root = harneryPackageRoot();
17
17
  if (existsSync(resolve(root, ".git"))) {
18
18
  const result = spawnSync("git", ["-C", root, "rev-parse", "HEAD"], {
19
+ windowsHide: true,
19
20
  encoding: "utf8",
20
21
  timeout: 5_000,
21
22
  });
@@ -14,7 +14,7 @@
14
14
  import { existsSync, readFileSync } from "node:fs";
15
15
  import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { codexWindowsBridgeConfig, resolveBinName } from "../../config.ts";
17
+ import { codexWindowsBridgeConfig, portableHookCommands, resolveBinName } from "../../config.ts";
18
18
  import {
19
19
  ADAPTER_SPECS,
20
20
  type AdapterId,
@@ -122,6 +122,22 @@ export function hookCommandWindows(
122
122
  export interface WiringOptions {
123
123
  /** Bridge entry point from `hooks.codexWindowsBridge`, or null when not opted in. */
124
124
  windowsBridgeEntryPoint?: string | null;
125
+ /**
126
+ * `hooks.portableCommands`: write the same `agent-hook` commands from PATH on
127
+ * every OS, plus a native Codex `commandWindows`, so one committed settings
128
+ * file is current everywhere.
129
+ */
130
+ portableCommands?: boolean;
131
+ /** The OS to write for. Defaults to the host; tests inject it. */
132
+ platform?: NodeJS.Platform;
133
+ }
134
+
135
+ /** The wiring options a project's committed config selects. */
136
+ export function projectWiringOptions(projectRoot: string): WiringOptions {
137
+ return {
138
+ windowsBridgeEntryPoint: codexWindowsBridgeConfig(projectRoot).entryPoint,
139
+ portableCommands: portableHookCommands(projectRoot),
140
+ };
125
141
  }
126
142
 
127
143
  /** The `commandWindows` init writes for one event, or undefined for none. */
@@ -132,7 +148,12 @@ export function expectedCommandWindows(
132
148
  adapter: AdapterId,
133
149
  options?: WiringOptions,
134
150
  ): string | undefined {
135
- if (!spec.windowsBridgeHooks || !options?.windowsBridgeEntryPoint) return undefined;
151
+ if (!spec.windowsBridgeHooks) return undefined;
152
+ if (!options?.windowsBridgeEntryPoint) {
153
+ return (options?.platform ?? process.platform) === "win32" || options?.portableCommands
154
+ ? nativeHookCommand(agentHookPath, subcommand, adapter)
155
+ : undefined;
156
+ }
136
157
  return hookCommandWindows(
137
158
  spec,
138
159
  agentHookPath,
@@ -193,7 +214,11 @@ export function hookCommand(
193
214
  agentHookPath: string,
194
215
  subcommand: string,
195
216
  adapter: AdapterId,
217
+ options?: WiringOptions,
196
218
  ): string {
219
+ if ((options?.platform ?? process.platform) === "win32") {
220
+ return nativeHookCommand(agentHookPath, subcommand, adapter);
221
+ }
197
222
  if (agentHookPath === "agent-hook") {
198
223
  return `agent-hook ${subcommand} --adapter ${adapter}`;
199
224
  }
@@ -201,13 +226,29 @@ export function hookCommand(
201
226
  return `bash ${anchor}${agentHookPath} ${subcommand} --adapter ${adapter}`;
202
227
  }
203
228
 
229
+ function nativeHookCommand(agentHookPath: string, subcommand: string, adapter: AdapterId): string {
230
+ if (agentHookPath === "agent-hook") return `agent-hook ${subcommand} --adapter ${adapter}`;
231
+ const runner = join(dirname(agentHookPath), "run.mjs").replaceAll("\\", "/");
232
+ return `node "${runner}" agent-hook ${subcommand} --adapter ${adapter}`;
233
+ }
234
+
204
235
  /**
205
236
  * Pick a committed hook target that survives cloning the consumer elsewhere.
206
237
  * Projects that contain Harnery can use its repo-relative launcher. Standalone
207
238
  * consumers use the package's installed `agent-hook` executable from PATH
208
239
  * instead of recording a relative path back into one developer's package tree.
209
240
  */
210
- export function agentHookPathForProject(projectRoot: string, packageRoot: string): string {
241
+ export function agentHookPathForProject(
242
+ projectRoot: string,
243
+ packageRoot: string,
244
+ options?: WiringOptions,
245
+ ): string {
246
+ // Native npm shims run the linked source checkout without Bash, and do not
247
+ // depend on a hook's working directory after an agent changes directories.
248
+ // A project that commits one settings file for every OS asks for the same.
249
+ if ((options?.platform ?? process.platform) === "win32" || options?.portableCommands) {
250
+ return "agent-hook";
251
+ }
211
252
  const hook = join(packageRoot, "bin", "agent-hook");
212
253
  const candidate = relative(projectRoot, hook);
213
254
  if (candidate === ".." || candidate.startsWith(`..${sep}`) || isAbsolute(candidate)) {
@@ -268,6 +309,7 @@ export function diffWiring(
268
309
  expected.agentHookPath,
269
310
  event.subcommand,
270
311
  expected.adapter,
312
+ expected,
271
313
  );
272
314
  const timeout = hookTimeoutFor(spec, event.subcommand);
273
315
  const commandWindows = expectedCommandWindows(
@@ -386,7 +428,9 @@ function pluginDrift(
386
428
  const packageRoot = harneryPackageRoot();
387
429
  const check = checkOpenCodePlugin(projectRoot, {
388
430
  binName: resolveBinName(projectRoot),
389
- agentHook: packageRoot ? agentHookPathForProject(projectRoot, packageRoot) : "agent-hook",
431
+ agentHook: packageRoot
432
+ ? agentHookPathForProject(projectRoot, packageRoot, projectWiringOptions(projectRoot))
433
+ : "agent-hook",
390
434
  });
391
435
  if (check.status === "fresh") return null;
392
436
  if (check.status === "missing") return null;
@@ -425,14 +469,15 @@ export function loadAdapterWiring(projectRoot: string): AdapterWiringStatus[] {
425
469
  continue;
426
470
  }
427
471
  const packageRoot = harneryPackageRoot();
472
+ const options = projectWiringOptions(projectRoot);
428
473
  const diff = diffWiring(
429
474
  settings,
430
475
  spec,
431
476
  packageRoot
432
477
  ? {
433
- agentHookPath: agentHookPathForProject(projectRoot, packageRoot),
478
+ agentHookPath: agentHookPathForProject(projectRoot, packageRoot, options),
434
479
  adapter: id,
435
- windowsBridgeEntryPoint: codexWindowsBridgeConfig(projectRoot).entryPoint,
480
+ ...options,
436
481
  }
437
482
  : undefined,
438
483
  );
@@ -0,0 +1,72 @@
1
+ import { coordEnv } from "../../lib/env.ts";
2
+ import type { AutoUpdateNotice, TickResult } from "../autoupdate/types.ts";
3
+ import { readAutoUpdateConfig } from "../config.ts";
4
+
5
+ /** Prefix on every self-update line injected into an agent's context. */
6
+ export const AUTOUPDATE_CUE_PREFIX = "Auto-update: ";
7
+
8
+ export interface AutoUpdateCueDeps {
9
+ tick: (coordRoot: string) => TickResult;
10
+ pendingNotices: (coordRoot: string, sessionKey: string) => AutoUpdateNotice[];
11
+ }
12
+
13
+ export interface AutoUpdateCueOptions {
14
+ /**
15
+ * Whether this hook will show context to the agent. When false, the tick
16
+ * still runs but notices stay queued for a session that can read them.
17
+ */
18
+ deliver?: boolean;
19
+ /** Test seam; production loads the engine lazily. */
20
+ deps?: AutoUpdateCueDeps;
21
+ /** Receives tick or delivery failures. The cue itself never throws. */
22
+ onError?: (err: unknown, phase: "autoupdate-tick" | "autoupdate-notices") => void;
23
+ }
24
+
25
+ async function loadDeps(): Promise<AutoUpdateCueDeps> {
26
+ const engine = await import("../autoupdate/index.ts");
27
+ return {
28
+ tick: (root) => engine.autoUpdateTick(root),
29
+ pendingNotices: (root, key) => engine.pendingNotices(root, key),
30
+ };
31
+ }
32
+
33
+ /**
34
+ * Fire the activity-driven self-update check (ADR 0229) and collect the
35
+ * notices this session has not seen yet, one context line each.
36
+ *
37
+ * Projects without an `autoUpdate` section pay one config stat and never load
38
+ * the engine. With the section, the tick's hot path is one stamp stat; any
39
+ * real work runs in a detached child, so the hook never waits on it. Fails
40
+ * open: an error is reported through `onError` and the cue is empty.
41
+ */
42
+ export async function autoUpdateHookContext(
43
+ coordRoot: string,
44
+ sessionKey: string,
45
+ options: AutoUpdateCueOptions = {},
46
+ ): Promise<string> {
47
+ if (coordEnv("AUTOUPDATE_CHILD") === "1") return "";
48
+ let deps: AutoUpdateCueDeps;
49
+ try {
50
+ if (!readAutoUpdateConfig(coordRoot)) return "";
51
+ deps = options.deps ?? (await loadDeps());
52
+ } catch (err) {
53
+ options.onError?.(err, "autoupdate-tick");
54
+ return "";
55
+ }
56
+ try {
57
+ deps.tick(coordRoot);
58
+ } catch (err) {
59
+ options.onError?.(err, "autoupdate-tick");
60
+ }
61
+ if (options.deliver === false || !sessionKey) return "";
62
+ try {
63
+ return deps
64
+ .pendingNotices(coordRoot, sessionKey)
65
+ .map((notice) => `${AUTOUPDATE_CUE_PREFIX}${notice.text.trim()}`)
66
+ .filter((line) => line.length > AUTOUPDATE_CUE_PREFIX.length)
67
+ .join("\n");
68
+ } catch (err) {
69
+ options.onError?.(err, "autoupdate-notices");
70
+ return "";
71
+ }
72
+ }
@@ -108,6 +108,7 @@ import {
108
108
  parsePayload,
109
109
  } from "./adapter/parse.ts";
110
110
  import { hookTimeoutFor } from "./adapter/wiring.ts";
111
+ import { autoUpdateHookContext } from "./autoupdate-cue.ts";
111
112
  import { scheduleBackupSnapshot } from "./backup-schedule.ts";
112
113
  import {
113
114
  captureImages,
@@ -1316,6 +1317,13 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
1316
1317
  } catch (err) {
1317
1318
  logError(coordRoot, err, { phase: "backup-schedule" });
1318
1319
  }
1320
+ // Project self-update (ADR 0229) fires on activity. The tick costs one stat
1321
+ // when fresh and otherwise launches a detached child; queued notices for
1322
+ // this session ride the same context as the backup cue.
1323
+ const autoUpdateCue = await autoUpdateHookContext(coordRoot, owner.instance_id, {
1324
+ deliver: coordEnv("WORKFLOW_CHILD") !== "1",
1325
+ onError: (err, phase) => logError(coordRoot, err, { phase }),
1326
+ });
1319
1327
  // Storage maintenance can walk large stores. It runs in a detached worker so
1320
1328
  // session start emits its context without waiting for it; an adapter that
1321
1329
  // stops a slow hook would otherwise drop that context entirely.
@@ -1344,7 +1352,7 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
1344
1352
  data,
1345
1353
  adapter,
1346
1354
  recovery?.briefing ?? "",
1347
- backupCue,
1355
+ [backupCue, autoUpdateCue].filter(Boolean).join("\n\n"),
1348
1356
  );
1349
1357
  if (injected && recovery) {
1350
1358
  completeRecoveryInjection(coordRoot, owner.instance_id, sessionId, recovery);
@@ -1473,6 +1481,11 @@ async function handleTurnStarted(run: HookRun): Promise<void> {
1473
1481
  logError(coordRoot, err, { phase: "prompt-context-user-prompt" });
1474
1482
  }
1475
1483
  }
1484
+ // Project self-update (ADR 0229): see handleSessionStarted.
1485
+ const autoUpdateCue = await autoUpdateHookContext(coordRoot, owner.instance_id, {
1486
+ deliver: coordEnv("WORKFLOW_CHILD") !== "1",
1487
+ onError: (err, phase) => logError(coordRoot, err, { phase }),
1488
+ });
1476
1489
  try {
1477
1490
  const injected = await emitUserPromptSubmitSystemMessage(
1478
1491
  coordRoot,
@@ -1482,6 +1495,7 @@ async function handleTurnStarted(run: HookRun): Promise<void> {
1482
1495
  payload?.cwd,
1483
1496
  recovery?.briefing ?? "",
1484
1497
  hostPromptContext,
1498
+ autoUpdateCue,
1485
1499
  );
1486
1500
  if (injected && recovery) {
1487
1501
  completeRecoveryInjection(coordRoot, owner.instance_id, sessionId, recovery);
@@ -1542,6 +1556,14 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1542
1556
  });
1543
1557
  }
1544
1558
  if (!verdict.allow) {
1559
+ // Re-arm the prompt-time ritual reminder; it is otherwise shown only on a
1560
+ // session's first prompt so the per-prompt context stays small.
1561
+ try {
1562
+ const { markTurnRitualBounced } = await import("../agents/render/prompt-context.ts");
1563
+ markTurnRitualBounced(coordRoot, owner.instance_id);
1564
+ } catch (err) {
1565
+ logError(coordRoot, err, { phase: "stop-ritual-rearm" });
1566
+ }
1545
1567
  // Backstop for sessions whose evidence can never land (e.g. a headless
1546
1568
  // child whose status command resolves to a different owner): after
1547
1569
  // `cap` consecutive blocked stops in one cycle, allow the stop instead
@@ -2435,6 +2457,7 @@ async function emitUserPromptSubmitSystemMessage(
2435
2457
  workspaceCwd?: string,
2436
2458
  recoveryBriefing = "",
2437
2459
  hostPromptContext = "",
2460
+ autoUpdateCue = "",
2438
2461
  ): Promise<boolean> {
2439
2462
  // Workflow children get no prompt-time coordination context, matching
2440
2463
  // SessionStart. The turn ritual is not enforced for them, the naming nudge
@@ -2463,6 +2486,9 @@ async function emitUserPromptSubmitSystemMessage(
2463
2486
  if (hostPromptContext) {
2464
2487
  additionalContext = [additionalContext, hostPromptContext].filter(Boolean).join("\n\n");
2465
2488
  }
2489
+ if (autoUpdateCue) {
2490
+ additionalContext = [additionalContext, autoUpdateCue].filter(Boolean).join("\n\n");
2491
+ }
2466
2492
  if (!additionalContext) return false;
2467
2493
 
2468
2494
  const { emitContext } = await import("./adapter/output.ts");
@@ -17,7 +17,7 @@ import { existsSync, readdirSync, rmSync } from "node:fs";
17
17
  import os from "node:os";
18
18
  import { join } from "node:path";
19
19
  import { applyDetection } from "../../../lib/presence.ts";
20
- import { coordBinPath } from "../../agents/coord-bin.ts";
20
+ import { coordBinPath, spawnCoordBinSync } from "../../agents/coord-bin.ts";
21
21
 
22
22
  export { captureImages, imageJanitor, recordImageArtifactsV3 } from "./image-capture.ts";
23
23
 
@@ -70,7 +70,7 @@ export function journalJanitor(repoRoot: string): void {
70
70
  try {
71
71
  const bin = harnBin(repoRoot);
72
72
  if (!bin) return;
73
- spawnSync("bash", [bin, "journal", "janitor", "--quiet"], {
73
+ spawnCoordBinSync(bin, ["journal", "janitor", "--quiet"], {
74
74
  env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
75
75
  timeout: 5000,
76
76
  stdio: "ignore",
@@ -89,7 +89,7 @@ export function journalRecoveryCue(repoRoot: string): string {
89
89
  try {
90
90
  const bin = harnBin(repoRoot);
91
91
  if (!bin) return "";
92
- const r = spawnSync("bash", [bin, "journal", "recovery-cue"], {
92
+ const r = spawnCoordBinSync(bin, ["journal", "recovery-cue"], {
93
93
  env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
94
94
  timeout: 5000,
95
95
  encoding: "utf8",
@@ -105,7 +105,7 @@ export function journalArchive(repoRoot: string, owner: string): void {
105
105
  try {
106
106
  const bin = harnBin(repoRoot);
107
107
  if (!bin || !owner) return;
108
- spawnSync("bash", [bin, "journal", "archive", "--owner", owner], {
108
+ spawnCoordBinSync(bin, ["journal", "archive", "--owner", owner], {
109
109
  env: { ...process.env, HARNERY_OUTPUT_SESSION_TEE: "0" },
110
110
  timeout: 5000,
111
111
  stdio: "ignore",
@@ -91,6 +91,7 @@ function gitRevParse(start: string, flag: string): string | null {
91
91
  // A stale adapter project directory may no longer exist. Treat spawn
92
92
  // failure as no path evidence instead of turning discovery into an error.
93
93
  const result = spawnSync("git", ["rev-parse", flag], {
94
+ windowsHide: true,
94
95
  cwd: start,
95
96
  encoding: "utf8",
96
97
  });
@@ -17,6 +17,7 @@ import {
17
17
  } from "node:fs";
18
18
  import { hostname } from "node:os";
19
19
  import { dirname, join, resolve } from "node:path";
20
+ import { fileURLToPath } from "node:url";
20
21
  import { coordEnv } from "../../lib/env.ts";
21
22
  import {
22
23
  type ReadLedgerV3SinceResult,
@@ -150,7 +151,9 @@ export async function spawnSemanticService(
150
151
  const sharedLogs = coordEnv("SHARED_LOGS") !== "0";
151
152
  const logFd = sharedLogs ? undefined : openSync(paths.log, "a", stateFileMode());
152
153
  if (logFd !== undefined) chmodSync(paths.log, stateFileMode());
153
- const harnBin = new URL("../../../bin/harn", import.meta.url).pathname;
154
+ const harnBin = fileURLToPath(
155
+ new URL(import.meta.url.endsWith(".ts") ? "../../cli.ts" : "../../cli.js", import.meta.url),
156
+ );
154
157
  if (!existsSync(harnBin)) {
155
158
  if (logFd !== undefined) closeSync(logFd);
156
159
  throw new Error(`cannot find harn executable at ${harnBin}`);
@@ -160,7 +163,8 @@ export async function spawnSemanticService(
160
163
  args.push("--calls-per-hour", String(options.callsPerHour));
161
164
  }
162
165
  let spawnError: Error | undefined;
163
- const child = spawn(harnBin, args, {
166
+ const child = spawn(process.execPath, [harnBin, ...args], {
167
+ windowsHide: true,
164
168
  cwd: coordRoot,
165
169
  detached: true,
166
170
  stdio: logFd === undefined ? ["ignore", "ignore", "ignore"] : ["ignore", logFd, logFd],
@@ -54,6 +54,12 @@ export function stateFileMode(): number {
54
54
  * True when a state file or directory is readable or writable by someone it should not be: anyone
55
55
  * but the owner in private mode, anyone outside the owner and group in group mode.
56
56
  */
57
- export function stateModeTooOpen(mode: number): boolean {
57
+ export function stateModeTooOpen(
58
+ mode: number,
59
+ platform: NodeJS.Platform = process.platform,
60
+ ): boolean {
61
+ // Windows synthesizes POSIX mode bits; access is controlled by inherited
62
+ // NTFS ACLs, which chmod and stat.mode cannot inspect or tighten.
63
+ if (platform === "win32") return false;
58
64
  return (mode & (stateSharing() === "group" ? 0o007 : 0o077)) !== 0;
59
65
  }
@@ -61,10 +61,24 @@ export function writeImmutableJson(path: string, value: unknown): boolean {
61
61
  }
62
62
 
63
63
  export function fsyncParentDirectory(path: string): void {
64
- const directory = dirname(path);
64
+ fsyncDirectory(dirname(path));
65
+ }
66
+
67
+ /** Flush directory entries where the runtime supports it; file fsync remains mandatory. */
68
+ export function fsyncDirectory(directory: string): void {
65
69
  const fd = openSync(directory, "r");
66
70
  try {
67
71
  fsyncSync(fd);
72
+ } catch (error) {
73
+ // Windows does not expose directory FlushFileBuffers through node:fs.
74
+ // Keep atomic links/renames and file flushes, but do not claim POSIX
75
+ // directory-entry durability on that platform. Other errors still fail.
76
+ if (
77
+ process.platform !== "win32" ||
78
+ !["EPERM", "EINVAL", "ENOTSUP"].includes((error as NodeJS.ErrnoException).code ?? "")
79
+ ) {
80
+ throw error;
81
+ }
68
82
  } finally {
69
83
  closeSync(fd);
70
84
  }
@@ -1101,6 +1101,7 @@ async function executeWorkflow(
1101
1101
  runId,
1102
1102
  agentId: id,
1103
1103
  subscriptionOnly,
1104
+ ...(agentOpts.mcp ? { mcp: agentOpts.mcp } : {}),
1104
1105
  // A full-access launch has no sandbox to project a policy into.
1105
1106
  ...(access.mode === "full-access"
1106
1107
  ? { access: "full-access" as const }
@@ -1505,6 +1506,8 @@ function agentCallKey(
1505
1506
  agentOpts.schema ?? null,
1506
1507
  ];
1507
1508
  if (agentOpts.specialist) parts.push(agentOpts.specialist);
1509
+ // Appended only when set so existing resume keys stay stable.
1510
+ if (agentOpts.mcp) parts.push({ mcp: agentOpts.mcp });
1508
1511
  parts.push(prompt);
1509
1512
  const basis = JSON.stringify(parts);
1510
1513
  return createHash("sha256").update(basis).digest("hex").slice(0, 16);
@@ -16,9 +16,17 @@
16
16
  * visible to peers — the point of running them under harnery. So the child
17
17
  * gets HARNERY_WORKFLOW_CHILD=1 and the stop-hook rule exempts it
18
18
  * (stop-hook.ts), keeping heartbeats + events on.
19
+ * 3. **No MCP servers or claude.ai connectors unless the spawn asks.** A
20
+ * headless child otherwise loads every connector on the account (about 130
21
+ * tool schemas) into every turn. `req.mcp` defaults to `"none"` (strict
22
+ * empty MCP config plus ENABLE_CLAUDEAI_MCP_SERVERS=false); `"inherit"` or
23
+ * a server map is the per-spawn opt-in. Project settings, hooks, and
24
+ * CLAUDE.md still load: workflow children need them, so this adapter never
25
+ * passes `--setting-sources ""`.
19
26
  */
20
27
 
21
28
  import { exec } from "../../lib/exec.ts";
29
+ import { claudeMcpLaunch } from "../../lib/headless/index.ts";
22
30
  import { builtinAdapterProfile, validateAdapterEffort } from "../adapters/profiles.ts";
23
31
  import type { AdapterInvocation, AdapterRawResult } from "../adapters/types.ts";
24
32
  import { notFoundError } from "./adapters.ts";
@@ -62,6 +70,7 @@ export function buildClaudeInvocation(req: SpawnRequest): AdapterInvocation {
62
70
  if (fullAccess) argv.push(...fullAccess);
63
71
  if (req.model) argv.push("--model", req.model);
64
72
  if (req.effort) argv.push("--effort", req.effort);
73
+ argv.push(...claudeMcpLaunch(req.mcp).args);
65
74
  return { argv };
66
75
  }
67
76
 
@@ -152,10 +161,13 @@ export const claudeCodeSpawner: Spawner = async (req: SpawnRequest): Promise<Spa
152
161
  }
153
162
  const r = await exec(invocation.argv, {
154
163
  cwd: req.cwd,
155
- env: buildChildEnv(req.runId, {
156
- subscriptionOnly: req.subscriptionOnly,
157
- agentId: req.agentId,
158
- }),
164
+ env: {
165
+ ...buildChildEnv(req.runId, {
166
+ subscriptionOnly: req.subscriptionOnly,
167
+ agentId: req.agentId,
168
+ }),
169
+ ...claudeMcpLaunch(req.mcp).env,
170
+ },
159
171
  timeout: req.timeoutMs,
160
172
  });
161
173
  return normalizeClaudeResult({ ...r, durationMs: Date.now() - t0 });
@@ -72,6 +72,7 @@ export function resolveSpecialistAssignment(
72
72
  maxTurns: opts.maxTurns ?? profile.maxTurns,
73
73
  label: opts.label,
74
74
  schema: opts.schema,
75
+ ...(opts.mcp ? { mcp: opts.mcp } : {}),
75
76
  },
76
77
  };
77
78
  }