harnery 0.46.0 → 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 (187) 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/rules/claim-conflict.d.ts.map +1 -1
  40. package/dist/core/agents/rules/claim-conflict.js +36 -16
  41. package/dist/core/agents/rules/stop-hook.d.ts +6 -0
  42. package/dist/core/agents/rules/stop-hook.d.ts.map +1 -1
  43. package/dist/core/agents/rules/stop-hook.js +20 -1
  44. package/dist/core/agents/state/proc-start.d.ts +3 -1
  45. package/dist/core/agents/state/proc-start.d.ts.map +1 -1
  46. package/dist/core/agents/state/proc-start.js +26 -4
  47. package/dist/core/autoupdate/engine.d.ts +97 -0
  48. package/dist/core/autoupdate/engine.d.ts.map +1 -0
  49. package/dist/core/autoupdate/engine.js +548 -0
  50. package/dist/core/autoupdate/fingerprint.d.ts +16 -0
  51. package/dist/core/autoupdate/fingerprint.d.ts.map +1 -0
  52. package/dist/core/autoupdate/fingerprint.js +73 -0
  53. package/dist/core/autoupdate/git.d.ts +30 -0
  54. package/dist/core/autoupdate/git.d.ts.map +1 -0
  55. package/dist/core/autoupdate/git.js +82 -0
  56. package/dist/core/autoupdate/index.d.ts +6 -0
  57. package/dist/core/autoupdate/index.d.ts.map +1 -0
  58. package/dist/core/autoupdate/index.js +4 -0
  59. package/dist/core/autoupdate/state.d.ts +59 -0
  60. package/dist/core/autoupdate/state.d.ts.map +1 -0
  61. package/dist/core/autoupdate/state.js +214 -0
  62. package/dist/core/autoupdate/types.d.ts +113 -0
  63. package/dist/core/autoupdate/types.d.ts.map +1 -0
  64. package/dist/core/autoupdate/types.js +35 -0
  65. package/dist/core/config.d.ts +53 -1
  66. package/dist/core/config.d.ts.map +1 -1
  67. package/dist/core/config.js +114 -1
  68. package/dist/core/events/v3/bootstrap.js +1 -0
  69. package/dist/core/events/v3/runtime-build.d.ts.map +1 -1
  70. package/dist/core/events/v3/runtime-build.js +1 -0
  71. package/dist/core/hooks/adapter/wiring.d.ts +12 -2
  72. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  73. package/dist/core/hooks/adapter/wiring.js +38 -8
  74. package/dist/core/hooks/autoupdate-cue.d.ts +29 -0
  75. package/dist/core/hooks/autoupdate-cue.d.ts.map +1 -0
  76. package/dist/core/hooks/autoupdate-cue.js +53 -0
  77. package/dist/core/hooks/cli.js +52 -4
  78. package/dist/core/hooks/effects/index.js +4 -4
  79. package/dist/core/hooks/resolve/coord-root.js +1 -0
  80. package/dist/core/hooks/resolve/transcript.d.ts +12 -0
  81. package/dist/core/hooks/resolve/transcript.d.ts.map +1 -1
  82. package/dist/core/hooks/resolve/transcript.js +54 -0
  83. package/dist/core/semantic/contract.d.ts +3 -3
  84. package/dist/core/semantic/service.d.ts.map +1 -1
  85. package/dist/core/semantic/service.js +4 -2
  86. package/dist/core/storage/modes.d.ts +1 -1
  87. package/dist/core/storage/modes.d.ts.map +1 -1
  88. package/dist/core/storage/modes.js +5 -1
  89. package/dist/core/workflow/durable-record.d.ts +2 -0
  90. package/dist/core/workflow/durable-record.d.ts.map +1 -1
  91. package/dist/core/workflow/durable-record.js +13 -1
  92. package/dist/core/workflow/engine.js +4 -0
  93. package/dist/core/workflow/spawn-claude.d.ts +7 -0
  94. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  95. package/dist/core/workflow/spawn-claude.js +16 -4
  96. package/dist/core/workflow/specialists.d.ts.map +1 -1
  97. package/dist/core/workflow/specialists.js +1 -0
  98. package/dist/core/workflow/types.d.ts +10 -0
  99. package/dist/core/workflow/types.d.ts.map +1 -1
  100. package/dist/core/workflow/workspaces/leases.d.ts.map +1 -1
  101. package/dist/core/workflow/workspaces/leases.js +1 -9
  102. package/dist/lib/computer/cua.d.ts +50 -0
  103. package/dist/lib/computer/cua.d.ts.map +1 -0
  104. package/dist/lib/computer/cua.js +474 -0
  105. package/dist/lib/computer/host.d.ts +19 -0
  106. package/dist/lib/computer/host.d.ts.map +1 -0
  107. package/dist/lib/computer/host.js +72 -0
  108. package/dist/lib/computer/lease.d.ts +13 -0
  109. package/dist/lib/computer/lease.d.ts.map +1 -0
  110. package/dist/lib/computer/lease.js +269 -0
  111. package/dist/lib/computer/types.d.ts +163 -0
  112. package/dist/lib/computer/types.d.ts.map +1 -0
  113. package/dist/lib/computer/types.js +10 -0
  114. package/dist/lib/docs-metadata-v2.d.ts.map +1 -1
  115. package/dist/lib/docs-metadata-v2.js +6 -5
  116. package/dist/lib/exec.d.ts.map +1 -1
  117. package/dist/lib/exec.js +1 -0
  118. package/dist/lib/guarded-remove.d.ts.map +1 -1
  119. package/dist/lib/guarded-remove.js +5 -4
  120. package/dist/lib/headless/index.d.ts +33 -0
  121. package/dist/lib/headless/index.d.ts.map +1 -1
  122. package/dist/lib/headless/index.js +38 -2
  123. package/dist/lib/http/client.d.ts +2 -9
  124. package/dist/lib/http/client.d.ts.map +1 -1
  125. package/dist/lib/http/client.js +30 -9
  126. package/dist/lib/instructions/apply.d.ts +9 -0
  127. package/dist/lib/instructions/apply.d.ts.map +1 -1
  128. package/dist/lib/instructions/apply.js +92 -14
  129. package/dist/lib/instructions/git-hooks.d.ts.map +1 -1
  130. package/dist/lib/instructions/git-hooks.js +8 -4
  131. package/dist/lib/instructions/templates.d.ts +11 -0
  132. package/dist/lib/instructions/templates.d.ts.map +1 -1
  133. package/dist/lib/instructions/templates.js +81 -1
  134. package/package.json +5 -5
  135. package/schemas/config.schema.json +89 -1
  136. package/src/commander.ts +98 -8
  137. package/src/commands/agents.ts +100 -32
  138. package/src/commands/autoupdate.ts +250 -0
  139. package/src/commands/computer.ts +682 -0
  140. package/src/commands/docs.ts +3 -1
  141. package/src/commands/fetch.ts +11 -1
  142. package/src/commands/grep.ts +274 -59
  143. package/src/commands/init.ts +17 -7
  144. package/src/commands/rm.ts +2 -2
  145. package/src/commands/web.ts +2 -0
  146. package/src/core/agents/canonical-emit.ts +2 -3
  147. package/src/core/agents/coord-bin.ts +30 -0
  148. package/src/core/agents/coord-client.ts +6 -2
  149. package/src/core/agents/render/prompt-context.ts +152 -53
  150. package/src/core/agents/rules/claim-conflict.ts +50 -18
  151. package/src/core/agents/rules/stop-hook.ts +28 -1
  152. package/src/core/agents/state/proc-start.ts +29 -5
  153. package/src/core/autoupdate/engine.ts +691 -0
  154. package/src/core/autoupdate/fingerprint.ts +89 -0
  155. package/src/core/autoupdate/git.ts +120 -0
  156. package/src/core/autoupdate/index.ts +29 -0
  157. package/src/core/autoupdate/state.ts +269 -0
  158. package/src/core/autoupdate/types.ts +135 -0
  159. package/src/core/config.ts +142 -1
  160. package/src/core/events/v3/bootstrap.ts +1 -0
  161. package/src/core/events/v3/runtime-build.ts +1 -0
  162. package/src/core/hooks/adapter/wiring.ts +51 -6
  163. package/src/core/hooks/autoupdate-cue.ts +72 -0
  164. package/src/core/hooks/cli.ts +54 -1
  165. package/src/core/hooks/effects/index.ts +4 -4
  166. package/src/core/hooks/resolve/coord-root.ts +1 -0
  167. package/src/core/hooks/resolve/transcript.ts +61 -0
  168. package/src/core/semantic/service.ts +6 -2
  169. package/src/core/storage/modes.ts +7 -1
  170. package/src/core/workflow/durable-record.ts +15 -1
  171. package/src/core/workflow/engine.ts +3 -0
  172. package/src/core/workflow/spawn-claude.ts +16 -4
  173. package/src/core/workflow/specialists.ts +1 -0
  174. package/src/core/workflow/types.ts +10 -0
  175. package/src/core/workflow/workspaces/leases.ts +1 -9
  176. package/src/lib/computer/cua.ts +572 -0
  177. package/src/lib/computer/host.ts +87 -0
  178. package/src/lib/computer/lease.ts +299 -0
  179. package/src/lib/computer/types.ts +177 -0
  180. package/src/lib/docs-metadata-v2.ts +24 -5
  181. package/src/lib/exec.ts +1 -0
  182. package/src/lib/guarded-remove.ts +5 -3
  183. package/src/lib/headless/index.ts +66 -2
  184. package/src/lib/http/client.ts +33 -19
  185. package/src/lib/instructions/apply.ts +100 -12
  186. package/src/lib/instructions/git-hooks.ts +8 -4
  187. 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,
@@ -139,6 +140,7 @@ import type { SessionNameDisplayInspection } from "./resolve/transcript.ts";
139
140
  import {
140
141
  inspectSessionNameDisplayImmediately,
141
142
  scanAssistantStatusBoxPresent,
143
+ scanPendingBackgroundHandoffs,
142
144
  scanStatusBoxPresent,
143
145
  scanTranscriptRuntime,
144
146
  } from "./resolve/transcript.ts";
@@ -1315,6 +1317,13 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
1315
1317
  } catch (err) {
1316
1318
  logError(coordRoot, err, { phase: "backup-schedule" });
1317
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
+ });
1318
1327
  // Storage maintenance can walk large stores. It runs in a detached worker so
1319
1328
  // session start emits its context without waiting for it; an adapter that
1320
1329
  // stops a slow hook would otherwise drop that context entirely.
@@ -1343,7 +1352,7 @@ async function handleSessionStarted(run: HookRun): Promise<void> {
1343
1352
  data,
1344
1353
  adapter,
1345
1354
  recovery?.briefing ?? "",
1346
- backupCue,
1355
+ [backupCue, autoUpdateCue].filter(Boolean).join("\n\n"),
1347
1356
  );
1348
1357
  if (injected && recovery) {
1349
1358
  completeRecoveryInjection(coordRoot, owner.instance_id, sessionId, recovery);
@@ -1472,6 +1481,11 @@ async function handleTurnStarted(run: HookRun): Promise<void> {
1472
1481
  logError(coordRoot, err, { phase: "prompt-context-user-prompt" });
1473
1482
  }
1474
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
+ });
1475
1489
  try {
1476
1490
  const injected = await emitUserPromptSubmitSystemMessage(
1477
1491
  coordRoot,
@@ -1481,6 +1495,7 @@ async function handleTurnStarted(run: HookRun): Promise<void> {
1481
1495
  payload?.cwd,
1482
1496
  recovery?.briefing ?? "",
1483
1497
  hostPromptContext,
1498
+ autoUpdateCue,
1484
1499
  );
1485
1500
  if (injected && recovery) {
1486
1501
  completeRecoveryInjection(coordRoot, owner.instance_id, sessionId, recovery);
@@ -1506,6 +1521,18 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1506
1521
  logError(coordRoot, err, { phase: "stop-presence" });
1507
1522
  }
1508
1523
 
1524
+ // Claude Code may move a running foreground command (often the agent's own
1525
+ // commit and push) into the background to deliver a user message. Until it
1526
+ // reports back, finalization cannot pass, so the verdict defers the status
1527
+ // demands instead of bouncing the agent in a loop.
1528
+ const pendingBackgroundHandoffs =
1529
+ adapter === "claude-code"
1530
+ ? scanPendingBackgroundHandoffs(
1531
+ payload?.transcript_path ??
1532
+ behavior.discoverTranscript?.(payload?.session_id ?? sessionId),
1533
+ )
1534
+ : [];
1535
+
1509
1536
  // Stop verdict (V3 ritual + task/status gate). Direct in-process call: the
1510
1537
  // rule lives in Harnery. agent-hook already emitted this turn.completed
1511
1538
  // with privacy-safe ritual observations, so the evidence is in the stream.
@@ -1519,8 +1546,24 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1519
1546
  session_name_observation_unavailable: typeof data.session_name_unavailable_reason === "string",
1520
1547
  bypass: coordEnv("AGENT_COORD_BYPASS_STOP") === "1",
1521
1548
  workflow_child: coordEnv("WORKFLOW_CHILD") === "1",
1549
+ pending_background_handoff: pendingBackgroundHandoffs.length > 0,
1522
1550
  });
1551
+ if (verdict.rule === "stop-hook.background_handoff_deferred") {
1552
+ appendDebug(coordRoot, {
1553
+ ...debugBase,
1554
+ effect: "stop-deferred-background-handoff",
1555
+ background_task_ids: pendingBackgroundHandoffs,
1556
+ });
1557
+ }
1523
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
+ }
1524
1567
  // Backstop for sessions whose evidence can never land (e.g. a headless
1525
1568
  // child whose status command resolves to a different owner): after
1526
1569
  // `cap` consecutive blocked stops in one cycle, allow the stop instead
@@ -1553,6 +1596,12 @@ async function handleStop(run: HookRun): Promise<number | undefined> {
1553
1596
  // until the adapter has committed turn.completed above. Reconcile only
1554
1597
  // when such a request exists; any later real work cancels it in the
1555
1598
  // finalizer instead of being terminated underneath the agent.
1599
+ // A deferred stop proves nothing about finalization; an explicit end must
1600
+ // wait for the turn that reports the background task's result.
1601
+ if (verdict.rule === "stop-hook.background_handoff_deferred") {
1602
+ if (behavior.runtimeContextRetry) scheduleRuntimeContextRetry(coordRoot, adapter, sessionId);
1603
+ return undefined;
1604
+ }
1556
1605
  try {
1557
1606
  const { hasPendingExplicitSessionEndV3, reconcileSessionFinalizationV3 } = await import(
1558
1607
  "../agents/session-finalizer-v3.ts"
@@ -2408,6 +2457,7 @@ async function emitUserPromptSubmitSystemMessage(
2408
2457
  workspaceCwd?: string,
2409
2458
  recoveryBriefing = "",
2410
2459
  hostPromptContext = "",
2460
+ autoUpdateCue = "",
2411
2461
  ): Promise<boolean> {
2412
2462
  // Workflow children get no prompt-time coordination context, matching
2413
2463
  // SessionStart. The turn ritual is not enforced for them, the naming nudge
@@ -2436,6 +2486,9 @@ async function emitUserPromptSubmitSystemMessage(
2436
2486
  if (hostPromptContext) {
2437
2487
  additionalContext = [additionalContext, hostPromptContext].filter(Boolean).join("\n\n");
2438
2488
  }
2489
+ if (autoUpdateCue) {
2490
+ additionalContext = [additionalContext, autoUpdateCue].filter(Boolean).join("\n\n");
2491
+ }
2439
2492
  if (!additionalContext) return false;
2440
2493
 
2441
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
  });
@@ -94,6 +94,67 @@ export function scanAssistantStatusBoxPresent(
94
94
  );
95
95
  }
96
96
 
97
+ /** A handoff older than this no longer defers Stop, so a lost notification cannot disable the ritual. */
98
+ export const BACKGROUND_HANDOFF_MAX_AGE_MS = 30 * 60 * 1000;
99
+
100
+ /**
101
+ * Claude Code interleaves request-capture rows of several hundred KB each, so
102
+ * the 256KB status-box window can lose a handoff written a minute earlier.
103
+ */
104
+ const BACKGROUND_HANDOFF_TAIL_BYTES = 8 * 1024 * 1024;
105
+
106
+ /**
107
+ * Claude Code background tasks it moved off the foreground on its own (to
108
+ * deliver a mid-command user message) that have not yet posted a
109
+ * `<task-notification>`. Only the structured `toolUseResult` fields written by
110
+ * Claude Code open a handoff; the agent cannot forge them through tool output.
111
+ * Any later notification text naming the task closes it, so a spoofed
112
+ * notification can only make Stop stricter. A command the agent chose to
113
+ * background (`run_in_background`) or a Monitor opens nothing.
114
+ */
115
+ export function scanPendingBackgroundHandoffs(
116
+ transcriptPath: string | undefined,
117
+ nowMs: number = Date.now(),
118
+ ): string[] {
119
+ const readablePath = resolveTranscriptPath(transcriptPath);
120
+ if (!readablePath) return [];
121
+ let size: number;
122
+ try {
123
+ size = statSync(readablePath).size;
124
+ } catch {
125
+ return [];
126
+ }
127
+ const text = readRange(readablePath, Math.max(0, size - BACKGROUND_HANDOFF_TAIL_BYTES), size);
128
+ if (!text) return [];
129
+ const opened = new Map<string, number>();
130
+ for (const line of text.split("\n")) {
131
+ // Request-capture rows echo the same text; the row-type check below keeps
132
+ // only Claude Code's own top-level tool-result rows.
133
+ if (line.includes("backgroundedToDeliverMessage")) {
134
+ const row = parseTranscriptRow(line);
135
+ const result = row?.toolUseResult as
136
+ | { backgroundTaskId?: unknown; backgroundedToDeliverMessage?: unknown }
137
+ | undefined;
138
+ if (
139
+ row?.type === "user" &&
140
+ result?.backgroundedToDeliverMessage === true &&
141
+ typeof result.backgroundTaskId === "string" &&
142
+ result.backgroundTaskId
143
+ ) {
144
+ opened.set(result.backgroundTaskId, Date.parse(String(row.timestamp)));
145
+ }
146
+ }
147
+ if (line.includes("task-notification")) {
148
+ for (const match of line.matchAll(/<task-id>([^<]+)<\/task-id>/g)) {
149
+ opened.delete(match[1]!);
150
+ }
151
+ }
152
+ }
153
+ return [...opened]
154
+ .filter(([, at]) => Number.isFinite(at) && nowMs - at <= BACKGROUND_HANDOFF_MAX_AGE_MS)
155
+ .map(([id]) => id);
156
+ }
157
+
97
158
  /** Most recent user-visible assistant text across Claude Code and Codex JSONL. */
98
159
  export function scanLatestAssistantText(transcriptPath: string | undefined): string | undefined {
99
160
  const readablePath = resolveTranscriptPath(transcriptPath);
@@ -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],