@indigoai-us/hq-cli 5.345.135 → 5.345.137

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 (63) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/assets/scaffold/core/scripts/worktree-gc.sh +98 -8
  3. package/dist/command-catalog.generated.d.ts +17 -6
  4. package/dist/command-catalog.generated.js +23 -7
  5. package/dist/commands/core-checkpoint-native.d.ts +10 -0
  6. package/dist/commands/core-checkpoint-native.js +60 -15
  7. package/dist/commands/daemon.js +3 -1
  8. package/dist/commands/integrations-core.d.ts +22 -0
  9. package/dist/commands/integrations-core.js +25 -0
  10. package/dist/commands/integrations-manage.d.ts +24 -1
  11. package/dist/commands/integrations-manage.js +92 -17
  12. package/dist/commands/integrations.d.ts +11 -0
  13. package/dist/commands/integrations.js +100 -19
  14. package/dist/commands/lanes-loop.d.ts +9 -0
  15. package/dist/commands/lanes-loop.js +9 -1
  16. package/dist/commands/lanes.js +69 -54
  17. package/dist/commands/meetings.d.ts +2 -0
  18. package/dist/commands/meetings.js +57 -14
  19. package/dist/commands/mesh.d.ts +19 -1
  20. package/dist/commands/mesh.js +158 -70
  21. package/dist/commands/reindex.js +80 -5
  22. package/dist/commands/storage-offload.d.ts +93 -28
  23. package/dist/commands/storage-offload.js +290 -34
  24. package/dist/commands/storage.js +60 -7
  25. package/dist/hq-cloud-sync-runner/bin/sync-runner.js +2550 -1748
  26. package/dist/hq-cloud-sync-runner/package.json +1 -1
  27. package/dist/index.js +4 -0
  28. package/dist/lib/capacity/route.js +24 -0
  29. package/dist/lib/core-utils/session-company.d.ts +11 -0
  30. package/dist/lib/core-utils/session-company.js +34 -0
  31. package/dist/lib/core-utils/worktree-resume.js +44 -4
  32. package/dist/lib/core-utils/worktree-session-owner.d.ts +27 -0
  33. package/dist/lib/core-utils/worktree-session-owner.js +157 -0
  34. package/dist/lib/daemon/catalog.d.ts +2 -0
  35. package/dist/lib/daemon/catalog.js +3 -3
  36. package/dist/lib/daemon/hq-child-bootstrap-stage.d.ts +5 -0
  37. package/dist/lib/daemon/hq-child-bootstrap-stage.js +3 -11
  38. package/dist/lib/daemon/server.js +10 -1
  39. package/dist/lib/daemon/supervisor.js +6 -0
  40. package/dist/lib/daemon/sync-runner-bundle.d.ts +5 -0
  41. package/dist/lib/daemon/sync-runner-bundle.js +4 -12
  42. package/dist/lib/doctor/checks/claude-wiring.js +2 -1
  43. package/dist/lib/doctor/fix/apply.d.ts +20 -0
  44. package/dist/lib/doctor/fix/apply.js +63 -3
  45. package/dist/lib/doctor/fix/remediation.d.ts +1 -1
  46. package/dist/lib/doctor/fix/remediation.js +16 -0
  47. package/dist/lib/doctor/shared.d.ts +11 -1
  48. package/dist/lib/doctor/shared.js +16 -2
  49. package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
  50. package/dist/lib/flag-registry-worker-source.generated.js +1 -1
  51. package/dist/lib/lanes/monitor.js +14 -2
  52. package/dist/lib/mesh/live/daemon/install.d.ts +12 -0
  53. package/dist/lib/mesh/live/daemon/install.js +22 -0
  54. package/dist/lib/mesh/live/flush.js +106 -3
  55. package/dist/lib/mesh/live/session-events-client.d.ts +9 -0
  56. package/dist/lib/mesh/live/session-events-client.js +15 -0
  57. package/dist/lib/work-context/state.d.ts +10 -0
  58. package/dist/lib/work-context/state.js +5 -0
  59. package/dist/utils/fsync-directory.d.ts +17 -0
  60. package/dist/utils/fsync-directory.js +27 -0
  61. package/dist/utils/windows-hide-default.d.ts +42 -0
  62. package/dist/utils/windows-hide-default.js +98 -0
  63. package/package.json +2 -2
@@ -8,13 +8,18 @@
8
8
  * uploads each blob, verifies size + sha256 + git blob id, then hands only
9
9
  * verified blobs to core `hq-storage.sh strip-large`, which rewrites history
10
10
  * to the pointer text. Working-tree files are never touched.
11
- * - Current files (idle big files in the working tree). NOT offloaded yet:
12
- * the sync delete planner lives in @indigoai-us/hq-cloud (`computePullPlan`
13
- * and the push delete planner in dist/cli/sync.js), so replacing `x.mp4`
14
- * with `x.mp4.hqcloud` would still propagate as a remote delete of `x.mp4`.
15
- * `--current` reports `{ available: false }` until hq-cloud learns about
16
- * placeholders (CURRENT_OFFLOAD_ENABLED). `offloadCurrentFile` is built
17
- * and tested but not called.
11
+ * - Current files (`--current [paths...]`, idle big files in the working
12
+ * tree). Each file goes to the vault that owns its path. A file under
13
+ * `companies/<slug>/` is never uploaded: when HQ sync already stored this
14
+ * exact version in that company's vault (journal hash + size, and the vault
15
+ * object still has the journal's ETag), the file is replaced by a
16
+ * placeholder pointing at that object. Otherwise it is skipped with "run
17
+ * hq sync first", so sync stays the only writer of company vault keys. Any
18
+ * other file points at its personal-vault sync object the same way, or is
19
+ * uploaded to the personal vault's `.hq-offload/blobs/`. Current-file
20
+ * offload runs only when the installed HQ sync holds every `.hqcloud`
21
+ * sibling (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING) and skips
22
+ * `.hq-offload/`; otherwise it reports "update HQ sync".
18
23
  *
19
24
  * Fetch also reads placeholders that HQ sync rewrote after a teammate changed
20
25
  * the file (`vault: company`, the company vault key, `sha256: unknown`,
@@ -24,9 +29,10 @@
24
29
  * which writes the caller's own personal-vault copy to a file outside HQ so
25
30
  * it can never sync back.
26
31
  *
27
- * Tenant isolation: bytes only ever go to the caller's personal vault. The
28
- * personal store refuses a company vault bucket, and nothing here lists or
29
- * opens company memberships.
32
+ * Tenant isolation: uploads only ever go to the caller's personal vault, and
33
+ * the personal store refuses a company vault bucket. A company vault is opened
34
+ * read-only (HEAD and ETag-pinned GET), and only for a company the caller is
35
+ * an active member of.
30
36
  */
31
37
  import { GetObjectCommand, HeadObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
32
38
  import type { ScriptRunner } from "./storage.js";
@@ -37,14 +43,10 @@ export declare const POINTER_HEADER = "HQ-CLOUD-FILE v1";
37
43
  export declare const POINTER_FOOTER = "This file is stored in HQ cloud storage. Open it in HQ, or run the command above to download it.";
38
44
  export declare const DEFAULT_MIN_BYTES: number;
39
45
  export declare const DEFAULT_IDLE_DAYS = 14;
40
- export declare const CURRENT_BLOCKED_REASON = "Offloading current files is not available yet: HQ sync would treat the replaced file as deleted.";
41
- /**
42
- * Gate for `--current`. Stays false until the owner enables current-file
43
- * offload on top of hq-cloud's cloud-only placeholders (PR #919, 6.18.61).
44
- */
45
- export declare const CURRENT_OFFLOAD_ENABLED = false;
46
46
  export declare const ORPHANED_MESSAGE = "This file was deleted by a teammate. It's no longer in the cloud for your team.";
47
47
  export declare const UPDATE_SYNC = "update HQ sync";
48
+ /** Reason on company files HQ sync has not stored in the company vault yet. */
49
+ export declare const RUN_SYNC_FIRST = "run hq sync first";
48
50
  /** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
49
51
  export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
50
52
  /**
@@ -56,7 +58,7 @@ export declare const OFFLOAD_TOP_LEVEL = ".hq-offload";
56
58
  * @indigoai-us/hq-cloud 6.18.61 (PR #919) lists ".hq-offload" in
57
59
  * PERSONAL_VAULT_EXCLUDED_TOP_LEVEL; storage.ts passes that export here. The
58
60
  * check stays at runtime so an older bundled HQ sync still reports
59
- * "update HQ sync". CURRENT_OFFLOAD_ENABLED is a separate owner decision.
61
+ * "update HQ sync". Current-file offload also needs syncHoldsPlaceholders.
60
62
  */
61
63
  export declare function syncExcludesOffload(excludedTopLevel: readonly string[] | undefined): boolean;
62
64
  export declare function offloadKey(gitBlobSha: string): string;
@@ -118,11 +120,16 @@ export interface OffloadS3Client {
118
120
  export declare function s3BlobStore(bucket: string, s3: OffloadS3Client): BlobStore;
119
121
  /** Read-only access to vault objects at their sync keys, pinned to an ETag. */
120
122
  export interface VaultObjectStore {
123
+ /** Size and unquoted ETag of `key`, or null when it is not there. */
124
+ head(key: string): Promise<{
125
+ size: number;
126
+ etag: string;
127
+ } | null>;
121
128
  /** Download `key` to `dest` only if its ETag is `etag`; returns the served ETag. */
122
129
  download(key: string, dest: string, etag: string): Promise<string>;
123
130
  }
124
131
  export interface VaultObjectS3Client {
125
- send(cmd: GetObjectCommand): Promise<unknown>;
132
+ send(cmd: GetObjectCommand | HeadObjectCommand): Promise<unknown>;
126
133
  }
127
134
  export declare function s3VaultObjectStore(bucket: string, s3: VaultObjectS3Client): VaultObjectStore;
128
135
  export interface OffloadDeps {
@@ -152,7 +159,40 @@ export interface OffloadDeps {
152
159
  vault: "company";
153
160
  slug: string;
154
161
  }) => Promise<VaultObjectStore>;
162
+ /**
163
+ * Whether the installed HQ sync treats every `x.hqcloud` sibling as a hold
164
+ * on `x` in push and pull (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING).
165
+ * Current-file offload stays off unless this returns true.
166
+ */
167
+ syncHoldsPlaceholders?: () => boolean;
168
+ /** What HQ sync last recorded for `target` (its journal entry), or null. */
169
+ syncRecord?: (target: OffloadTarget) => Promise<SyncRecord | null>;
170
+ /** File operations for current-file offload (tests inject to see the order). */
171
+ placeholderFs?: PlaceholderFs;
155
172
  }
173
+ /** The vault that owns a working-tree path, and the object key there. */
174
+ export type OffloadTarget = {
175
+ vault: "personal";
176
+ key: string;
177
+ } | {
178
+ vault: "company";
179
+ slug: string;
180
+ key: string;
181
+ };
182
+ /** A sync journal entry: the version HQ sync last pushed or pulled. */
183
+ export interface SyncRecord {
184
+ /** Hex sha256 of the synced bytes. */
185
+ hash?: string;
186
+ size: number;
187
+ remoteEtag?: string;
188
+ }
189
+ /**
190
+ * `companies/<slug>/<rest>` belongs to that company's vault at key `<rest>`.
191
+ * Every other path belongs to the personal vault at its HQ-root-relative path.
192
+ */
193
+ export declare function resolveOffloadTarget(rel: string): OffloadTarget;
194
+ /** Whether current-file offload can run with the installed HQ sync. */
195
+ export declare function currentSyncReady(deps: Pick<OffloadDeps, "syncExcludesOffload" | "syncHoldsPlaceholders">): boolean;
156
196
  export declare function storageScriptPath(hqRoot: string): string;
157
197
  /** Whether the installed core script has a `case` arm for `sub`. */
158
198
  export declare function scriptSupports(hqRoot: string, sub: "large" | "strip-large"): boolean;
@@ -215,17 +255,34 @@ export interface PlaceholderFs {
215
255
  }
216
256
  export declare const durablePlaceholderFs: PlaceholderFs;
217
257
  /**
218
- * Offload one current file: upload and verify, write `x.hqcloud` (fsynced),
219
- * and only then remove `x`. With the placeholder written first, a sync
220
- * watcher never sees `x` missing without its placeholder, which it would
221
- * push as a delete.
222
- *
223
- * Not called while CURRENT_OFFLOAD_ENABLED is false.
258
+ * Offload one personal-scope current file by upload: upload and verify,
259
+ * write `x.hqcloud` (fsynced), and only then remove `x`. With the placeholder
260
+ * written first, a sync watcher never sees `x` missing without its
261
+ * placeholder. Company files never take this path (see pointCurrentFileAtVault).
224
262
  */
225
263
  export declare function offloadCurrentFile(rel: string, store: BlobStore, deps: Pick<OffloadDeps, "hqRoot" | "now">, fsOps?: PlaceholderFs): Promise<{
226
264
  path: string;
227
265
  bytes: number;
228
266
  }>;
267
+ /** One current file in a `--current` plan (dry run and real run). */
268
+ export interface CurrentFilePlan {
269
+ path: string;
270
+ bytes: number;
271
+ idle_days: number;
272
+ vault: "personal" | "company";
273
+ company?: string;
274
+ /** HQ sync already stored this exact version in the owning vault. */
275
+ synced: boolean;
276
+ action: "point-at-vault" | "upload" | "skip";
277
+ reason?: string;
278
+ }
279
+ export interface CurrentOffloaded {
280
+ path: string;
281
+ bytes: number;
282
+ vault: "personal" | "company";
283
+ company?: string;
284
+ action: "point-at-vault" | "upload";
285
+ }
229
286
  export interface OffloadOptions {
230
287
  minBytes: number;
231
288
  idleDays: number;
@@ -233,12 +290,16 @@ export interface OffloadOptions {
233
290
  current: boolean;
234
291
  dryRun: boolean;
235
292
  yes: boolean;
293
+ /** With `current`: offload files at or under these HQ paths instead of the large-file list. */
294
+ paths?: string[];
236
295
  }
237
296
  export interface OffloadResult {
238
297
  history: {
239
298
  uploaded: number;
240
299
  bytes: number;
241
300
  freed_bytes: number;
301
+ /** True when this run did not ask for history. */
302
+ skipped?: boolean;
242
303
  available?: boolean;
243
304
  reason?: string;
244
305
  candidates?: number;
@@ -273,13 +334,17 @@ export interface OffloadResult {
273
334
  } | null;
274
335
  };
275
336
  current: {
276
- offloaded: {
277
- path: string;
278
- bytes: number;
279
- }[];
337
+ offloaded: CurrentOffloaded[];
280
338
  freed_bytes: number;
281
339
  available?: boolean;
282
340
  reason?: string;
341
+ /** Every candidate with its target vault, sync state, and action. */
342
+ files?: CurrentFilePlan[];
343
+ /** True when this run did not ask for current files. */
344
+ skipped?: boolean;
345
+ /** Company files skipped because HQ sync has not stored this version yet. */
346
+ unsynced?: string[];
347
+ message?: string;
283
348
  };
284
349
  errors: string[];
285
350
  dry_run: boolean;
@@ -8,13 +8,18 @@
8
8
  * uploads each blob, verifies size + sha256 + git blob id, then hands only
9
9
  * verified blobs to core `hq-storage.sh strip-large`, which rewrites history
10
10
  * to the pointer text. Working-tree files are never touched.
11
- * - Current files (idle big files in the working tree). NOT offloaded yet:
12
- * the sync delete planner lives in @indigoai-us/hq-cloud (`computePullPlan`
13
- * and the push delete planner in dist/cli/sync.js), so replacing `x.mp4`
14
- * with `x.mp4.hqcloud` would still propagate as a remote delete of `x.mp4`.
15
- * `--current` reports `{ available: false }` until hq-cloud learns about
16
- * placeholders (CURRENT_OFFLOAD_ENABLED). `offloadCurrentFile` is built
17
- * and tested but not called.
11
+ * - Current files (`--current [paths...]`, idle big files in the working
12
+ * tree). Each file goes to the vault that owns its path. A file under
13
+ * `companies/<slug>/` is never uploaded: when HQ sync already stored this
14
+ * exact version in that company's vault (journal hash + size, and the vault
15
+ * object still has the journal's ETag), the file is replaced by a
16
+ * placeholder pointing at that object. Otherwise it is skipped with "run
17
+ * hq sync first", so sync stays the only writer of company vault keys. Any
18
+ * other file points at its personal-vault sync object the same way, or is
19
+ * uploaded to the personal vault's `.hq-offload/blobs/`. Current-file
20
+ * offload runs only when the installed HQ sync holds every `.hqcloud`
21
+ * sibling (hq-cloud CLOUD_PLACEHOLDER_HOLDS_ANY_SIBLING) and skips
22
+ * `.hq-offload/`; otherwise it reports "update HQ sync".
18
23
  *
19
24
  * Fetch also reads placeholders that HQ sync rewrote after a teammate changed
20
25
  * the file (`vault: company`, the company vault key, `sha256: unknown`,
@@ -24,9 +29,10 @@
24
29
  * which writes the caller's own personal-vault copy to a file outside HQ so
25
30
  * it can never sync back.
26
31
  *
27
- * Tenant isolation: bytes only ever go to the caller's personal vault. The
28
- * personal store refuses a company vault bucket, and nothing here lists or
29
- * opens company memberships.
32
+ * Tenant isolation: uploads only ever go to the caller's personal vault, and
33
+ * the personal store refuses a company vault bucket. A company vault is opened
34
+ * read-only (HEAD and ETag-pinned GET), and only for a company the caller is
35
+ * an active member of.
30
36
  */
31
37
  import * as crypto from "node:crypto";
32
38
  import * as fs from "node:fs";
@@ -42,15 +48,12 @@ export const POINTER_HEADER = "HQ-CLOUD-FILE v1";
42
48
  export const POINTER_FOOTER = "This file is stored in HQ cloud storage. Open it in HQ, or run the command above to download it.";
43
49
  export const DEFAULT_MIN_BYTES = 50 * 1024 * 1024;
44
50
  export const DEFAULT_IDLE_DAYS = 14;
45
- export const CURRENT_BLOCKED_REASON = "Offloading current files is not available yet: HQ sync would treat the replaced file as deleted.";
46
- /**
47
- * Gate for `--current`. Stays false until the owner enables current-file
48
- * offload on top of hq-cloud's cloud-only placeholders (PR #919, 6.18.61).
49
- */
50
- export const CURRENT_OFFLOAD_ENABLED = false;
51
51
  export const ORPHANED_MESSAGE = "This file was deleted by a teammate. It's no longer in the cloud for your team.";
52
52
  const UPDATE_HQ = "update HQ";
53
53
  export const UPDATE_SYNC = "update HQ sync";
54
+ /** Reason on company files HQ sync has not stored in the company vault yet. */
55
+ export const RUN_SYNC_FIRST = "run hq sync first";
56
+ const DAY_MS = 86_400_000;
54
57
  const PERSONAL_BUCKET_PREFIX = "hq-vault-prs-";
55
58
  /** Top-level HQ-root folder that holds offloaded blobs in the personal vault. */
56
59
  export const OFFLOAD_TOP_LEVEL = ".hq-offload";
@@ -63,7 +66,7 @@ export const OFFLOAD_TOP_LEVEL = ".hq-offload";
63
66
  * @indigoai-us/hq-cloud 6.18.61 (PR #919) lists ".hq-offload" in
64
67
  * PERSONAL_VAULT_EXCLUDED_TOP_LEVEL; storage.ts passes that export here. The
65
68
  * check stays at runtime so an older bundled HQ sync still reports
66
- * "update HQ sync". CURRENT_OFFLOAD_ENABLED is a separate owner decision.
69
+ * "update HQ sync". Current-file offload also needs syncHoldsPlaceholders.
67
70
  */
68
71
  export function syncExcludesOffload(excludedTopLevel) {
69
72
  return Array.isArray(excludedTopLevel) && excludedTopLevel.includes(OFFLOAD_TOP_LEVEL);
@@ -245,6 +248,17 @@ export function s3BlobStore(bucket, s3) {
245
248
  }
246
249
  export function s3VaultObjectStore(bucket, s3) {
247
250
  return {
251
+ async head(key) {
252
+ try {
253
+ const res = (await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key })));
254
+ return { size: res.ContentLength ?? -1, etag: normalizeEtag(res.ETag) };
255
+ }
256
+ catch (err) {
257
+ if (isNotFound(err))
258
+ return null;
259
+ throw err;
260
+ }
261
+ },
248
262
  async download(key, dest, etag) {
249
263
  let res;
250
264
  try {
@@ -273,6 +287,21 @@ export function s3VaultObjectStore(bucket, s3) {
273
287
  },
274
288
  };
275
289
  }
290
+ /**
291
+ * `companies/<slug>/<rest>` belongs to that company's vault at key `<rest>`.
292
+ * Every other path belongs to the personal vault at its HQ-root-relative path.
293
+ */
294
+ export function resolveOffloadTarget(rel) {
295
+ const posix = toPosix(rel);
296
+ const m = /^companies\/([^/]+)\/(.+)$/.exec(posix);
297
+ if (m)
298
+ return { vault: "company", slug: m[1], key: m[2] };
299
+ return { vault: "personal", key: posix };
300
+ }
301
+ /** Whether current-file offload can run with the installed HQ sync. */
302
+ export function currentSyncReady(deps) {
303
+ return deps.syncExcludesOffload() && deps.syncHoldsPlaceholders?.() === true;
304
+ }
276
305
  // ── Core script capability ──────────────────────────────────────────────────
277
306
  export function storageScriptPath(hqRoot) {
278
307
  return path.join(hqRoot, "core", "scripts", "hq-storage.sh");
@@ -408,16 +437,17 @@ export const durablePlaceholderFs = {
408
437
  },
409
438
  };
410
439
  /**
411
- * Offload one current file: upload and verify, write `x.hqcloud` (fsynced),
412
- * and only then remove `x`. With the placeholder written first, a sync
413
- * watcher never sees `x` missing without its placeholder, which it would
414
- * push as a delete.
415
- *
416
- * Not called while CURRENT_OFFLOAD_ENABLED is false.
440
+ * Offload one personal-scope current file by upload: upload and verify,
441
+ * write `x.hqcloud` (fsynced), and only then remove `x`. With the placeholder
442
+ * written first, a sync watcher never sees `x` missing without its
443
+ * placeholder. Company files never take this path (see pointCurrentFileAtVault).
417
444
  */
418
445
  export async function offloadCurrentFile(rel, store, deps, fsOps = durablePlaceholderFs) {
419
446
  if (store.vault !== "personal")
420
447
  throw new Error("Offload only writes to your personal vault.");
448
+ if (resolveOffloadTarget(rel).vault === "company") {
449
+ throw new Error(`'${rel}' is a company file. Company files are never uploaded to your personal vault.`);
450
+ }
421
451
  assertRestorable(rel);
422
452
  const abs = containedPath(deps.hqRoot, rel);
423
453
  const before = await fs.promises.stat(abs);
@@ -449,23 +479,251 @@ export async function offloadCurrentFile(rel, store, deps, fsOps = durablePlaceh
449
479
  await fsOps.removeOriginal(abs);
450
480
  return { path: toPosix(rel), bytes: digest.size };
451
481
  }
482
+ /**
483
+ * Point one current file at the vault object HQ sync stored for it: confirm
484
+ * the object still has the recorded ETag and size, write `x.hqcloud`
485
+ * (fsynced), and only then remove `x`. Nothing is uploaded.
486
+ */
487
+ async function pointCurrentFileAtVault(file, vault, deps, fsOps) {
488
+ const { plan, target, stat: before, digest, record } = file;
489
+ if (!before || !digest || !record?.remoteEtag)
490
+ throw new Error(`'${plan.path}' has no synced version.`);
491
+ const abs = containedPath(deps.hqRoot, plan.path);
492
+ const head = await vault.head(target.key);
493
+ if (!head || head.etag !== normalizeEtag(record.remoteEtag) || head.size !== digest.size) {
494
+ throw new Error(`The cloud copy of '${plan.path}' changed. Nothing was replaced; ${RUN_SYNC_FIRST}.`);
495
+ }
496
+ const unchanged = async () => {
497
+ const st = await fs.promises.stat(abs);
498
+ return st.size === before.size && st.mtimeMs === before.mtimeMs;
499
+ };
500
+ const changed = new Error(`'${plan.path}' changed while offloading. Nothing was replaced.`);
501
+ if (!(await unchanged()))
502
+ throw changed;
503
+ const placeholder = `${abs}${PLACEHOLDER_SUFFIX}`;
504
+ await fsOps.writePlaceholder(placeholder, renderPointer({
505
+ path: plan.path,
506
+ size: digest.size,
507
+ sha256: digest.sha256,
508
+ vault: target.vault,
509
+ key: target.key,
510
+ offloadedAt: deps.now().toISOString().replace(/\.\d{3}Z$/, "Z"),
511
+ remoteEtag: head.etag,
512
+ }));
513
+ if (!(await unchanged())) {
514
+ await fs.promises.rm(placeholder, { force: true });
515
+ throw changed;
516
+ }
517
+ await fsOps.removeOriginal(abs);
518
+ }
519
+ /** Whether a working-tree path is never a current-offload candidate. */
520
+ function skippedByName(rel) {
521
+ const parts = rel.split("/");
522
+ const name = parts[parts.length - 1];
523
+ return (parts[0] === OFFLOAD_TOP_LEVEL ||
524
+ parts.includes(".git") ||
525
+ (parts[0] === ".claude" && parts[1] === "hooks") ||
526
+ name.endsWith(PLACEHOLDER_SUFFIX) ||
527
+ name.endsWith(".hqcloud-download"));
528
+ }
529
+ /**
530
+ * Regular files at or under each path argument, HQ-root-relative and sorted.
531
+ * Symlinks are not followed, and a path outside HQ throws.
532
+ */
533
+ function walkCurrentPaths(hqRoot, args) {
534
+ const realRoot = fs.realpathSync(path.resolve(hqRoot));
535
+ const out = [];
536
+ const seen = new Set();
537
+ const visit = (abs) => {
538
+ const rel = toPosix(path.relative(realRoot, abs));
539
+ if (rel === "" ? false : skippedByName(rel))
540
+ return;
541
+ const st = fs.lstatSync(abs);
542
+ if (st.isSymbolicLink())
543
+ return;
544
+ if (st.isDirectory()) {
545
+ for (const name of fs.readdirSync(abs).sort())
546
+ visit(path.join(abs, name));
547
+ return;
548
+ }
549
+ if (st.isFile() && !seen.has(rel)) {
550
+ seen.add(rel);
551
+ out.push(rel);
552
+ }
553
+ };
554
+ for (const arg of args) {
555
+ const lexical = path.resolve(realRoot, arg);
556
+ const abs = containedPath(hqRoot, arg);
557
+ if (!fs.existsSync(abs))
558
+ throw new Error(`'${arg}' does not exist in HQ.`);
559
+ if (fs.lstatSync(lexical).isSymbolicLink())
560
+ continue;
561
+ visit(abs);
562
+ }
563
+ return out;
564
+ }
565
+ /**
566
+ * Decide what `--current` does with each candidate. Read-only: stats and
567
+ * hashes local files, reads sync records and HEADs vault objects.
568
+ */
569
+ async function planCurrentFiles(rels, opts, deps, openVault) {
570
+ let active;
571
+ const planned = [];
572
+ const now = deps.now().getTime();
573
+ for (const rel of rels) {
574
+ if (skippedByName(rel))
575
+ continue;
576
+ const abs = containedPath(deps.hqRoot, rel);
577
+ let st;
578
+ try {
579
+ st = fs.lstatSync(abs);
580
+ }
581
+ catch (err) {
582
+ if (err.code === "ENOENT")
583
+ continue;
584
+ throw err;
585
+ }
586
+ if (!st.isFile() || fs.existsSync(`${abs}${PLACEHOLDER_SUFFIX}`))
587
+ continue;
588
+ const idle = Math.max(0, Math.floor((now - st.mtimeMs) / DAY_MS));
589
+ if (st.size < opts.minBytes || idle < opts.idleDays)
590
+ continue;
591
+ const target = resolveOffloadTarget(rel);
592
+ const plan = {
593
+ path: rel,
594
+ bytes: st.size,
595
+ idle_days: idle,
596
+ vault: target.vault,
597
+ synced: false,
598
+ action: target.vault === "company" ? "skip" : "upload",
599
+ };
600
+ if (target.vault === "company") {
601
+ plan.company = target.slug;
602
+ active ??= deps.activeCompanySlugs ? await deps.activeCompanySlugs() : [];
603
+ if (!active.includes(target.slug)) {
604
+ plan.reason = `You are not an active member of '${target.slug}'.`;
605
+ planned.push({ plan, target });
606
+ continue;
607
+ }
608
+ }
609
+ const record = deps.syncRecord ? await deps.syncRecord(target) : null;
610
+ let digest;
611
+ if (record?.remoteEtag && record.hash && record.size === st.size) {
612
+ digest = await digestFile(abs);
613
+ if (digest.sha256 === record.hash.toLowerCase()) {
614
+ const head = await (await openVault(target)).head(target.key);
615
+ plan.synced = head !== null && head.etag === normalizeEtag(record.remoteEtag) && head.size === st.size;
616
+ }
617
+ }
618
+ if (plan.synced)
619
+ plan.action = "point-at-vault";
620
+ else if (target.vault === "company")
621
+ plan.reason = RUN_SYNC_FIRST;
622
+ planned.push({ plan, target, stat: st, digest, record: record ?? undefined });
623
+ }
624
+ return planned;
625
+ }
626
+ /** `--current`: plan, confirm, then offload each file to the vault that owns it. */
627
+ async function runCurrent(opts, deps, result) {
628
+ const current = result.current;
629
+ if (!currentSyncReady(deps)) {
630
+ current.available = false;
631
+ current.reason = UPDATE_SYNC;
632
+ return "ok";
633
+ }
634
+ let rels;
635
+ if (opts.paths && opts.paths.length > 0) {
636
+ rels = walkCurrentPaths(deps.hqRoot, opts.paths);
637
+ }
638
+ else {
639
+ const large = await getLarge(deps, opts.minBytes);
640
+ if (!large.available) {
641
+ current.available = false;
642
+ current.reason = large.reason;
643
+ return "ok";
644
+ }
645
+ rels = large.current.map((f) => toPosix(f.path));
646
+ }
647
+ current.available = true;
648
+ const vaults = new Map();
649
+ const openVault = (t) => {
650
+ if (!deps.openVaultObjects)
651
+ throw new Error("This HQ build cannot read files stored by HQ sync.");
652
+ const id = t.vault === "company" ? `company:${t.slug}` : "personal";
653
+ let v = vaults.get(id);
654
+ if (!v) {
655
+ v = deps.openVaultObjects(t.vault === "company" ? { vault: "company", slug: t.slug } : { vault: "personal" });
656
+ vaults.set(id, v);
657
+ }
658
+ return v;
659
+ };
660
+ const planned = await planCurrentFiles(rels, opts, deps, openVault);
661
+ current.files = planned.map((p) => p.plan);
662
+ const unsynced = planned.filter((p) => p.plan.reason === RUN_SYNC_FIRST).map((p) => p.plan.path);
663
+ if (unsynced.length > 0) {
664
+ current.unsynced = unsynced;
665
+ current.message =
666
+ `${unsynced.length} company file(s) are not in the company vault yet, so they were left as they are. ` +
667
+ `To offload them, ${RUN_SYNC_FIRST}, then run offload again: ${unsynced.join(", ")}`;
668
+ }
669
+ const todo = planned.filter((p) => p.plan.action !== "skip");
670
+ if (opts.dryRun || todo.length === 0)
671
+ return "ok";
672
+ if (!opts.yes) {
673
+ const ok = await deps.confirm(`Replace ${todo.length} current file(s) with links to their HQ cloud copies and remove the local copies?`);
674
+ if (!ok) {
675
+ deps.log("Aborted - nothing was uploaded or changed.");
676
+ return "aborted";
677
+ }
678
+ }
679
+ const fsOps = deps.placeholderFs ?? durablePlaceholderFs;
680
+ let personal;
681
+ for (const file of todo) {
682
+ const { plan } = file;
683
+ try {
684
+ if (plan.action === "point-at-vault") {
685
+ await pointCurrentFileAtVault(file, await openVault(file.target), deps, fsOps);
686
+ }
687
+ else {
688
+ personal ??= await deps.openPersonal();
689
+ await offloadCurrentFile(plan.path, personal, deps, fsOps);
690
+ }
691
+ const done = {
692
+ path: plan.path,
693
+ bytes: plan.bytes,
694
+ vault: plan.vault,
695
+ action: plan.action === "point-at-vault" ? "point-at-vault" : "upload",
696
+ };
697
+ if (plan.company)
698
+ done.company = plan.company;
699
+ current.offloaded.push(done);
700
+ current.freed_bytes += plan.bytes;
701
+ }
702
+ catch (err) {
703
+ result.errors.push(`${plan.path}: ${err.message}`);
704
+ }
705
+ }
706
+ return "ok";
707
+ }
452
708
  export async function runOffload(opts, deps) {
453
- const both = !opts.history && !opts.current;
454
- const doHistory = both || opts.history;
455
- const doCurrent = both || opts.current;
709
+ // Current files are offloaded only on request; a bare run touches history only.
710
+ const doHistory = opts.history || !opts.current;
711
+ const doCurrent = opts.current;
456
712
  const result = {
457
713
  history: { uploaded: 0, bytes: 0, freed_bytes: 0 },
458
714
  current: { offloaded: [], freed_bytes: 0 },
459
715
  errors: [],
460
716
  dry_run: opts.dryRun,
461
717
  };
462
- if (doCurrent) {
463
- result.current.available = false;
464
- result.current.reason = CURRENT_BLOCKED_REASON;
465
- }
718
+ if (!doCurrent)
719
+ result.current.skipped = true;
720
+ if (doCurrent && (await runCurrent(opts, deps, result)) === "aborted")
721
+ return undefined;
466
722
  const script = storageScriptPath(deps.hqRoot);
467
- if (!doHistory)
723
+ if (!doHistory) {
724
+ result.history.skipped = true;
468
725
  return result;
726
+ }
469
727
  if (!deps.syncExcludesOffload()) {
470
728
  result.history.available = false;
471
729
  result.history.reason = UPDATE_SYNC;
@@ -843,8 +1101,6 @@ async function fetchGitBlob(sha, out, deps) {
843
1101
  }
844
1102
  /** Current-file offload availability, shared by status and its error fallback. */
845
1103
  export function currentAvailability(syncReady) {
846
- if (!CURRENT_OFFLOAD_ENABLED)
847
- return { current_available: false, current_reason: CURRENT_BLOCKED_REASON };
848
1104
  if (!syncReady)
849
1105
  return { current_available: false, current_reason: UPDATE_SYNC };
850
1106
  return { current_available: true };
@@ -854,7 +1110,7 @@ export async function getOffloadStatus(deps, opts) {
854
1110
  history_candidates: { count: 0, bytes: 0 },
855
1111
  current_candidates: { count: 0, bytes: 0 },
856
1112
  placeholders: { count: 0, bytes: 0 },
857
- ...currentAvailability(deps.syncExcludesOffload()),
1113
+ ...currentAvailability(currentSyncReady(deps)),
858
1114
  };
859
1115
  for (const rel of await deps.listPlaceholders()) {
860
1116
  const pointer = readPointerFile(containedPath(deps.hqRoot, rel));