harnery 0.41.0 → 0.42.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 (212) hide show
  1. package/dist/commander.js +3 -0
  2. package/dist/commands/agents.d.ts.map +1 -1
  3. package/dist/commands/agents.js +9 -1
  4. package/dist/commands/artifacts.d.ts.map +1 -1
  5. package/dist/commands/artifacts.js +113 -12
  6. package/dist/commands/browse.d.ts +164 -0
  7. package/dist/commands/browse.d.ts.map +1 -1
  8. package/dist/commands/browse.js +263 -51
  9. package/dist/commands/claude-desktop.d.ts.map +1 -1
  10. package/dist/commands/claude-desktop.js +52 -0
  11. package/dist/commands/disk.d.ts +6 -0
  12. package/dist/commands/disk.d.ts.map +1 -0
  13. package/dist/commands/disk.js +80 -0
  14. package/dist/commands/doctor.d.ts +8 -0
  15. package/dist/commands/doctor.d.ts.map +1 -1
  16. package/dist/commands/doctor.js +48 -1
  17. package/dist/commands/files.d.ts +16 -0
  18. package/dist/commands/files.d.ts.map +1 -1
  19. package/dist/commands/files.js +54 -2
  20. package/dist/commands/init.d.ts +2 -2
  21. package/dist/commands/init.d.ts.map +1 -1
  22. package/dist/commands/init.js +43 -9
  23. package/dist/commands/rm.d.ts +4 -0
  24. package/dist/commands/rm.d.ts.map +1 -0
  25. package/dist/commands/rm.js +87 -0
  26. package/dist/commands/servers.d.ts +7 -0
  27. package/dist/commands/servers.d.ts.map +1 -0
  28. package/dist/commands/servers.js +394 -0
  29. package/dist/commands/tunnel.d.ts.map +1 -1
  30. package/dist/commands/tunnel.js +82 -2
  31. package/dist/core/adapters/profiles.d.ts +9 -0
  32. package/dist/core/adapters/profiles.d.ts.map +1 -1
  33. package/dist/core/adapters/profiles.js +22 -1
  34. package/dist/core/adapters/types.d.ts +9 -0
  35. package/dist/core/adapters/types.d.ts.map +1 -1
  36. package/dist/core/agents/cli.js +13 -6
  37. package/dist/core/agents/render/session-context.d.ts.map +1 -1
  38. package/dist/core/agents/render/session-context.js +2 -0
  39. package/dist/core/agents/state/live-coordination-view.d.ts +20 -0
  40. package/dist/core/agents/state/live-coordination-view.d.ts.map +1 -1
  41. package/dist/core/agents/state/live-coordination-view.js +60 -0
  42. package/dist/core/artifacts/delivery-card.js +9 -2
  43. package/dist/core/artifacts/index.d.ts +140 -4
  44. package/dist/core/artifacts/index.d.ts.map +1 -1
  45. package/dist/core/artifacts/index.js +522 -49
  46. package/dist/core/config.d.ts +68 -11
  47. package/dist/core/config.d.ts.map +1 -1
  48. package/dist/core/config.js +128 -3
  49. package/dist/core/diagnostics/bundle.d.ts.map +1 -1
  50. package/dist/core/diagnostics/bundle.js +2 -2
  51. package/dist/core/events/v3/producers/recorder.d.ts.map +1 -1
  52. package/dist/core/events/v3/producers/recorder.js +16 -0
  53. package/dist/core/events/v3/projection.js +9 -1
  54. package/dist/core/hooks/adapter/events.d.ts +17 -0
  55. package/dist/core/hooks/adapter/events.d.ts.map +1 -1
  56. package/dist/core/hooks/adapter/events.js +5 -0
  57. package/dist/core/hooks/adapter/runtime-telemetry.js +3 -1
  58. package/dist/core/hooks/adapter/wiring.d.ts +37 -7
  59. package/dist/core/hooks/adapter/wiring.d.ts.map +1 -1
  60. package/dist/core/hooks/adapter/wiring.js +62 -4
  61. package/dist/core/hooks/cli.js +45 -6
  62. package/dist/core/hooks/run-markers.d.ts +37 -0
  63. package/dist/core/hooks/run-markers.d.ts.map +1 -0
  64. package/dist/core/hooks/run-markers.js +109 -0
  65. package/dist/core/servers/index.d.ts +211 -0
  66. package/dist/core/servers/index.d.ts.map +1 -0
  67. package/dist/core/servers/index.js +560 -0
  68. package/dist/core/servers/net.d.ts +58 -0
  69. package/dist/core/servers/net.d.ts.map +1 -0
  70. package/dist/core/servers/net.js +270 -0
  71. package/dist/core/servers/tunnels.d.ts +14 -0
  72. package/dist/core/servers/tunnels.d.ts.map +1 -0
  73. package/dist/core/servers/tunnels.js +33 -0
  74. package/dist/core/storage/builtins.js +1 -0
  75. package/dist/core/workflow/engine.d.ts.map +1 -1
  76. package/dist/core/workflow/engine.js +30 -1
  77. package/dist/core/workflow/sandbox-projection.d.ts +13 -2
  78. package/dist/core/workflow/sandbox-projection.d.ts.map +1 -1
  79. package/dist/core/workflow/sandbox-projection.js +23 -0
  80. package/dist/core/workflow/spawn-claude.d.ts.map +1 -1
  81. package/dist/core/workflow/spawn-claude.js +6 -1
  82. package/dist/core/workflow/spawn-codex.d.ts +2 -0
  83. package/dist/core/workflow/spawn-codex.d.ts.map +1 -1
  84. package/dist/core/workflow/spawn-codex.js +20 -1
  85. package/dist/core/workflow/spawn-cursor.d.ts.map +1 -1
  86. package/dist/core/workflow/spawn-cursor.js +6 -1
  87. package/dist/core/workflow/spawn-opencode.d.ts.map +1 -1
  88. package/dist/core/workflow/spawn-opencode.js +4 -1
  89. package/dist/core/workflow/types.d.ts +47 -2
  90. package/dist/core/workflow/types.d.ts.map +1 -1
  91. package/dist/core/workflow/worker-access.d.ts +92 -0
  92. package/dist/core/workflow/worker-access.d.ts.map +1 -0
  93. package/dist/core/workflow/worker-access.js +450 -0
  94. package/dist/lib/browser/batch-steps.d.ts +18 -0
  95. package/dist/lib/browser/batch-steps.d.ts.map +1 -0
  96. package/dist/lib/browser/batch-steps.js +82 -0
  97. package/dist/lib/browser/collect/collector.d.ts +99 -0
  98. package/dist/lib/browser/collect/collector.d.ts.map +1 -0
  99. package/dist/lib/browser/collect/collector.js +202 -0
  100. package/dist/lib/browser/collect/fields.d.ts +31 -0
  101. package/dist/lib/browser/collect/fields.d.ts.map +1 -0
  102. package/dist/lib/browser/collect/fields.js +75 -0
  103. package/dist/lib/browser/collect/index.d.ts +6 -0
  104. package/dist/lib/browser/collect/index.d.ts.map +1 -0
  105. package/dist/lib/browser/collect/index.js +5 -0
  106. package/dist/lib/browser/collect/page-driver.d.ts +14 -0
  107. package/dist/lib/browser/collect/page-driver.d.ts.map +1 -0
  108. package/dist/lib/browser/collect/page-driver.js +306 -0
  109. package/dist/lib/browser/collect/presets.d.ts +52 -0
  110. package/dist/lib/browser/collect/presets.d.ts.map +1 -0
  111. package/dist/lib/browser/collect/presets.js +176 -0
  112. package/dist/lib/browser/collect/scroll-plan.d.ts +85 -0
  113. package/dist/lib/browser/collect/scroll-plan.d.ts.map +1 -0
  114. package/dist/lib/browser/collect/scroll-plan.js +118 -0
  115. package/dist/lib/browser/index.d.ts +1 -1
  116. package/dist/lib/browser/index.d.ts.map +1 -1
  117. package/dist/lib/browser/page-review-judge.d.ts +2 -0
  118. package/dist/lib/browser/page-review-judge.d.ts.map +1 -1
  119. package/dist/lib/browser/page-review-judge.js +13 -0
  120. package/dist/lib/browser/qa-run-contracts.d.ts +10 -0
  121. package/dist/lib/browser/qa-run-contracts.d.ts.map +1 -1
  122. package/dist/lib/browser/qa-run.d.ts.map +1 -1
  123. package/dist/lib/browser/qa-run.js +1 -0
  124. package/dist/lib/browser/windows-chrome.d.ts +59 -0
  125. package/dist/lib/browser/windows-chrome.d.ts.map +1 -0
  126. package/dist/lib/browser/windows-chrome.js +179 -0
  127. package/dist/lib/claude-desktop-share.d.ts +136 -0
  128. package/dist/lib/claude-desktop-share.d.ts.map +1 -0
  129. package/dist/lib/claude-desktop-share.js +394 -0
  130. package/dist/lib/disk-usage.d.ts +50 -0
  131. package/dist/lib/disk-usage.d.ts.map +1 -0
  132. package/dist/lib/disk-usage.js +303 -0
  133. package/dist/lib/docs-lint.d.ts +6 -0
  134. package/dist/lib/docs-lint.d.ts.map +1 -1
  135. package/dist/lib/docs-lint.js +8 -3
  136. package/dist/lib/guarded-remove.d.ts +34 -0
  137. package/dist/lib/guarded-remove.d.ts.map +1 -0
  138. package/dist/lib/guarded-remove.js +310 -0
  139. package/dist/lib/instructions/templates.js +8 -8
  140. package/dist/lib/tunnel/error-page.d.ts +1 -1
  141. package/dist/lib/tunnel/error-page.d.ts.map +1 -1
  142. package/dist/lib/tunnel/error-page.js +19 -8
  143. package/dist/lib/tunnel/gate.js +20 -1
  144. package/dist/lib/tunnel/path-scope.d.ts +22 -0
  145. package/dist/lib/tunnel/path-scope.d.ts.map +1 -0
  146. package/dist/lib/tunnel/path-scope.js +62 -0
  147. package/dist/lib/tunnel/state.d.ts +5 -0
  148. package/dist/lib/tunnel/state.d.ts.map +1 -1
  149. package/dist/lib/tunnel/state.js +17 -3
  150. package/package.json +6 -1
  151. package/schemas/config.schema.json +91 -0
  152. package/src/commander.ts +19 -0
  153. package/src/commands/agents.ts +14 -0
  154. package/src/commands/artifacts.ts +171 -19
  155. package/src/commands/browse.ts +400 -57
  156. package/src/commands/claude-desktop.ts +61 -0
  157. package/src/commands/disk.ts +114 -0
  158. package/src/commands/doctor.ts +52 -1
  159. package/src/commands/files.ts +93 -2
  160. package/src/commands/init.ts +55 -5
  161. package/src/commands/rm.ts +115 -0
  162. package/src/commands/servers.ts +506 -0
  163. package/src/commands/tunnel.ts +102 -2
  164. package/src/core/adapters/profiles.ts +22 -1
  165. package/src/core/adapters/types.ts +10 -0
  166. package/src/core/agents/cli.ts +14 -6
  167. package/src/core/agents/render/session-context.ts +1 -0
  168. package/src/core/agents/state/live-coordination-view.ts +73 -0
  169. package/src/core/artifacts/delivery-card.ts +10 -2
  170. package/src/core/artifacts/index.ts +697 -44
  171. package/src/core/config.ts +183 -4
  172. package/src/core/diagnostics/bundle.ts +2 -1
  173. package/src/core/events/v3/producers/recorder.ts +16 -0
  174. package/src/core/events/v3/projection.ts +11 -1
  175. package/src/core/hooks/adapter/events.ts +19 -0
  176. package/src/core/hooks/adapter/runtime-telemetry.ts +3 -1
  177. package/src/core/hooks/adapter/wiring.ts +116 -8
  178. package/src/core/hooks/cli.ts +47 -6
  179. package/src/core/hooks/run-markers.ts +132 -0
  180. package/src/core/servers/index.ts +712 -0
  181. package/src/core/servers/net.ts +283 -0
  182. package/src/core/servers/tunnels.ts +39 -0
  183. package/src/core/storage/builtins.ts +1 -0
  184. package/src/core/workflow/engine.ts +38 -1
  185. package/src/core/workflow/sandbox-projection.ts +38 -2
  186. package/src/core/workflow/spawn-claude.ts +5 -1
  187. package/src/core/workflow/spawn-codex.ts +18 -1
  188. package/src/core/workflow/spawn-cursor.ts +5 -1
  189. package/src/core/workflow/spawn-opencode.ts +4 -1
  190. package/src/core/workflow/types.ts +62 -2
  191. package/src/core/workflow/worker-access.ts +525 -0
  192. package/src/lib/browser/batch-steps.ts +79 -0
  193. package/src/lib/browser/collect/collector.ts +306 -0
  194. package/src/lib/browser/collect/fields.ts +87 -0
  195. package/src/lib/browser/collect/index.ts +47 -0
  196. package/src/lib/browser/collect/page-driver.ts +327 -0
  197. package/src/lib/browser/collect/presets.ts +237 -0
  198. package/src/lib/browser/collect/scroll-plan.ts +179 -0
  199. package/src/lib/browser/index.ts +1 -0
  200. package/src/lib/browser/page-review-judge.ts +16 -0
  201. package/src/lib/browser/qa-run-contracts.ts +11 -0
  202. package/src/lib/browser/qa-run.ts +1 -0
  203. package/src/lib/browser/windows-chrome.ts +227 -0
  204. package/src/lib/claude-desktop-share.ts +511 -0
  205. package/src/lib/disk-usage.ts +347 -0
  206. package/src/lib/docs-lint.ts +8 -3
  207. package/src/lib/guarded-remove.ts +376 -0
  208. package/src/lib/instructions/templates.ts +8 -8
  209. package/src/lib/tunnel/error-page.ts +25 -9
  210. package/src/lib/tunnel/gate.ts +22 -1
  211. package/src/lib/tunnel/path-scope.ts +73 -0
  212. package/src/lib/tunnel/state.ts +20 -3
@@ -0,0 +1,114 @@
1
+ import { type Command, InvalidArgumentError } from "commander";
2
+ import type { EmitContext } from "../commander.ts";
3
+ import {
4
+ type DiskGroup,
5
+ type DiskOptions,
6
+ type DiskReport,
7
+ diskUsage,
8
+ parseDiskSize,
9
+ } from "../lib/disk-usage.ts";
10
+
11
+ function positive(value: string): number {
12
+ if (!/^\d+$/.test(value) || !Number.isSafeInteger(Number(value)) || Number(value) < 1)
13
+ throw new InvalidArgumentError("requires a positive integer");
14
+ return Number(value);
15
+ }
16
+
17
+ export function registerDiskCommand(program: Command, emit: EmitContext): void {
18
+ program
19
+ .command("disk [path]")
20
+ .description("Measure checkout disk usage, including nested repositories and ignored files")
21
+ .option(
22
+ "--git <states>",
23
+ "Filter: tracked, untracked, ignored, git-metadata, non-repository, unknown (comma-separated)",
24
+ )
25
+ .option(
26
+ "--type <types>",
27
+ "File extensions or video, audio, image, media, archive (comma-separated)",
28
+ )
29
+ .option(
30
+ "--exclude <paths>",
31
+ "Skip directory names at any depth or root-relative paths; repeatable, comma-separated",
32
+ (value: string, previous: string[]) => [...previous, value],
33
+ [],
34
+ )
35
+ .option("--group <by>", "Group by directory, repository, extension, git, or files", "directory")
36
+ .option("--depth <n>", "Directory grouping depth; does not limit the scan", positive, 1)
37
+ .option("--top <n>", "Maximum groups and largest files to display", positive, 20)
38
+ .option(
39
+ "--min-size <size>",
40
+ "Include individual files at least this size (e.g. 10MB, 1GiB)",
41
+ parseDiskSize,
42
+ )
43
+ .option("--apparent", "Rank and filter by file lengths instead of allocated blocks")
44
+ .option("--json", "Emit the versioned disk usage report")
45
+ .action(
46
+ (path: string | undefined, options: DiskOptions & { group: DiskGroup; json?: boolean }) => {
47
+ try {
48
+ const { json, ...filters } = options;
49
+ const report = diskUsage(path, filters);
50
+ if (json) {
51
+ emit.config({ format: "json" });
52
+ emit.data(report);
53
+ } else emit.text(renderDiskUsage(report));
54
+ if (!report.complete) emit.setExitCode(1);
55
+ } catch (error) {
56
+ emit.error({
57
+ code: "disk_usage_failed",
58
+ message: error instanceof Error ? error.message : String(error),
59
+ });
60
+ emit.setExitCode(1);
61
+ }
62
+ },
63
+ );
64
+ }
65
+
66
+ export function renderDiskUsage(report: DiskReport): string {
67
+ const bytes = (value: number): string =>
68
+ value >= 1e9
69
+ ? `${(value / 1e9).toFixed(2)} GB`
70
+ : value >= 1e6
71
+ ? `${(value / 1e6).toFixed(2)} MB`
72
+ : value >= 1e3
73
+ ? `${(value / 1e3).toFixed(2)} KB`
74
+ : `${value} B`;
75
+ const size = (row: { allocated_bytes: number | null; apparent_bytes: number }): string =>
76
+ bytes(
77
+ report.measurement === "allocated"
78
+ ? (row.allocated_bytes ?? row.apparent_bytes)
79
+ : row.apparent_bytes,
80
+ );
81
+ const lines = [
82
+ `Disk usage: ${report.root}`,
83
+ `${size(report.totals)} in ${report.totals.files.toLocaleString()} regular files (${report.measurement})`,
84
+ `File lengths: ${bytes(report.totals.apparent_bytes)}. Directory metadata and symlinks are excluded.`,
85
+ "",
86
+ "Git state:",
87
+ ];
88
+ for (const [state, totals] of Object.entries(report.git))
89
+ if (totals.files)
90
+ lines.push(
91
+ `${size(totals).padStart(12)} ${String(totals.files).padStart(8)} files ${state}`,
92
+ );
93
+ lines.push(
94
+ "",
95
+ `Largest ${report.filters.group} groups (${report.groups.length} of ${report.group_count}):`,
96
+ );
97
+ for (const row of report.groups)
98
+ lines.push(`${size(row).padStart(12)} ${String(row.files).padStart(8)} files ${row.path}`);
99
+ if (report.filters.group !== "files") {
100
+ lines.push("", "Largest files:");
101
+ for (const row of report.largest_files)
102
+ lines.push(`${size(row).padStart(12)} ${row.git.padEnd(14)} ${row.path}`);
103
+ }
104
+ if (report.skipped.symlinks || report.skipped.special_files || report.skipped.excluded)
105
+ lines.push(
106
+ "",
107
+ `Skipped: ${report.skipped.symlinks} symlinks, ${report.skipped.special_files} special files, ${report.skipped.excluded} excluded entries.`,
108
+ );
109
+ if (report.issues.length) {
110
+ lines.push("", "Incomplete scan:");
111
+ for (const issue of report.issues) lines.push(`${issue.path}: ${issue.message}`);
112
+ }
113
+ return `${lines.join("\n")}\n`;
114
+ }
@@ -18,7 +18,7 @@ import path from "node:path";
18
18
  import type { Command } from "commander";
19
19
  import type { EmitContext } from "../commander.ts";
20
20
  import { BUILTIN_ADAPTER_IDS } from "../core/adapters/index.ts";
21
- import { resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
21
+ import { codexWindowsBridgeConfig, resolveBinName, ripgrepAutoInstall } from "../core/config.ts";
22
22
  import {
23
23
  countSummarizedSinceV3,
24
24
  listDiagnosticSummariesV3,
@@ -33,6 +33,7 @@ import {
33
33
  type CodexWslBridgeStatus,
34
34
  inspectCodexWslBridge,
35
35
  } from "../core/hooks/codex-wsl-bridge.ts";
36
+ import { findUnfinishedHookRuns } from "../core/hooks/run-markers.ts";
36
37
  import {
37
38
  ADAPTER_BINARIES,
38
39
  ADAPTER_INSTALL_HINTS,
@@ -115,6 +116,8 @@ export async function runChecks(): Promise<Check[]> {
115
116
  const codexWslStatus = inspectCodexWslBridge();
116
117
  const recentCodexMidFlightCount = root ? countRecentCodexMidFlightOnboardings(root) : 0;
117
118
  const codexWslBridge = codexWslBridgeCheck(codexWslStatus, recentCodexMidFlightCount);
119
+ const codexHookRuns = codexHookRunsCheck(root);
120
+ const codexWindowsRoute = codexWindowsBridgeCheck(root);
118
121
  // Probe the adapter CLIs before the hook check runs, not in output order:
119
122
  // an unwired adapter only matters if its CLI is actually installed, and
120
123
  // probing once here keeps that join to one spawn per CLI.
@@ -131,6 +134,8 @@ export async function runChecks(): Promise<Check[]> {
131
134
  ...(codexAuthorization ? [codexAuthorization] : []),
132
135
  checkGitHookRegions(),
133
136
  ...(codexWslBridge ? [codexWslBridge] : []),
137
+ ...(codexWindowsRoute ? [codexWindowsRoute] : []),
138
+ ...(codexHookRuns ? [codexHookRuns] : []),
134
139
  ...workflow.map((w) => w.check),
135
140
  checkRestic(),
136
141
  checkRclone(),
@@ -189,6 +194,52 @@ export function codexWslBridgeCheck(
189
194
  };
190
195
  }
191
196
 
197
+ /**
198
+ * Hook runs that started but never finished (ADR 0197). Codex kills a hook at
199
+ * its timeout and the tool proceeds without that hook's checks; the start
200
+ * marker is the only trace such a run leaves.
201
+ */
202
+ export function codexHookRunsCheck(
203
+ coordRoot: string | null,
204
+ nowMs = Date.now(),
205
+ isAlive?: (pid: number) => boolean,
206
+ ): Check | null {
207
+ if (!coordRoot) return null;
208
+ const runs = findUnfinishedHookRuns(coordRoot, nowMs, undefined, isAlive);
209
+ if (runs.count === 0) return null;
210
+ const events = runs.events.map((e) => `${e.event} ×${e.count}`).join(", ");
211
+ return {
212
+ name: "codex:hook runs",
213
+ severity: "warn",
214
+ detail:
215
+ `${runs.count} hook run${runs.count === 1 ? "" : "s"} started but never finished in the last 24h ` +
216
+ `(${events}; latest ${runs.latest}); their tools proceeded without those hooks' checks`,
217
+ hint: "a hook that outlives its timeout is killed; look for a stalled WSL session or a slow hook",
218
+ };
219
+ }
220
+
221
+ /** Report the per-project Windows bridge opt-in, or an invalid value init ignored. */
222
+ export function codexWindowsBridgeCheck(coordRoot: string | null): Check | null {
223
+ if (!coordRoot) return null;
224
+ const bridge = codexWindowsBridgeConfig(coordRoot);
225
+ if (bridge.error) {
226
+ return {
227
+ name: "codex:Windows hook route",
228
+ severity: "warn",
229
+ detail: `${bridge.error}; Codex hooks are wired without commandWindows`,
230
+ hint: "set hooks.codexWindowsBridge.entryPoint to a plain command name in .harnery/config.jsonc, then re-run init",
231
+ };
232
+ }
233
+ if (!bridge.entryPoint) return null;
234
+ return {
235
+ name: "codex:Windows hook route",
236
+ severity: "ok",
237
+ detail:
238
+ `opted in: Windows-native Codex hooks hand off to \`${bridge.entryPoint}\` from WSL paths ` +
239
+ `when it is on PATH, otherwise run the usual command; bridge health is reported by the bridge itself`,
240
+ };
241
+ }
242
+
192
243
  const CODEX_MID_FLIGHT_HISTORY_WINDOW_MS = 48 * 60 * 60 * 1000;
193
244
 
194
245
  /** Count bounded, privacy-safe recovery diagnostics so a healthy environment
@@ -5,7 +5,21 @@ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
5
5
  import { resolveCoordRoot } from "../core/agents/coord-client.ts";
6
6
  import { DEFAULT_WEB_PORT, resolveWebPort } from "../core/config.ts";
7
7
  import { coordRootId, localCoordRootIdUrl } from "../lib/coord-root-id.ts";
8
- import { localFilesOriginUrl, localFileViewerUrl } from "../lib/local-file-url.ts";
8
+ import {
9
+ encodedRepoPath,
10
+ encodeLinkSafeComponent,
11
+ FILES_ORIGIN_HOST,
12
+ localFilesOriginUrl,
13
+ localFileViewerUrl,
14
+ } from "../lib/local-file-url.ts";
15
+ import { isPathAllowed } from "../lib/tunnel/path-scope.ts";
16
+ import {
17
+ isTunnelStateLive,
18
+ listStates,
19
+ type ProcessAliveCheck,
20
+ type TunnelState,
21
+ tunnelTargetPort,
22
+ } from "../lib/tunnel/state.ts";
9
23
 
10
24
  export { FILES_ORIGIN_HOST } from "../lib/local-file-url.ts";
11
25
 
@@ -162,6 +176,72 @@ export function mintLocalFileUrl(input: string, options: LocalFileUrlOptions): L
162
176
  return { url, relPath, mode };
163
177
  }
164
178
 
179
+ export class TunnelLinkError extends Error {
180
+ constructor(
181
+ message: string,
182
+ readonly hint: string,
183
+ ) {
184
+ super(message);
185
+ this.name = "TunnelLinkError";
186
+ }
187
+ }
188
+
189
+ function vhostName(vhost: string): string {
190
+ return vhost.replace(/:\d+$/, "").toLowerCase();
191
+ }
192
+
193
+ function newestFirst(states: TunnelState[]): TunnelState[] {
194
+ return [...states].sort((a, b) => b.started_at.localeCompare(a.started_at));
195
+ }
196
+
197
+ /**
198
+ * Turn a minted local URL into the same file's URL on a live tunnel.
199
+ *
200
+ * HTML needs a tunnel in front of the isolated files origin (its vhost is
201
+ * FILES_ORIGIN_HOST), because only that origin renders the page and serves its
202
+ * relative assets; a dashboard tunnel can only show the viewer, which displays
203
+ * HTML as source. Other files need a dashboard tunnel that serves the viewer.
204
+ * In both cases the tunnel's path scope must forward the request, or its gate
205
+ * would refuse the link, so a tunnel that does not cover the file is skipped.
206
+ */
207
+ export function tunnelFileUrl(
208
+ local: LocalFileUrl,
209
+ port: number,
210
+ states: TunnelState[],
211
+ processAlive?: ProcessAliveCheck,
212
+ ): string {
213
+ const live = states.filter(
214
+ (state) => tunnelTargetPort(state) === port && isTunnelStateLive(state, processAlive),
215
+ );
216
+ if (local.mode === "raw") {
217
+ const path = `/${encodedRepoPath(local.relPath)}`;
218
+ const tunnel = newestFirst(live).find(
219
+ (state) =>
220
+ vhostName(state.vhost) === FILES_ORIGIN_HOST && isPathAllowed(path, state.allow_paths),
221
+ );
222
+ if (tunnel) return `${tunnel.url.replace(/\/+$/, "")}${path}`;
223
+ const folder = local.relPath.includes("/")
224
+ ? `/${local.relPath.slice(0, local.relPath.lastIndexOf("/"))}`
225
+ : "/";
226
+ throw new TunnelLinkError(
227
+ `no live tunnel serves the files origin for /${local.relPath}`,
228
+ `Start one scoped to the page's folder, for example: tunnel up --name pages --target 127.0.0.1:${port} --vhost ${FILES_ORIGIN_HOST} --allow-path ${folder}`,
229
+ );
230
+ }
231
+ const tunnel = newestFirst(live).find(
232
+ (state) =>
233
+ vhostName(state.vhost) !== FILES_ORIGIN_HOST &&
234
+ ["/files", "/api/file", "/_next"].every((path) => isPathAllowed(path, state.allow_paths)),
235
+ );
236
+ if (tunnel) {
237
+ return `${tunnel.url.replace(/\/+$/, "")}/files?path=${encodeLinkSafeComponent(local.relPath)}`;
238
+ }
239
+ throw new TunnelLinkError(
240
+ `no live tunnel serves the dashboard file viewer on port ${port}`,
241
+ `Start one, for example: tunnel up --name files --target 127.0.0.1:${port} --allow-path /files --allow-path /api/file --allow-path /_next`,
242
+ );
243
+ }
244
+
165
245
  export function registerFilesCommand(
166
246
  program: Command,
167
247
  emit: EmitContext,
@@ -180,6 +260,10 @@ export function registerFilesCommand(
180
260
  .option("--raw", "Use the isolated files origin, regardless of extension")
181
261
  .option("--viewer", "Use the dashboard file viewer, regardless of extension")
182
262
  .option("--no-verify", "Mint without checking the running dashboard's repository identity")
263
+ .option(
264
+ "--tunnel",
265
+ "Print the file's URL on a live tunnel instead of localhost (HTML uses a files-origin tunnel)",
266
+ )
183
267
  .action(
184
268
  async (
185
269
  filePath: string,
@@ -189,6 +273,7 @@ export function registerFilesCommand(
189
273
  raw?: boolean;
190
274
  viewer?: boolean;
191
275
  verify?: boolean;
276
+ tunnel?: boolean;
192
277
  },
193
278
  ) => {
194
279
  if (opts.raw && opts.viewer) {
@@ -224,8 +309,14 @@ export function registerFilesCommand(
224
309
  mode: opts.raw ? "raw" : opts.viewer ? "viewer" : "auto",
225
310
  });
226
311
  if (opts.verify !== false) await verifyDashboardRoot(coordRoot, port);
227
- emit.text(`${result.url}\n`);
312
+ const url = opts.tunnel ? tunnelFileUrl(result, port, listStates(coordRoot)) : result.url;
313
+ emit.text(`${url}\n`);
228
314
  } catch (error) {
315
+ if (error instanceof TunnelLinkError) {
316
+ emit.error({ code: "no_tunnel_for_file", message: error.message, hint: error.hint });
317
+ emit.setExitCode(1);
318
+ return;
319
+ }
229
320
  if (error instanceof DashboardRootError) {
230
321
  emit.error({ code: error.code, message: error.message, hint: error.hint });
231
322
  emit.setExitCode(1);
@@ -27,7 +27,12 @@ import { dirname, relative, resolve } from "node:path";
27
27
  import { fileURLToPath } from "node:url";
28
28
  import type { Command } from "commander";
29
29
  import type { EmitContext } from "../commander.ts";
30
- import { DEFAULT_BIN_NAME, pinnedBinName, stripJsonComments } from "../core/config.ts";
30
+ import {
31
+ codexWindowsBridgeConfig,
32
+ DEFAULT_BIN_NAME,
33
+ pinnedBinName,
34
+ stripJsonComments,
35
+ } from "../core/config.ts";
31
36
  import { canonicalJsonV3 } from "../core/events/v3/canonical.ts";
32
37
  import { ADAPTER_CAPABILITY_PROFILES_V3 } from "../core/events/v3/capabilities.ts";
33
38
  import type { EventV3ControlState } from "../core/events/v3/control.ts";
@@ -48,13 +53,17 @@ import {
48
53
  CURSOR_STOP_FOLLOWUP_LOOP_LIMIT,
49
54
  commandWiresSubcommand,
50
55
  diffWiring,
56
+ expectedCommandWindows,
51
57
  groupCommands,
52
58
  type HookGroup,
53
59
  hookCommand,
60
+ hookTimeoutFor,
54
61
  isAgentHookCommand,
55
62
  makeEntry,
63
+ orderHandlerKeys,
56
64
  type SettingsFile,
57
65
  summarizeAdapterWiring,
66
+ type WiringOptions,
58
67
  } from "../core/hooks/adapter/wiring.ts";
59
68
  import { detectInstalledAdapters } from "../core/workflow/adapters.ts";
60
69
  import { applyIndexerExclusions } from "../lib/indexer-exclusions.ts";
@@ -147,7 +156,11 @@ function checkAdapterHooks(
147
156
  let drift = false;
148
157
  try {
149
158
  const settings = JSON.parse(readFileSync(settingsPath, "utf8")) as SettingsFile;
150
- const hookDiff = diffWiring(settings, spec, { agentHookPath: agentHook, adapter });
159
+ const hookDiff = diffWiring(settings, spec, {
160
+ agentHookPath: agentHook,
161
+ adapter,
162
+ windowsBridgeEntryPoint: codexWindowsBridgeConfig(projectRoot).entryPoint,
163
+ });
151
164
  drift =
152
165
  hookDiff.missing.length > 0 ||
153
166
  hookDiff.stale.length > 0 ||
@@ -460,11 +473,16 @@ export function registerInitCommand(program: Command, emit: EmitContext, binName
460
473
  } else {
461
474
  settings = {};
462
475
  }
476
+ const bridge = codexWindowsBridgeConfig(projectRoot);
477
+ if (bridge.error && spec.windowsBridgeHooks) {
478
+ actions.push(`⚠ ${bridge.error}; writing ${adapter} hooks without commandWindows`);
479
+ }
463
480
  const { wired, already, removed, upgraded } = wireHooks(
464
481
  settings,
465
482
  spec,
466
483
  agentHook,
467
484
  adapter,
485
+ { windowsBridgeEntryPoint: bridge.entryPoint },
468
486
  );
469
487
 
470
488
  if (wired === 0 && removed === 0 && upgraded === 0) {
@@ -588,6 +606,7 @@ export function wireHooks(
588
606
  spec: AdapterSpec,
589
607
  agentHookPath: string,
590
608
  adapter: AdapterId,
609
+ options?: WiringOptions,
591
610
  ): { wired: number; already: number; removed: number; upgraded: number } {
592
611
  if (spec.rootVersion !== undefined && settings.version === undefined) {
593
612
  settings.version = spec.rootVersion;
@@ -606,6 +625,19 @@ export function wireHooks(
606
625
  }
607
626
  for (const { settingsKey, subcommand } of spec.events) {
608
627
  const command = hookCommand(spec, agentHookPath, subcommand, adapter);
628
+ const timeout = hookTimeoutFor(spec, subcommand);
629
+ const commandWindows = expectedCommandWindows(
630
+ spec,
631
+ agentHookPath,
632
+ subcommand,
633
+ adapter,
634
+ options,
635
+ );
636
+ const handlerFields = {
637
+ timeout,
638
+ commandWindows,
639
+ windowsBridge: spec.windowsBridgeHooks === true,
640
+ };
609
641
  let present = false;
610
642
  for (const key of Object.keys(settings.hooks)) {
611
643
  if (key === settingsKey) continue;
@@ -616,7 +648,7 @@ export function wireHooks(
616
648
  const groups = settings.hooks[settingsKey] ?? [];
617
649
  const nextGroups: HookGroup[] = [];
618
650
  for (const group of groups) {
619
- const normalized = normalizeEventGroup(group, subcommand, command, present);
651
+ const normalized = normalizeEventGroup(group, subcommand, command, handlerFields, present);
620
652
  if (normalized.found && !present) present = true;
621
653
  upgraded += normalized.upgraded;
622
654
  removed += normalized.removed;
@@ -635,7 +667,7 @@ export function wireHooks(
635
667
  command,
636
668
  spec.entryShape === "cursor" && subcommand === "stop"
637
669
  ? { loop_limit: CURSOR_STOP_FOLLOWUP_LOOP_LIMIT }
638
- : undefined,
670
+ : { timeout, commandWindows },
639
671
  ),
640
672
  );
641
673
  settings.hooks[settingsKey] = current;
@@ -658,6 +690,7 @@ function normalizeEventGroup(
658
690
  group: HookGroup,
659
691
  subcommand: string,
660
692
  canonical: string,
693
+ fields: { timeout?: number; commandWindows?: string; windowsBridge: boolean },
661
694
  alreadyFound: boolean,
662
695
  ): { group: HookGroup | null; found: boolean; upgraded: number; removed: number } {
663
696
  let found = false;
@@ -676,7 +709,8 @@ function normalizeEventGroup(
676
709
  }
677
710
  if ("hooks" in group && Array.isArray(group.hooks)) {
678
711
  const kept = [] as typeof group.hooks;
679
- for (const hook of group.hooks) {
712
+ for (const original of group.hooks) {
713
+ let hook = original;
680
714
  if (typeof hook.command !== "string" || !commandWiresSubcommand(hook.command, subcommand)) {
681
715
  kept.push(hook);
682
716
  continue;
@@ -686,8 +720,24 @@ function normalizeEventGroup(
686
720
  continue;
687
721
  }
688
722
  found = true;
723
+ let changed = false;
689
724
  if (hook.command !== canonical) {
690
725
  hook.command = canonical;
726
+ changed = true;
727
+ }
728
+ if (fields.timeout !== undefined && hook.timeout !== fields.timeout) {
729
+ hook.timeout = fields.timeout;
730
+ changed = true;
731
+ }
732
+ // Harnery owns commandWindows on its own handlers for adapters that run
733
+ // it: write the opted-in command, or remove one a project opted out of.
734
+ if (fields.windowsBridge && hook.commandWindows !== fields.commandWindows) {
735
+ if (fields.commandWindows === undefined) delete hook.commandWindows;
736
+ else hook.commandWindows = fields.commandWindows;
737
+ changed = true;
738
+ }
739
+ if (changed) {
740
+ hook = orderHandlerKeys(hook);
691
741
  upgraded++;
692
742
  }
693
743
  kept.push(hook);
@@ -0,0 +1,115 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { isAbsolute, relative, resolve, sep } from "node:path";
4
+ import type { Command } from "commander";
5
+ import type { EmitContext, HarneryProgramContext } from "../commander.ts";
6
+ import { resolveCoordRoot } from "../core/agents/coord-client.ts";
7
+ import {
8
+ readCoordinationViewV3,
9
+ requireAuthoritySafeCoordinationViewV3,
10
+ } from "../core/events/v3/coordination-view.ts";
11
+ import { liveInstanceIdV3 } from "../core/events/v3/live-route-observer.ts";
12
+ import { resolveOwner } from "../core/hooks/resolve/owner.ts";
13
+ import { guardedRemove, type RemoveOptions } from "../lib/guarded-remove.ts";
14
+
15
+ export function registerRmCommand(
16
+ program: Command,
17
+ emit: EmitContext,
18
+ context?: HarneryProgramContext,
19
+ ): void {
20
+ program
21
+ .command("rm <paths...>")
22
+ .description("Preview guarded removal of untracked files; --yes applies permanent deletion")
23
+ .requiredOption(
24
+ "--root <directory>",
25
+ "Allowed directory; each target must be strictly inside it",
26
+ )
27
+ .option("-r, --recursive", "Permit directory removal after inspecting its complete contents")
28
+ .option(
29
+ "--allow-protected <path>",
30
+ "Remove this one protected path (Git metadata, credentials, Harnery state, keys) inside a target; repeat per path",
31
+ (value: string, previous: string[] = []) => [...previous, value],
32
+ )
33
+ .option("--yes", "Permanently remove validated targets")
34
+ .option("--dry-run", "Preview only, even with --yes")
35
+ .option("--json", "Emit the versioned removal report")
36
+ .action((paths: string[], options: RemoveOptions & { json?: boolean }) => {
37
+ try {
38
+ const coordRoot = context?.resolveCoordRoot?.() ?? context?.repoRoot ?? resolveCoordRoot();
39
+ const checkClaims = (targets: string[]): void => {
40
+ if (!coordRoot || !existsSync(resolve(coordRoot, ".harnery"))) return;
41
+ const view = requireAuthoritySafeCoordinationViewV3(readCoordinationViewV3(coordRoot));
42
+ const owner = resolveOwner({ payload: null, coordRoot });
43
+ const ownId = owner ? liveInstanceIdV3(owner.instance_id) : undefined;
44
+ const ownSession = ownId ? view.instances[ownId]?.session_id : undefined;
45
+ for (const peer of Object.values(view.instances)) {
46
+ if (
47
+ !peer.authority_eligible ||
48
+ peer.instance_id === ownId ||
49
+ (ownSession && peer.session_id === ownSession)
50
+ )
51
+ continue;
52
+ for (const claim of peer.files_touched) {
53
+ const claimed = resolve(coordRoot, claim);
54
+ for (const target of targets) {
55
+ if (overlaps(target, claimed))
56
+ throw new Error(
57
+ `Target overlaps another agent's claim: ${claim} (${peer.instance_id})`,
58
+ );
59
+ }
60
+ }
61
+ }
62
+ };
63
+ // The workspace this command coordinates, and the checkout it runs in:
64
+ // their own .git and .harnery can never be allowed.
65
+ const liveRoots = [coordRoot, gitTopLevel(process.cwd())].filter((dir): dir is string =>
66
+ Boolean(dir),
67
+ );
68
+ const report = guardedRemove(paths, { ...options, liveRoots }, checkClaims);
69
+ if (options.json) {
70
+ emit.config({ format: "json" });
71
+ emit.data(report);
72
+ } else {
73
+ const lines = [
74
+ `${report.applied ? "Removed" : "Would remove"} ${report.targets.length} target(s), ${report.entries} entries, ${report.bytes} bytes:`,
75
+ ...report.targets,
76
+ ];
77
+ if (report.protected.length)
78
+ lines.push(
79
+ "Protected paths named with --allow-protected:",
80
+ ...report.protected.map((path) => ` ${path}`),
81
+ );
82
+ if (report.links.length)
83
+ lines.push(
84
+ "Links removed as links (their targets are untouched):",
85
+ ...report.links.map((link) => ` ${link.path} -> ${link.target}`),
86
+ );
87
+ if (!report.applied)
88
+ lines.push("Preview only. Add --yes to permanently delete these targets.");
89
+ emit.text(lines.join("\n"));
90
+ }
91
+ } catch (error) {
92
+ emit.error({
93
+ code: "removal_refused",
94
+ message: error instanceof Error ? error.message : String(error),
95
+ });
96
+ emit.setExitCode(1);
97
+ }
98
+ });
99
+ }
100
+
101
+ function gitTopLevel(dir: string): string | undefined {
102
+ const result = spawnSync("git", ["-C", dir, "rev-parse", "--show-toplevel"], {
103
+ encoding: "utf8",
104
+ timeout: 10_000,
105
+ });
106
+ return result.status === 0 ? result.stdout.trim() || undefined : undefined;
107
+ }
108
+
109
+ function overlaps(a: string, b: string): boolean {
110
+ const inside = (parent: string, child: string) => {
111
+ const rel = relative(parent, child);
112
+ return rel === "" || (rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
113
+ };
114
+ return inside(a, b) || inside(b, a);
115
+ }