@indigoai-us/hq-cli 5.345.150 → 5.345.151
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.
- package/CHANGELOG.md +6 -0
- package/dist/commands/cloud-off-gate.d.ts +138 -0
- package/dist/commands/cloud-off-gate.js +234 -0
- package/dist/commands/cloud.d.ts +7 -0
- package/dist/commands/cloud.js +59 -17
- package/dist/commands/core-handoff.js +6 -1
- package/dist/commands/meetings.js +7 -3
- package/dist/lib/daemon/catalog.d.ts +7 -0
- package/dist/lib/daemon/catalog.js +10 -1
- package/dist/lib/daemon/run.js +13 -0
- package/dist/lib/lanes/links.js +27 -11
- package/dist/lib/vault-sync-switches.d.ts +41 -0
- package/dist/lib/vault-sync-switches.js +86 -0
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.151] — 2026-10-20
|
|
6
|
+
- `hq lanes link _deliver` no longer hands a conductor message to a subagent. Subagent hook events share the root session id, so a subagent tool call used to claim the message and the root session never saw it. Child payloads, including Grok child sessions (`subagent_type`) and Codex child threads, now get nothing and the message stays queued until the root session's next tool call or stop. SubagentStop no longer delivers. A parent session id alone does not mark a child, so resumed, forked, or launcher-spawned Grok roots still receive their messages.
|
|
7
|
+
- If you leave out `--company`, `hq meetings invite <url>` now uses your only active company. If you belong to several companies, it lists them and asks you to pick one. The command used to send the invite as Personal instead. Personal can never record, so you saw the HQ Workforce upgrade prompt even when your company already had meeting recording.
|
|
8
|
+
- `hq sync push`, `pull` and `now` honour the HQ app's Settings > Sync switches on this computer. A command aimed at a vault whose sync is off (the personal vault, one company vault, or every vault with "Turn off all cloud sync") prints `Sync is off for <vault>` with how to turn it back on, and exits 3 without signing in or touching the vault. The target is the `--company` value, else `activeCompany` or `companySlug` in `.hq/config.json`; a `cmp_*` uid is matched through `companies/manifest.yaml`, and an unmatched uid is refused while any company vault is off. `--creds-from-stdin` is checked once stdin is read. With `--json`, the refusal is a `{"type":"skipped","reason":"sync_off"}` line on stderr. `--all` skips the off vaults and syncs the rest. The `hq daemon` background sync skips off vaults the same way and stays stopped while all cloud sync is off; a switch change restarts it once the current sync pass finishes. A settings file that cannot be read prints a warning (and logs one in the daemon) instead of silently syncing everything. `/handoff` still finishes locally and logs a skipped push as skipped.
|
|
9
|
+
- If you already had the HQ app's personal-files switch off (`personalSyncEnabled: false`) or a company's sync off (`workspaceSyncEnabled.<slug>: false`), `hq sync` now refuses those vaults too. That includes the `/handoff` personal publish, the `/startwork` and `/resumework` prefetch, and `/delegate` for those vaults. Turn the vault back on in HQ Settings > Sync to sync it from the command line again.
|
|
10
|
+
|
|
5
11
|
## [5.345.150] — 2026-10-19
|
|
6
12
|
- The bundled sync engine now uses hq-cloud 6.18.90, which brings two fixes.
|
|
7
13
|
- Owners and admins can now destroy one of a company's Outposts with
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync switch gate for the manual `hq sync push|pull|now` commands
|
|
3
|
+
* (usage-only-pricing US-016).
|
|
4
|
+
*
|
|
5
|
+
* The desktop app's Settings > Sync section has one switch per vault plus one
|
|
6
|
+
* "Turn off all cloud sync" switch (see `../lib/vault-sync-switches.ts`). The
|
|
7
|
+
* switches affect only this computer. The app gates its own sync paths, but
|
|
8
|
+
* skills and scripts call these CLI commands directly (`/handoff`, the
|
|
9
|
+
* `/startwork` and `/resumework` prefetch, `/delegate`, job remediation). This
|
|
10
|
+
* gate makes those calls honour the same switches: a command aimed at an off
|
|
11
|
+
* vault refuses, and an `--all` run skips the off vaults while the rest sync.
|
|
12
|
+
*
|
|
13
|
+
* How the target vault is found, before any login or network call:
|
|
14
|
+
* - `--personal`, or a `prs_*` uid, is the personal vault;
|
|
15
|
+
* - `--company <ref>`, else the active company in `<hq-root>/.hq/config.json`
|
|
16
|
+
* (`activeCompany ?? companySlug`, the same order hq-cloud's resolver uses);
|
|
17
|
+
* - a `cmp_*` uid is mapped to its slug through `companies/manifest.yaml`
|
|
18
|
+
* (`cloud_uid`). A uid that cannot be mapped while any company vault is off
|
|
19
|
+
* is refused (fail closed): the gate cannot prove it is not an off vault.
|
|
20
|
+
* - `--creds-from-stdin`: the target is the parsed EntityContext, checked
|
|
21
|
+
* after stdin is read (`refusalForEntity`) and still before any transfer.
|
|
22
|
+
*
|
|
23
|
+
* The daemon's own `syncPaused` is not read here: it pauses background sync
|
|
24
|
+
* only (`hq daemon sync pause`, the tray pause), and a manual push while
|
|
25
|
+
* background sync is paused is expected to run.
|
|
26
|
+
*/
|
|
27
|
+
import { readVaultSyncSwitches, vaultSkipEnv, type VaultSyncSwitches } from "../lib/vault-sync-switches.js";
|
|
28
|
+
export { readVaultSyncSwitches, vaultSkipEnv, type VaultSyncSwitches };
|
|
29
|
+
/**
|
|
30
|
+
* Exit code for "skipped because sync is off". Distinct from 1 (sync failed)
|
|
31
|
+
* so a caller can tell the two apart; every non-zero exit still means "nothing
|
|
32
|
+
* was synced", which is what `/delegate` and job remediation need to see.
|
|
33
|
+
*/
|
|
34
|
+
export declare const CLOUD_OFF_EXIT_CODE = 3;
|
|
35
|
+
export type GatedSyncCommand = "push" | "pull" | "now";
|
|
36
|
+
/** True when "Turn off all cloud sync" is on. Never throws. */
|
|
37
|
+
export declare function isCloudOff(home?: string): boolean;
|
|
38
|
+
/** True when `company` (a slug) is a company vault whose switch is off. */
|
|
39
|
+
export declare function isCompanyVaultOff(switches: VaultSyncSwitches, company: string): boolean;
|
|
40
|
+
/**
|
|
41
|
+
* The refusal text when every vault is off. The first line keeps the phrase
|
|
42
|
+
* "refused immediately": `handoff-sync-prefetch` (shell and native) reports
|
|
43
|
+
* `skipped` rather than `error:<code>` when the pull log contains it, so
|
|
44
|
+
* released HQ versions treat this exit as skipped with no script change.
|
|
45
|
+
*/
|
|
46
|
+
export declare function cloudOffMessage(command: GatedSyncCommand): string;
|
|
47
|
+
/** The refusal text when the one vault a command targets is off. */
|
|
48
|
+
export declare function vaultOffMessage(command: GatedSyncCommand, vaultName: string): string;
|
|
49
|
+
/** The refusal text when a company uid cannot be mapped while some vault is off. */
|
|
50
|
+
export declare function unresolvedVaultMessage(command: GatedSyncCommand, ref: string): string;
|
|
51
|
+
/** Which vaults a `push|pull|now` invocation targets. */
|
|
52
|
+
export interface SyncSelection {
|
|
53
|
+
all?: boolean;
|
|
54
|
+
/** `true` for `--personal`; `false` is `--no-personal` (only meaningful with `--all`). */
|
|
55
|
+
personal?: boolean;
|
|
56
|
+
company?: string;
|
|
57
|
+
/** Used to find the active company and the uid-to-slug manifest. */
|
|
58
|
+
hqRoot?: string;
|
|
59
|
+
/** `--creds-from-stdin`: the target is checked after stdin is parsed. */
|
|
60
|
+
credsFromStdin?: boolean;
|
|
61
|
+
}
|
|
62
|
+
/** What an `--all` run must leave out. */
|
|
63
|
+
export interface VaultSkips {
|
|
64
|
+
skipPersonal: boolean;
|
|
65
|
+
skipCompanies: ReadonlySet<string>;
|
|
66
|
+
}
|
|
67
|
+
/** A refusal: the text, and the vault it names (for JSON output). */
|
|
68
|
+
export interface VaultRefusal {
|
|
69
|
+
message: string;
|
|
70
|
+
vault: string;
|
|
71
|
+
}
|
|
72
|
+
/** The vault a company reference or entity uid names. */
|
|
73
|
+
export type ResolvedVault = {
|
|
74
|
+
kind: "personal";
|
|
75
|
+
} | {
|
|
76
|
+
kind: "company";
|
|
77
|
+
slug: string;
|
|
78
|
+
} | {
|
|
79
|
+
kind: "unknown";
|
|
80
|
+
ref: string;
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Active company from `<hqRoot>/.hq/config.json`, best effort. Uses
|
|
84
|
+
* `activeCompany ?? companySlug`, the order hq-cloud's `resolveActiveCompany`
|
|
85
|
+
* uses, so a config with only `companySlug` is still gated.
|
|
86
|
+
*/
|
|
87
|
+
export declare function readActiveCompanyRef(hqRoot: string | undefined): string | undefined;
|
|
88
|
+
/** Map a slug, `cmp_*` uid or `prs_*` uid to the vault it names. */
|
|
89
|
+
export declare function resolveVaultRef(ref: string, hqRoot: string | undefined): ResolvedVault;
|
|
90
|
+
/**
|
|
91
|
+
* The refusal for `selection`, or null when it may run. `--all` never
|
|
92
|
+
* refuses for one vault: it skips the off vaults (see {@link vaultSkips}).
|
|
93
|
+
* With `--creds-from-stdin` and no `--company`, the target is unknown until
|
|
94
|
+
* stdin is parsed; {@link refusalForEntity} checks it then.
|
|
95
|
+
*/
|
|
96
|
+
export declare function refusalFor(command: GatedSyncCommand, selection: SyncSelection, switches: VaultSyncSwitches): VaultRefusal | null;
|
|
97
|
+
/**
|
|
98
|
+
* The refusal for a pre-vended EntityContext read from stdin. A `prs_*` uid
|
|
99
|
+
* is the personal vault; otherwise the slug (or the uid, through the
|
|
100
|
+
* manifest) names the company.
|
|
101
|
+
*/
|
|
102
|
+
export declare function refusalForEntity(command: GatedSyncCommand, entity: {
|
|
103
|
+
uid?: unknown;
|
|
104
|
+
slug?: unknown;
|
|
105
|
+
}, hqRoot: string | undefined, switches: VaultSyncSwitches): VaultRefusal | null;
|
|
106
|
+
/** The vaults an `--all` run leaves out. */
|
|
107
|
+
export declare function vaultSkips(selection: SyncSelection, switches: VaultSyncSwitches): VaultSkips;
|
|
108
|
+
/** Thrown after stdin is parsed when its target vault is off. */
|
|
109
|
+
export declare class VaultSyncOffError extends Error {
|
|
110
|
+
readonly refusal: VaultRefusal;
|
|
111
|
+
constructor(refusal: VaultRefusal);
|
|
112
|
+
}
|
|
113
|
+
export interface CloudOffGateDeps {
|
|
114
|
+
home?: string;
|
|
115
|
+
writeErr?: (text: string) => void;
|
|
116
|
+
exit?: (code: number) => never;
|
|
117
|
+
/** `--json`: stderr is a JSON Lines stream, so write events, not prose. */
|
|
118
|
+
json?: boolean;
|
|
119
|
+
}
|
|
120
|
+
/** Write a refusal: plain text, or one JSON Lines event in `--json` mode. */
|
|
121
|
+
export declare function writeVaultRefusal(refusal: VaultRefusal, deps?: Pick<CloudOffGateDeps, "writeErr" | "json">): void;
|
|
122
|
+
/**
|
|
123
|
+
* Print the refusal and exit {@link CLOUD_OFF_EXIT_CODE} when `selection`
|
|
124
|
+
* targets an off vault (or every vault is off); otherwise return what an
|
|
125
|
+
* `--all` run must skip. Runs before any login, lock or network work, and
|
|
126
|
+
* outside the client-health report, so a refusal is not counted as a failed
|
|
127
|
+
* sync. Warns when the settings file is unreadable.
|
|
128
|
+
*/
|
|
129
|
+
export declare function refuseSyncForOffVaults(command: GatedSyncCommand, selection: SyncSelection, deps?: CloudOffGateDeps): VaultSkips;
|
|
130
|
+
/**
|
|
131
|
+
* `--creds-from-stdin` check: throw {@link VaultSyncOffError} when the parsed
|
|
132
|
+
* EntityContext targets an off vault. The caller writes it and exits 3.
|
|
133
|
+
*/
|
|
134
|
+
export declare function assertEntityVaultSyncAllowed(command: GatedSyncCommand, entity: {
|
|
135
|
+
uid?: unknown;
|
|
136
|
+
slug?: unknown;
|
|
137
|
+
}, hqRoot: string | undefined, home?: string): void;
|
|
138
|
+
//# sourceMappingURL=cloud-off-gate.d.ts.map
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync switch gate for the manual `hq sync push|pull|now` commands
|
|
3
|
+
* (usage-only-pricing US-016).
|
|
4
|
+
*
|
|
5
|
+
* The desktop app's Settings > Sync section has one switch per vault plus one
|
|
6
|
+
* "Turn off all cloud sync" switch (see `../lib/vault-sync-switches.ts`). The
|
|
7
|
+
* switches affect only this computer. The app gates its own sync paths, but
|
|
8
|
+
* skills and scripts call these CLI commands directly (`/handoff`, the
|
|
9
|
+
* `/startwork` and `/resumework` prefetch, `/delegate`, job remediation). This
|
|
10
|
+
* gate makes those calls honour the same switches: a command aimed at an off
|
|
11
|
+
* vault refuses, and an `--all` run skips the off vaults while the rest sync.
|
|
12
|
+
*
|
|
13
|
+
* How the target vault is found, before any login or network call:
|
|
14
|
+
* - `--personal`, or a `prs_*` uid, is the personal vault;
|
|
15
|
+
* - `--company <ref>`, else the active company in `<hq-root>/.hq/config.json`
|
|
16
|
+
* (`activeCompany ?? companySlug`, the same order hq-cloud's resolver uses);
|
|
17
|
+
* - a `cmp_*` uid is mapped to its slug through `companies/manifest.yaml`
|
|
18
|
+
* (`cloud_uid`). A uid that cannot be mapped while any company vault is off
|
|
19
|
+
* is refused (fail closed): the gate cannot prove it is not an off vault.
|
|
20
|
+
* - `--creds-from-stdin`: the target is the parsed EntityContext, checked
|
|
21
|
+
* after stdin is read (`refusalForEntity`) and still before any transfer.
|
|
22
|
+
*
|
|
23
|
+
* The daemon's own `syncPaused` is not read here: it pauses background sync
|
|
24
|
+
* only (`hq daemon sync pause`, the tray pause), and a manual push while
|
|
25
|
+
* background sync is paused is expected to run.
|
|
26
|
+
*/
|
|
27
|
+
import * as fs from "node:fs";
|
|
28
|
+
import * as path from "node:path";
|
|
29
|
+
import { readCompaniesManifestMap } from "../lib/work-context/repo-remote.js";
|
|
30
|
+
import { readVaultSyncSwitches, unreadableSwitchesWarning, vaultSkipEnv, } from "../lib/vault-sync-switches.js";
|
|
31
|
+
export { readVaultSyncSwitches, vaultSkipEnv };
|
|
32
|
+
/**
|
|
33
|
+
* Exit code for "skipped because sync is off". Distinct from 1 (sync failed)
|
|
34
|
+
* so a caller can tell the two apart; every non-zero exit still means "nothing
|
|
35
|
+
* was synced", which is what `/delegate` and job remediation need to see.
|
|
36
|
+
*/
|
|
37
|
+
export const CLOUD_OFF_EXIT_CODE = 3;
|
|
38
|
+
/** True when "Turn off all cloud sync" is on. Never throws. */
|
|
39
|
+
export function isCloudOff(home) {
|
|
40
|
+
return readVaultSyncSwitches(home).allOff;
|
|
41
|
+
}
|
|
42
|
+
/** True when `company` (a slug) is a company vault whose switch is off. */
|
|
43
|
+
export function isCompanyVaultOff(switches, company) {
|
|
44
|
+
return switches.offCompanies.has(company.trim().toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
const HOW_TO_TURN_ON = "To turn it back on, open HQ and go to Settings > Sync.";
|
|
47
|
+
const NOTHING_MOVED = "Nothing was uploaded or downloaded. Your local files are unchanged.";
|
|
48
|
+
/**
|
|
49
|
+
* The refusal text when every vault is off. The first line keeps the phrase
|
|
50
|
+
* "refused immediately": `handoff-sync-prefetch` (shell and native) reports
|
|
51
|
+
* `skipped` rather than `error:<code>` when the pull log contains it, so
|
|
52
|
+
* released HQ versions treat this exit as skipped with no script change.
|
|
53
|
+
*/
|
|
54
|
+
export function cloudOffMessage(command) {
|
|
55
|
+
return [
|
|
56
|
+
`Cloud sync is off for every vault on this computer, so \`hq sync ${command}\` was refused immediately.`,
|
|
57
|
+
NOTHING_MOVED,
|
|
58
|
+
HOW_TO_TURN_ON,
|
|
59
|
+
].join("\n");
|
|
60
|
+
}
|
|
61
|
+
/** The refusal text when the one vault a command targets is off. */
|
|
62
|
+
export function vaultOffMessage(command, vaultName) {
|
|
63
|
+
return [
|
|
64
|
+
`Sync is off for ${vaultName} on this computer, so \`hq sync ${command}\` was refused immediately.`,
|
|
65
|
+
NOTHING_MOVED,
|
|
66
|
+
HOW_TO_TURN_ON,
|
|
67
|
+
].join("\n");
|
|
68
|
+
}
|
|
69
|
+
/** The refusal text when a company uid cannot be mapped while some vault is off. */
|
|
70
|
+
export function unresolvedVaultMessage(command, ref) {
|
|
71
|
+
return [
|
|
72
|
+
`Sync is off for one or more company vaults on this computer, and \`hq sync ${command}\` could not tell which vault ${ref} is, so it was refused immediately.`,
|
|
73
|
+
NOTHING_MOVED,
|
|
74
|
+
`Run it with --company <slug>, or turn the vaults back on in HQ Settings > Sync.`,
|
|
75
|
+
].join("\n");
|
|
76
|
+
}
|
|
77
|
+
const COMPANY_UID = /^cmp_/i;
|
|
78
|
+
const PERSON_UID = /^prs_/i;
|
|
79
|
+
/**
|
|
80
|
+
* Active company from `<hqRoot>/.hq/config.json`, best effort. Uses
|
|
81
|
+
* `activeCompany ?? companySlug`, the order hq-cloud's `resolveActiveCompany`
|
|
82
|
+
* uses, so a config with only `companySlug` is still gated.
|
|
83
|
+
*/
|
|
84
|
+
export function readActiveCompanyRef(hqRoot) {
|
|
85
|
+
if (!hqRoot)
|
|
86
|
+
return undefined;
|
|
87
|
+
try {
|
|
88
|
+
const cfg = JSON.parse(fs.readFileSync(path.join(hqRoot, ".hq", "config.json"), "utf8"));
|
|
89
|
+
for (const value of [cfg?.activeCompany, cfg?.companySlug]) {
|
|
90
|
+
if (typeof value === "string" && value.trim())
|
|
91
|
+
return value.trim();
|
|
92
|
+
}
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
/** Map a slug, `cmp_*` uid or `prs_*` uid to the vault it names. */
|
|
100
|
+
export function resolveVaultRef(ref, hqRoot) {
|
|
101
|
+
const trimmed = ref.trim();
|
|
102
|
+
if (PERSON_UID.test(trimmed))
|
|
103
|
+
return { kind: "personal" };
|
|
104
|
+
if (!COMPANY_UID.test(trimmed))
|
|
105
|
+
return { kind: "company", slug: trimmed };
|
|
106
|
+
const manifest = hqRoot ? readCompaniesManifestMap(hqRoot) : null;
|
|
107
|
+
for (const [slug, entry] of Object.entries(manifest ?? {})) {
|
|
108
|
+
const uid = entry?.cloud_uid;
|
|
109
|
+
if (typeof uid === "string" && uid.toLowerCase() === trimmed.toLowerCase()) {
|
|
110
|
+
return { kind: "company", slug };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
return { kind: "unknown", ref: trimmed };
|
|
114
|
+
}
|
|
115
|
+
function refusalForVault(command, vault, switches) {
|
|
116
|
+
switch (vault.kind) {
|
|
117
|
+
case "personal":
|
|
118
|
+
return switches.personalOff
|
|
119
|
+
? { message: vaultOffMessage(command, "your personal vault"), vault: "personal" }
|
|
120
|
+
: null;
|
|
121
|
+
case "company":
|
|
122
|
+
return isCompanyVaultOff(switches, vault.slug)
|
|
123
|
+
? { message: vaultOffMessage(command, vault.slug), vault: vault.slug }
|
|
124
|
+
: null;
|
|
125
|
+
case "unknown":
|
|
126
|
+
// Fail closed: with any company vault off, an unmapped uid may be it.
|
|
127
|
+
return switches.offCompanies.size > 0
|
|
128
|
+
? { message: unresolvedVaultMessage(command, vault.ref), vault: vault.ref }
|
|
129
|
+
: null;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* The refusal for `selection`, or null when it may run. `--all` never
|
|
134
|
+
* refuses for one vault: it skips the off vaults (see {@link vaultSkips}).
|
|
135
|
+
* With `--creds-from-stdin` and no `--company`, the target is unknown until
|
|
136
|
+
* stdin is parsed; {@link refusalForEntity} checks it then.
|
|
137
|
+
*/
|
|
138
|
+
export function refusalFor(command, selection, switches) {
|
|
139
|
+
if (switches.allOff)
|
|
140
|
+
return { message: cloudOffMessage(command), vault: "all" };
|
|
141
|
+
if (selection.all)
|
|
142
|
+
return null;
|
|
143
|
+
if (selection.personal === true) {
|
|
144
|
+
return refusalForVault(command, { kind: "personal" }, switches);
|
|
145
|
+
}
|
|
146
|
+
const ref = selection.company ?? (selection.credsFromStdin ? undefined : readActiveCompanyRef(selection.hqRoot));
|
|
147
|
+
if (!ref)
|
|
148
|
+
return null;
|
|
149
|
+
return refusalForVault(command, resolveVaultRef(ref, selection.hqRoot), switches);
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* The refusal for a pre-vended EntityContext read from stdin. A `prs_*` uid
|
|
153
|
+
* is the personal vault; otherwise the slug (or the uid, through the
|
|
154
|
+
* manifest) names the company.
|
|
155
|
+
*/
|
|
156
|
+
export function refusalForEntity(command, entity, hqRoot, switches) {
|
|
157
|
+
if (switches.allOff)
|
|
158
|
+
return { message: cloudOffMessage(command), vault: "all" };
|
|
159
|
+
const uid = typeof entity.uid === "string" ? entity.uid : "";
|
|
160
|
+
const slug = typeof entity.slug === "string" ? entity.slug : "";
|
|
161
|
+
if (PERSON_UID.test(uid))
|
|
162
|
+
return refusalForVault(command, { kind: "personal" }, switches);
|
|
163
|
+
if (slug.trim())
|
|
164
|
+
return refusalForVault(command, { kind: "company", slug }, switches);
|
|
165
|
+
if (uid.trim())
|
|
166
|
+
return refusalForVault(command, resolveVaultRef(uid, hqRoot), switches);
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/** The vaults an `--all` run leaves out. */
|
|
170
|
+
export function vaultSkips(selection, switches) {
|
|
171
|
+
return {
|
|
172
|
+
skipPersonal: selection.personal === false || switches.personalOff,
|
|
173
|
+
skipCompanies: switches.offCompanies,
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
/** Thrown after stdin is parsed when its target vault is off. */
|
|
177
|
+
export class VaultSyncOffError extends Error {
|
|
178
|
+
refusal;
|
|
179
|
+
constructor(refusal) {
|
|
180
|
+
super(refusal.message);
|
|
181
|
+
this.refusal = refusal;
|
|
182
|
+
this.name = "VaultSyncOffError";
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
/** Write a refusal: plain text, or one JSON Lines event in `--json` mode. */
|
|
186
|
+
export function writeVaultRefusal(refusal, deps = {}) {
|
|
187
|
+
const writeErr = deps.writeErr ?? ((text) => process.stderr.write(text));
|
|
188
|
+
if (deps.json) {
|
|
189
|
+
writeErr(`${JSON.stringify({
|
|
190
|
+
type: "skipped",
|
|
191
|
+
reason: "sync_off",
|
|
192
|
+
vault: refusal.vault,
|
|
193
|
+
exitCode: CLOUD_OFF_EXIT_CODE,
|
|
194
|
+
message: refusal.message,
|
|
195
|
+
})}\n`);
|
|
196
|
+
}
|
|
197
|
+
else {
|
|
198
|
+
writeErr(`${refusal.message}\n`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
function warnIfUnreadable(switches, deps) {
|
|
202
|
+
if (!switches.unreadable)
|
|
203
|
+
return;
|
|
204
|
+
const writeErr = deps.writeErr ?? ((text) => process.stderr.write(text));
|
|
205
|
+
const message = unreadableSwitchesWarning(switches.unreadable);
|
|
206
|
+
writeErr(deps.json ? `${JSON.stringify({ type: "warning", message })}\n` : `${message}\n`);
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Print the refusal and exit {@link CLOUD_OFF_EXIT_CODE} when `selection`
|
|
210
|
+
* targets an off vault (or every vault is off); otherwise return what an
|
|
211
|
+
* `--all` run must skip. Runs before any login, lock or network work, and
|
|
212
|
+
* outside the client-health report, so a refusal is not counted as a failed
|
|
213
|
+
* sync. Warns when the settings file is unreadable.
|
|
214
|
+
*/
|
|
215
|
+
export function refuseSyncForOffVaults(command, selection, deps = {}) {
|
|
216
|
+
const switches = readVaultSyncSwitches(deps.home);
|
|
217
|
+
warnIfUnreadable(switches, deps);
|
|
218
|
+
const refusal = refusalFor(command, selection, switches);
|
|
219
|
+
if (refusal) {
|
|
220
|
+
writeVaultRefusal(refusal, deps);
|
|
221
|
+
(deps.exit ?? ((code) => process.exit(code)))(CLOUD_OFF_EXIT_CODE);
|
|
222
|
+
}
|
|
223
|
+
return vaultSkips(selection, switches);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* `--creds-from-stdin` check: throw {@link VaultSyncOffError} when the parsed
|
|
227
|
+
* EntityContext targets an off vault. The caller writes it and exits 3.
|
|
228
|
+
*/
|
|
229
|
+
export function assertEntityVaultSyncAllowed(command, entity, hqRoot, home) {
|
|
230
|
+
const refusal = refusalForEntity(command, entity, hqRoot, readVaultSyncSwitches(home));
|
|
231
|
+
if (refusal)
|
|
232
|
+
throw new VaultSyncOffError(refusal);
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=cloud-off-gate.js.map
|
package/dist/commands/cloud.d.ts
CHANGED
|
@@ -196,6 +196,11 @@ export interface PullAllOptions {
|
|
|
196
196
|
* personal vault from the run without touching the rest of the plan.
|
|
197
197
|
*/
|
|
198
198
|
skipPersonal?: boolean;
|
|
199
|
+
/**
|
|
200
|
+
* Lowercased company slugs (or uids) to leave out: vaults whose sync is off
|
|
201
|
+
* on this computer (desktop Settings > Sync, US-016).
|
|
202
|
+
*/
|
|
203
|
+
skipCompanies?: ReadonlySet<string>;
|
|
199
204
|
/**
|
|
200
205
|
* Forward `--force-scope-shrink` to each company leg: when a scope shrink
|
|
201
206
|
* would un-track locally-modified files, proceed anyway (dirty files KEPT on
|
|
@@ -263,6 +268,8 @@ export interface PushAllOptions {
|
|
|
263
268
|
* `hq sync push --all --no-personal`.
|
|
264
269
|
*/
|
|
265
270
|
skipPersonal?: boolean;
|
|
271
|
+
/** Lowercased company slugs (or uids) to leave out (US-016). */
|
|
272
|
+
skipCompanies?: ReadonlySet<string>;
|
|
266
273
|
}
|
|
267
274
|
export interface PushAllRow {
|
|
268
275
|
slug: string;
|
package/dist/commands/cloud.js
CHANGED
|
@@ -28,6 +28,7 @@ import { maybeShowCliInviteNudge } from "../lib/cli-invite-nudge.js";
|
|
|
28
28
|
import { createPlanLimitRefusalTracker, describeRefusedCount, describeRefusedFiles, mergePlanLimitRefusals, renderPlanLimitRefusalLines, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
|
|
29
29
|
import { formatPlanGateError, planGateErrorFromVaultError, } from "../utils/plan-gate-error.js";
|
|
30
30
|
import { personalTargetedPushPathsEnabled } from "./personal-targeted-push-flag.js";
|
|
31
|
+
import { CLOUD_OFF_EXIT_CODE, VaultSyncOffError, assertEntityVaultSyncAllowed, refuseSyncForOffVaults, writeVaultRefusal, } from "./cloud-off-gate.js";
|
|
31
32
|
import { resolveExplicitPersonalPushPaths } from "./personal-targeted-push-paths.js";
|
|
32
33
|
/**
|
|
33
34
|
* Terminal-failure signal for the sync fanout runners. The runners
|
|
@@ -295,6 +296,12 @@ function narrowNudgeExceedsSize(hqRoot, ref) {
|
|
|
295
296
|
const threshold = resolveNarrowHintMinBytes({ hqRoot });
|
|
296
297
|
return companyFolderExceedsThreshold(companyDir, threshold);
|
|
297
298
|
}
|
|
299
|
+
/** True when a membership's vault is switched off on this computer (US-016). */
|
|
300
|
+
function isSkippedCompany(skip, slug, companyUid) {
|
|
301
|
+
if (!skip || skip.size === 0)
|
|
302
|
+
return false;
|
|
303
|
+
return skip.has(slug.toLowerCase()) || skip.has(companyUid.toLowerCase());
|
|
304
|
+
}
|
|
298
305
|
export async function pullAll(options, deps) {
|
|
299
306
|
const memberships = await deps.vaultClient.listMyMemberships();
|
|
300
307
|
const persons = await deps.vaultClient.listPersonEntities();
|
|
@@ -311,6 +318,8 @@ export async function pullAll(options, deps) {
|
|
|
311
318
|
catch {
|
|
312
319
|
// Best-effort — keep UID as the row label rather than aborting the run.
|
|
313
320
|
}
|
|
321
|
+
if (isSkippedCompany(options.skipCompanies, slug, m.companyUid))
|
|
322
|
+
continue;
|
|
314
323
|
plan.push({
|
|
315
324
|
slug,
|
|
316
325
|
companyUid: m.companyUid,
|
|
@@ -464,6 +473,8 @@ export async function pushAll(options, deps) {
|
|
|
464
473
|
catch {
|
|
465
474
|
// Best-effort — keep UID as the row label.
|
|
466
475
|
}
|
|
476
|
+
if (isSkippedCompany(options.skipCompanies, slug, m.companyUid))
|
|
477
|
+
continue;
|
|
467
478
|
plan.push({
|
|
468
479
|
slug,
|
|
469
480
|
companyUid: m.companyUid,
|
|
@@ -804,6 +815,13 @@ export function registerCloudCommands(program) {
|
|
|
804
815
|
"a toggle, and so CLI users can opt out one-off. Ignored outside " +
|
|
805
816
|
"`--all`.")
|
|
806
817
|
.action(async (paths, options) => {
|
|
818
|
+
const skips = refuseSyncForOffVaults("push", {
|
|
819
|
+
all: options.all,
|
|
820
|
+
personal: options.personal,
|
|
821
|
+
company: options.company,
|
|
822
|
+
hqRoot: options.hqRoot,
|
|
823
|
+
credsFromStdin: options.credsFromStdin,
|
|
824
|
+
}, { json: options.json === true });
|
|
807
825
|
try {
|
|
808
826
|
configureSyncLockTimeout(options.lockTimeout);
|
|
809
827
|
assertSingleSelector(options, "push");
|
|
@@ -833,7 +851,7 @@ export function registerCloudCommands(program) {
|
|
|
833
851
|
// `--all` the flag has no effect (logged above as part of the
|
|
834
852
|
// option's help text).
|
|
835
853
|
await withSyncHealthReport(async () => {
|
|
836
|
-
const refused = await runPushAll(options.hqRoot, options.message, options.onConflict,
|
|
854
|
+
const refused = await runPushAll(options.hqRoot, options.message, options.onConflict, skips.skipPersonal, skips.skipCompanies);
|
|
837
855
|
// A plan-limit refusal is not a sync failure, but the push did
|
|
838
856
|
// not upload everything it was asked to: exit non-zero.
|
|
839
857
|
if (refused)
|
|
@@ -891,6 +909,9 @@ export function registerCloudCommands(program) {
|
|
|
891
909
|
catch (e) {
|
|
892
910
|
throw new Error(`--creds-from-stdin: failed to parse stdin as JSON: ${e instanceof Error ? e.message : String(e)}`);
|
|
893
911
|
}
|
|
912
|
+
// Sync switches (US-016): with no --company the target is only
|
|
913
|
+
// known now. Check it before any login or transfer.
|
|
914
|
+
assertEntityVaultSyncAllowed("push", entityContext, options.hqRoot);
|
|
894
915
|
// Upload creds are pre-vended in entityContext, but prefix filtering
|
|
895
916
|
// still needs vault-service membership/grant resolution.
|
|
896
917
|
const accessToken = await ensureSyncCognitoToken();
|
|
@@ -1085,6 +1106,11 @@ export function registerCloudCommands(program) {
|
|
|
1085
1106
|
}
|
|
1086
1107
|
}
|
|
1087
1108
|
catch (err) {
|
|
1109
|
+
if (err instanceof VaultSyncOffError) {
|
|
1110
|
+
// Not a sync failure: the target vault is switched off.
|
|
1111
|
+
writeVaultRefusal(err.refusal, { json: jsonMode });
|
|
1112
|
+
process.exit(CLOUD_OFF_EXIT_CODE);
|
|
1113
|
+
}
|
|
1088
1114
|
if (syncHealth)
|
|
1089
1115
|
await syncHealth.failed();
|
|
1090
1116
|
const message = formatSyncFailure(err);
|
|
@@ -1128,6 +1154,12 @@ export function registerCloudCommands(program) {
|
|
|
1128
1154
|
"disk and only un-tracked from sync; clean out-of-scope files are " +
|
|
1129
1155
|
"quarantined under .hq/scope-quarantine/ (recoverable).")
|
|
1130
1156
|
.action(async (options) => {
|
|
1157
|
+
const skips = refuseSyncForOffVaults("pull", {
|
|
1158
|
+
all: options.all,
|
|
1159
|
+
personal: options.personal,
|
|
1160
|
+
company: options.company,
|
|
1161
|
+
hqRoot: options.hqRoot,
|
|
1162
|
+
});
|
|
1131
1163
|
try {
|
|
1132
1164
|
configureSyncLockTimeout(options.lockTimeout);
|
|
1133
1165
|
assertSingleSelector(options, "pull");
|
|
@@ -1140,7 +1172,7 @@ export function registerCloudCommands(program) {
|
|
|
1140
1172
|
// `options.personal === false` is Commander's auto-negation of
|
|
1141
1173
|
// `--personal`; in `--all` mode that means "drop the personal
|
|
1142
1174
|
// leg from the fanout" (see `--no-personal` option above).
|
|
1143
|
-
await withSyncHealthReport(() => runPullAll(options.hqRoot, options.onConflict, options.modeAll === true,
|
|
1175
|
+
await withSyncHealthReport(() => runPullAll(options.hqRoot, options.onConflict, options.modeAll === true, skips.skipPersonal, options.forceScopeShrink === true, skips.skipCompanies));
|
|
1144
1176
|
return;
|
|
1145
1177
|
}
|
|
1146
1178
|
if (options.personal) {
|
|
@@ -1352,6 +1384,12 @@ export function registerCloudCommands(program) {
|
|
|
1352
1384
|
"anyway. Dirty files are KEPT on disk (only un-tracked); clean " +
|
|
1353
1385
|
"out-of-scope files are quarantined under .hq/scope-quarantine/.")
|
|
1354
1386
|
.action(async (options) => {
|
|
1387
|
+
const skips = refuseSyncForOffVaults("now", {
|
|
1388
|
+
all: options.all,
|
|
1389
|
+
personal: options.personal,
|
|
1390
|
+
company: options.company,
|
|
1391
|
+
hqRoot: options.hqRoot,
|
|
1392
|
+
});
|
|
1355
1393
|
try {
|
|
1356
1394
|
configureSyncLockTimeout(options.lockTimeout);
|
|
1357
1395
|
assertSingleSelector(options, "now");
|
|
@@ -1360,7 +1398,7 @@ export function registerCloudCommands(program) {
|
|
|
1360
1398
|
// of `--personal`; in `--all` mode that means "drop the
|
|
1361
1399
|
// personal leg from both legs of the bidirectional fanout"
|
|
1362
1400
|
// (see `--no-personal` option above).
|
|
1363
|
-
await withSyncHealthReport(() => runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true,
|
|
1401
|
+
await withSyncHealthReport(() => runNowAll(options.hqRoot, options.message, options.onConflict, options.modeAll === true, skips.skipPersonal, options.forceScopeShrink === true, skips.skipCompanies));
|
|
1364
1402
|
return;
|
|
1365
1403
|
}
|
|
1366
1404
|
await withSyncHealthReport(() => runNowSingle(options.hqRoot, options.company, options.personal === true, options.message, options.onConflict, options.modeAll === true, options.forceScopeShrink === true));
|
|
@@ -1446,13 +1484,19 @@ export async function runSyncDoctorReconcileConflicts(hqRoot, yes) {
|
|
|
1446
1484
|
console.log(chalk.dim(" Re-run with --yes to apply the plan above."));
|
|
1447
1485
|
}
|
|
1448
1486
|
}
|
|
1449
|
-
|
|
1487
|
+
/** Banner lines for the vaults an `--all` run leaves out. */
|
|
1488
|
+
function logVaultSkips(skipPersonal, skipCompanies) {
|
|
1489
|
+
if (skipPersonal)
|
|
1490
|
+
console.log(` Personal: skipped (sync is off for it, or --no-personal)`);
|
|
1491
|
+
if (skipCompanies && skipCompanies.size > 0) {
|
|
1492
|
+
console.log(` Skipped: ${[...skipCompanies].sort().join(", ")} (sync is off on this computer)`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink, skipCompanies) {
|
|
1450
1496
|
console.log(chalk.bold("\nHQ Sync — Pull (all)"));
|
|
1451
1497
|
console.log(` HQ root: ${hqRoot}`);
|
|
1452
1498
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1453
|
-
|
|
1454
|
-
console.log(` Personal: skipped (--no-personal)`);
|
|
1455
|
-
}
|
|
1499
|
+
logVaultSkips(skipPersonal, skipCompanies);
|
|
1456
1500
|
console.log("");
|
|
1457
1501
|
let result;
|
|
1458
1502
|
try {
|
|
@@ -1480,6 +1524,7 @@ async function runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, for
|
|
|
1480
1524
|
...(modeAllOverride ? { modeAllOverride: true } : {}),
|
|
1481
1525
|
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1482
1526
|
...(forceScopeShrink ? { forceScopeShrink: true } : {}),
|
|
1527
|
+
...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
|
|
1483
1528
|
}, {
|
|
1484
1529
|
vaultClient: adapter,
|
|
1485
1530
|
// DEV-1768: resolve each company's REAL pull scope (mode + prefixSet)
|
|
@@ -1587,13 +1632,11 @@ export async function runPullPersonal(hqRoot, onConflict) {
|
|
|
1587
1632
|
* limit refused across every target (null when none were), so the caller can
|
|
1588
1633
|
* refuse to report success; hard failures still throw {@link SyncExitError}.
|
|
1589
1634
|
*/
|
|
1590
|
-
async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
1635
|
+
async function runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompanies) {
|
|
1591
1636
|
console.log(chalk.bold("\nHQ Sync — Push (all)"));
|
|
1592
1637
|
console.log(` HQ root: ${hqRoot}`);
|
|
1593
1638
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
1594
|
-
|
|
1595
|
-
console.log(` Personal: skipped (--no-personal)`);
|
|
1596
|
-
}
|
|
1639
|
+
logVaultSkips(skipPersonal, skipCompanies);
|
|
1597
1640
|
console.log("");
|
|
1598
1641
|
let result;
|
|
1599
1642
|
try {
|
|
@@ -1618,6 +1661,7 @@ async function runPushAll(hqRoot, message, onConflict, skipPersonal) {
|
|
|
1618
1661
|
...(onConflict ? { onConflict } : {}),
|
|
1619
1662
|
...(message ? { message } : {}),
|
|
1620
1663
|
...(skipPersonal ? { skipPersonal: true } : {}),
|
|
1664
|
+
...(skipCompanies && skipCompanies.size > 0 ? { skipCompanies } : {}),
|
|
1621
1665
|
}, {
|
|
1622
1666
|
vaultClient: adapter,
|
|
1623
1667
|
resolveScope: (companyUid, slug) => resolvePullScope(realClient, companyUid, slug, hqRoot),
|
|
@@ -1998,24 +2042,22 @@ async function runNowSingle(hqRoot, company, personal, message, onConflict, mode
|
|
|
1998
2042
|
throw new SyncExitError(1);
|
|
1999
2043
|
}
|
|
2000
2044
|
}
|
|
2001
|
-
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal, forceScopeShrink) {
|
|
2045
|
+
async function runNowAll(hqRoot, message, onConflict, modeAllOverride, skipPersonal, forceScopeShrink, skipCompanies) {
|
|
2002
2046
|
console.log(chalk.bold("\nHQ Sync — Now (all)"));
|
|
2003
2047
|
console.log(` HQ root: ${hqRoot}`);
|
|
2004
2048
|
console.log(` Strategy: ${onConflict ?? "(interactive)"}`);
|
|
2005
|
-
|
|
2006
|
-
console.log(` Personal: skipped (--no-personal)`);
|
|
2007
|
-
}
|
|
2049
|
+
logVaultSkips(skipPersonal, skipCompanies);
|
|
2008
2050
|
console.log("");
|
|
2009
2051
|
// Push first (matches runner), then pull. Re-uses the per-leg orchestrators
|
|
2010
2052
|
// so the per-target rendering, error isolation, and exit codes are
|
|
2011
2053
|
// identical to running `push --all` then `pull --all` back-to-back.
|
|
2012
2054
|
console.log(chalk.dim("→ push --all"));
|
|
2013
|
-
const refused = await runPushAll(hqRoot, message, onConflict, skipPersonal);
|
|
2055
|
+
const refused = await runPushAll(hqRoot, message, onConflict, skipPersonal, skipCompanies);
|
|
2014
2056
|
console.log(chalk.dim("\n→ pull --all"));
|
|
2015
2057
|
// US-011: forward --mode-all so the strict refusal applies to the
|
|
2016
2058
|
// pull leg (push doesn't need a narrow-hint — the narrow ritual is
|
|
2017
2059
|
// pull-side).
|
|
2018
|
-
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink);
|
|
2060
|
+
await runPullAll(hqRoot, onConflict, modeAllOverride, skipPersonal, forceScopeShrink, skipCompanies);
|
|
2019
2061
|
if (refused) {
|
|
2020
2062
|
// A refused push does not stop the pull, but the run must not end looking
|
|
2021
2063
|
// like a clean sync. Its per-target details were printed above.
|
|
@@ -13,6 +13,7 @@ import { defaultBackgroundDependencies, runBackgroundLauncher, } from "../lib/se
|
|
|
13
13
|
import { resolveLiveRoot } from "../utils/hq-roots.js";
|
|
14
14
|
import { runBundledScript } from "../utils/run-bundled-script.js";
|
|
15
15
|
import { describeCoreCommand } from "./core-command-catalog.js";
|
|
16
|
+
import { CLOUD_OFF_EXIT_CODE } from "./cloud-off-gate.js";
|
|
16
17
|
import { shellDisplayPath } from "../lib/core-utils/shell-display-path.js";
|
|
17
18
|
import { fingerprintTrailerArgs, offlineFingerprint } from "../lib/fingerprint/offline.js";
|
|
18
19
|
import { compactJson, isRecord, jqTruthy, prettyJson, runHandoffOpenSteps, shellDate, } from "./core-handoff-open-steps.js";
|
|
@@ -1057,7 +1058,11 @@ export function runHandoffPostWorker(args, context) {
|
|
|
1057
1058
|
stdout: typeof pushed.stdout === "string" ? pushed.stdout : "",
|
|
1058
1059
|
stderr: typeof pushed.stderr === "string" ? pushed.stderr : "",
|
|
1059
1060
|
});
|
|
1060
|
-
log(pushed.status === 0
|
|
1061
|
+
log(pushed.status === 0
|
|
1062
|
+
? `workspace-sync: pushed companies/${company}/workspace`
|
|
1063
|
+
: pushed.status === CLOUD_OFF_EXIT_CODE
|
|
1064
|
+
? `workspace-sync: skipped (sync is off) companies/${company}/workspace`
|
|
1065
|
+
: `workspace-sync: failed companies/${company}/workspace`);
|
|
1061
1066
|
}
|
|
1062
1067
|
}
|
|
1063
1068
|
else if (threadFile && fs.existsSync(threadFile))
|
|
@@ -658,9 +658,13 @@ export function registerMeetingsCommand(program) {
|
|
|
658
658
|
try {
|
|
659
659
|
const token = await ensureCognitoToken();
|
|
660
660
|
const companySlug = meetings.opts().company;
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
661
|
+
// Always name a company. With no companyId the backend files the bot
|
|
662
|
+
// under Personal, which can never record, so an entitled user would
|
|
663
|
+
// get the HQ Workforce upgrade prompt. Without --company this uses the
|
|
664
|
+
// caller's only active company, or asks them to pick one.
|
|
665
|
+
const query = {
|
|
666
|
+
companyId: await getCompanyUid(token, companySlug),
|
|
667
|
+
};
|
|
664
668
|
const res = await vaultApiFetch({
|
|
665
669
|
token,
|
|
666
670
|
method: "POST",
|
|
@@ -13,6 +13,7 @@ import type { MachineType } from "./machine-type.js";
|
|
|
13
13
|
import type { SyncOwner } from "./config.js";
|
|
14
14
|
import type { RemoteControlPoolsConfig } from "./remote-control-pools.js";
|
|
15
15
|
import { type SyncRunnerProcess } from "./sync-runner.js";
|
|
16
|
+
import { type VaultSyncSwitches } from "../vault-sync-switches.js";
|
|
16
17
|
export declare const SERVICE_NAMES: readonly ["sync", "mesh", "heartbeat", "inbox", "bots", "remote-control", "codex-remote-control", "session-host", "lanes-reconcile", "search-index", "jobs-reconcile", "updater", "housekeeping"];
|
|
17
18
|
export type ServiceName = (typeof SERVICE_NAMES)[number];
|
|
18
19
|
export declare function isServiceName(value: unknown): value is ServiceName;
|
|
@@ -103,6 +104,12 @@ export interface PlanContext {
|
|
|
103
104
|
instantSyncEnabled?: boolean;
|
|
104
105
|
/** Keep the local sync runner stopped until the operator resumes it. */
|
|
105
106
|
syncPaused: boolean;
|
|
107
|
+
/**
|
|
108
|
+
* The desktop app's Settings > Sync switches on this computer (US-016).
|
|
109
|
+
* Off vaults are skipped by the runner; all off keeps the unit stopped.
|
|
110
|
+
* Absent means every vault is on.
|
|
111
|
+
*/
|
|
112
|
+
vaultSync?: VaultSyncSwitches;
|
|
106
113
|
/** Persisted/effective owner selected for this machine. */
|
|
107
114
|
syncOwner: SyncOwner;
|
|
108
115
|
/** A legacy desktop app or sync unit is still managing sync. */
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* them instead of silently dropping them.
|
|
11
11
|
*/
|
|
12
12
|
import { SYNC_RUNNER_OWNER, syncRunnerUnleasedBlockReason } from "./sync-runner.js";
|
|
13
|
+
import { vaultSkipEnv } from "../vault-sync-switches.js";
|
|
13
14
|
export const SERVICE_NAMES = [
|
|
14
15
|
"sync",
|
|
15
16
|
"mesh",
|
|
@@ -142,7 +143,11 @@ function planService(service, ctx) {
|
|
|
142
143
|
SYNC_RUNNER_OWNER,
|
|
143
144
|
],
|
|
144
145
|
},
|
|
145
|
-
env: {
|
|
146
|
+
env: {
|
|
147
|
+
...ctx.legacySyncEnv,
|
|
148
|
+
...(ctx.vaultSync ? vaultSkipEnv(ctx.vaultSync) : {}),
|
|
149
|
+
HQ_SYNC_PASS_REQUEST_DIR: ctx.syncPassRequestsDir,
|
|
150
|
+
},
|
|
146
151
|
heartbeatTimeoutMs: ctx.syncHeartbeatTimeoutMs,
|
|
147
152
|
});
|
|
148
153
|
let ownerBlock;
|
|
@@ -160,6 +165,10 @@ function planService(service, ctx) {
|
|
|
160
165
|
}
|
|
161
166
|
unit.blocked = firstBlock([
|
|
162
167
|
[ctx.syncPaused, "sync is paused (run `hq daemon sync resume`)"],
|
|
168
|
+
[
|
|
169
|
+
ctx.vaultSync?.allOff === true,
|
|
170
|
+
"cloud sync is off for every vault on this computer (turn it back on in HQ Settings > Sync)",
|
|
171
|
+
],
|
|
163
172
|
[!ctx.hqRoot, NO_HQ_ROOT],
|
|
164
173
|
[!ctx.signedIn, NOT_SIGNED_IN],
|
|
165
174
|
[!!ownerBlock, ownerBlock ?? ""],
|
package/dist/lib/daemon/run.js
CHANGED
|
@@ -32,6 +32,7 @@ import { DAEMON_CHILD_BOOTSTRAP_HANDOFF_WATCH_FLAG, DAEMON_LANES_RECONCILE_FILE_
|
|
|
32
32
|
import { createLanesReconcileWatcher } from "./lanes-reconcile-watcher.js";
|
|
33
33
|
import { findLegacyUnits, legacyMeshUnitsToRetire, legacyOwnerLookup } from "./legacy.js";
|
|
34
34
|
import { readLegacySyncEnv } from "./legacy-sync-env.js";
|
|
35
|
+
import { readVaultSyncSwitches, unreadableSwitchesWarning } from "../vault-sync-switches.js";
|
|
35
36
|
import { acquireDaemonLock, isProcessAlive, processCommandLine, releaseDaemonLock } from "./lock.js";
|
|
36
37
|
import { retireLegacyMeshUnits } from "./migrate.js";
|
|
37
38
|
import { daemonDir as meshDaemonDir, daemonPidPath as meshDaemonPidPath } from "../mesh/live/daemon/paths.js";
|
|
@@ -581,6 +582,8 @@ export async function runDaemon(opts) {
|
|
|
581
582
|
finishSuccession(succession, tracker.records(), log);
|
|
582
583
|
/** Service processes this daemon spawned, so a handoff can let go of them. */
|
|
583
584
|
const spawned = new Set();
|
|
585
|
+
/** Last unreadable-settings warning logged, so it is logged once per change. */
|
|
586
|
+
let lastVaultSyncWarning;
|
|
584
587
|
const updatable = cliSelfUpdatable();
|
|
585
588
|
const hostName = os.hostname().split(".")[0] || "hq-host";
|
|
586
589
|
let legacyCache = null;
|
|
@@ -949,6 +952,16 @@ export async function runDaemon(opts) {
|
|
|
949
952
|
signedIn: isSignedIn,
|
|
950
953
|
kitConfigured: fs.existsSync(agentKitPaths(home, env).kitConfigPath),
|
|
951
954
|
syncPaused: config.syncPaused === true,
|
|
955
|
+
vaultSync: (() => {
|
|
956
|
+
const switches = readVaultSyncSwitches(home);
|
|
957
|
+
// Log once per change, not every reconcile.
|
|
958
|
+
if (switches.unreadable !== lastVaultSyncWarning) {
|
|
959
|
+
if (switches.unreadable)
|
|
960
|
+
log("warn", unreadableSwitchesWarning(switches.unreadable));
|
|
961
|
+
lastVaultSyncWarning = switches.unreadable;
|
|
962
|
+
}
|
|
963
|
+
return switches;
|
|
964
|
+
})(),
|
|
952
965
|
syncOwner,
|
|
953
966
|
desktopAppManagesSync: appManagesSync,
|
|
954
967
|
syncRunnerProcesses,
|
package/dist/lib/lanes/links.js
CHANGED
|
@@ -267,28 +267,44 @@ function hookMessage(root, pointer) {
|
|
|
267
267
|
throw new LaneLinkError("link_pointer_invalid", "Child link pointer has no child name");
|
|
268
268
|
return { inbox: queueDir(root, pointer.link, pointer.name), role: "child", child: pointer.name };
|
|
269
269
|
}
|
|
270
|
+
// Same markers as core's conduct-lanes-link-deliver.sh. A parent session id
|
|
271
|
+
// alone is not one: resumed, forked, or launcher-spawned Grok roots carry it,
|
|
272
|
+
// and Grok marks its real children with subagent_type.
|
|
273
|
+
function isChildHookPayload(payload) {
|
|
274
|
+
const nonEmpty = (...keys) => keys.some((key) => typeof payload[key] === "string" && payload[key].length > 0);
|
|
275
|
+
if (["agent_id", "agentId"].some((key) => payload[key] !== undefined && payload[key] !== null && payload[key] !== ""))
|
|
276
|
+
return true;
|
|
277
|
+
if (nonEmpty("parent_thread_id", "parentThreadId", "agent_transcript_path", "agentTranscriptPath", "subagent_type", "subagentType"))
|
|
278
|
+
return true;
|
|
279
|
+
const transcript = payload.transcript_path ?? payload.transcriptPath;
|
|
280
|
+
return typeof transcript === "string" && /[/\\]subagents[/\\][^/\\]+\.jsonl$/.test(transcript);
|
|
281
|
+
}
|
|
270
282
|
export async function runLaneLink(subcommand, options) {
|
|
271
283
|
const now = options.now ?? (() => new Date());
|
|
272
284
|
const env = options.env ?? process.env;
|
|
273
285
|
const root = options.hqRoot;
|
|
274
286
|
let sid = "";
|
|
287
|
+
let childContext = false;
|
|
275
288
|
if (subcommand === "_deliver") {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
289
|
+
let payload = {};
|
|
290
|
+
try {
|
|
291
|
+
const parsed = JSON.parse(options.input ?? "{}");
|
|
292
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
293
|
+
payload = parsed;
|
|
294
|
+
}
|
|
295
|
+
catch { /* malformed hooks are inert */ }
|
|
296
|
+
const value = payload.session_id ?? payload.sessionId;
|
|
297
|
+
sid = typeof value === "string" && safeId(value) ? value : "";
|
|
298
|
+
childContext = isChildHookPayload(payload);
|
|
285
299
|
}
|
|
286
300
|
const requireSid = () => sid || (sid = resolveLinkSessionId(root, options.sessionId, env));
|
|
287
301
|
const links = rootDir(root);
|
|
288
302
|
if (subcommand === "_deliver") {
|
|
289
|
-
|
|
303
|
+
// Subagent hook events carry the parent's session_id. Delivering there would
|
|
304
|
+
// drain the root's queue into the subagent, so leave the message queued.
|
|
305
|
+
if (!sid || childContext || !["PostToolUse", "Stop"].includes(options.event ?? ""))
|
|
290
306
|
return {};
|
|
291
|
-
if (
|
|
307
|
+
if (options.event === "Stop" && env.HQ_STOP_DECISION_DELIVERABLE === "0")
|
|
292
308
|
return {};
|
|
293
309
|
const pointer = readPointer(root, sid);
|
|
294
310
|
if (!pointer)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HQ app's Settings > Sync switches on this computer (usage-only-pricing
|
|
3
|
+
* US-016), read from the desktop settings file (`menubar.json` in the HQ state
|
|
4
|
+
* directory):
|
|
5
|
+
*
|
|
6
|
+
* - `cloudPaused: true` turns sync off for every vault;
|
|
7
|
+
* - `personalSyncEnabled: false` turns the personal vault off;
|
|
8
|
+
* - `workspaceSyncEnabled: { "<slug>": false }` turns one company vault off.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-free on purpose (node:fs/os/path only): the `hq daemon` planner
|
|
11
|
+
* reads it every reconcile.
|
|
12
|
+
*
|
|
13
|
+
* A missing file, a missing key, or a value of the wrong type means on, so
|
|
14
|
+
* CLI-only installs and agent boxes keep syncing. A file that exists but
|
|
15
|
+
* cannot be read or parsed also means on, and is reported through
|
|
16
|
+
* `unreadable` so callers can warn instead of turning sync back on silently.
|
|
17
|
+
*/
|
|
18
|
+
export interface VaultSyncSwitches {
|
|
19
|
+
/** "Turn off all cloud sync" is on. */
|
|
20
|
+
allOff: boolean;
|
|
21
|
+
/** The personal vault's switch is off. */
|
|
22
|
+
personalOff: boolean;
|
|
23
|
+
/** Lowercased slugs of company vaults whose switch is off. */
|
|
24
|
+
offCompanies: ReadonlySet<string>;
|
|
25
|
+
/**
|
|
26
|
+
* Set when the settings file exists but could not be read or parsed. Every
|
|
27
|
+
* switch then reads as on; callers should warn.
|
|
28
|
+
*/
|
|
29
|
+
unreadable?: string;
|
|
30
|
+
}
|
|
31
|
+
export declare function vaultSyncSettingsPath(home?: string): string;
|
|
32
|
+
/** Read every switch. Never throws. */
|
|
33
|
+
export declare function readVaultSyncSwitches(home?: string): VaultSyncSwitches;
|
|
34
|
+
/** The warning printed when the settings file is unreadable. */
|
|
35
|
+
export declare function unreadableSwitchesWarning(reason: string): string;
|
|
36
|
+
/**
|
|
37
|
+
* Environment for an hq-cloud sync runner so it skips off vaults
|
|
38
|
+
* (`HQ_SYNC_SKIP_COMPANIES`, `HQ_SYNC_SKIP_PERSONAL`). Empty when all are on.
|
|
39
|
+
*/
|
|
40
|
+
export declare function vaultSkipEnv(switches: VaultSyncSwitches): Record<string, string>;
|
|
41
|
+
//# sourceMappingURL=vault-sync-switches.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The HQ app's Settings > Sync switches on this computer (usage-only-pricing
|
|
3
|
+
* US-016), read from the desktop settings file (`menubar.json` in the HQ state
|
|
4
|
+
* directory):
|
|
5
|
+
*
|
|
6
|
+
* - `cloudPaused: true` turns sync off for every vault;
|
|
7
|
+
* - `personalSyncEnabled: false` turns the personal vault off;
|
|
8
|
+
* - `workspaceSyncEnabled: { "<slug>": false }` turns one company vault off.
|
|
9
|
+
*
|
|
10
|
+
* Dependency-free on purpose (node:fs/os/path only): the `hq daemon` planner
|
|
11
|
+
* reads it every reconcile.
|
|
12
|
+
*
|
|
13
|
+
* A missing file, a missing key, or a value of the wrong type means on, so
|
|
14
|
+
* CLI-only installs and agent boxes keep syncing. A file that exists but
|
|
15
|
+
* cannot be read or parsed also means on, and is reported through
|
|
16
|
+
* `unreadable` so callers can warn instead of turning sync back on silently.
|
|
17
|
+
*/
|
|
18
|
+
import * as fs from "node:fs";
|
|
19
|
+
import * as os from "node:os";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
export function vaultSyncSettingsPath(home = os.homedir()) {
|
|
22
|
+
return path.join(home, ".hq", "menubar.json");
|
|
23
|
+
}
|
|
24
|
+
function allOn(unreadable) {
|
|
25
|
+
return {
|
|
26
|
+
allOff: false,
|
|
27
|
+
personalOff: false,
|
|
28
|
+
offCompanies: new Set(),
|
|
29
|
+
...(unreadable ? { unreadable } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
/** Read every switch. Never throws. */
|
|
33
|
+
export function readVaultSyncSwitches(home = os.homedir()) {
|
|
34
|
+
const file = vaultSyncSettingsPath(home);
|
|
35
|
+
let text;
|
|
36
|
+
try {
|
|
37
|
+
text = fs.readFileSync(file, "utf8");
|
|
38
|
+
}
|
|
39
|
+
catch (err) {
|
|
40
|
+
if (err?.code === "ENOENT")
|
|
41
|
+
return allOn();
|
|
42
|
+
return allOn(`could not read ${file}`);
|
|
43
|
+
}
|
|
44
|
+
let raw;
|
|
45
|
+
try {
|
|
46
|
+
raw = JSON.parse(text);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return allOn(`${file} is not valid JSON`);
|
|
50
|
+
}
|
|
51
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
52
|
+
return allOn(`${file} is not a JSON object`);
|
|
53
|
+
}
|
|
54
|
+
const prefs = raw;
|
|
55
|
+
const offCompanies = new Set();
|
|
56
|
+
const map = prefs.workspaceSyncEnabled;
|
|
57
|
+
if (typeof map === "object" && map !== null && !Array.isArray(map)) {
|
|
58
|
+
for (const [slug, enabled] of Object.entries(map)) {
|
|
59
|
+
if (enabled === false && slug.trim())
|
|
60
|
+
offCompanies.add(slug.trim().toLowerCase());
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return {
|
|
64
|
+
allOff: prefs.cloudPaused === true,
|
|
65
|
+
personalOff: prefs.personalSyncEnabled === false,
|
|
66
|
+
offCompanies,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
/** The warning printed when the settings file is unreadable. */
|
|
70
|
+
export function unreadableSwitchesWarning(reason) {
|
|
71
|
+
return `Warning: ${reason}, so HQ cannot tell which vaults have sync turned off on this computer. Every vault will sync. Fix or remove the file, or set the switches again in HQ Settings > Sync.`;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Environment for an hq-cloud sync runner so it skips off vaults
|
|
75
|
+
* (`HQ_SYNC_SKIP_COMPANIES`, `HQ_SYNC_SKIP_PERSONAL`). Empty when all are on.
|
|
76
|
+
*/
|
|
77
|
+
export function vaultSkipEnv(switches) {
|
|
78
|
+
const env = {};
|
|
79
|
+
if (switches.offCompanies.size > 0) {
|
|
80
|
+
env.HQ_SYNC_SKIP_COMPANIES = [...switches.offCompanies].sort().join(",");
|
|
81
|
+
}
|
|
82
|
+
if (switches.personalOff)
|
|
83
|
+
env.HQ_SYNC_SKIP_PERSONAL = "1";
|
|
84
|
+
return env;
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=vault-sync-switches.js.map
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@indigoai-us/hq-cli",
|
|
3
|
-
"version": "5.345.
|
|
3
|
+
"version": "5.345.151",
|
|
4
4
|
"description": "HQ by Indigo management CLI — modules and cloud sync",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"bin": {
|
|
@@ -114,11 +114,11 @@
|
|
|
114
114
|
"*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}": "eslint --cache --no-error-on-unmatched-pattern"
|
|
115
115
|
},
|
|
116
116
|
"optionalDependencies": {
|
|
117
|
-
"@indigoai-us/hq-hook-win32-x64": "5.345.
|
|
118
|
-
"@indigoai-us/hq-hook-win32-arm64": "5.345.
|
|
119
|
-
"@indigoai-us/hq-hook-darwin-x64": "5.345.
|
|
120
|
-
"@indigoai-us/hq-hook-darwin-arm64": "5.345.
|
|
121
|
-
"@indigoai-us/hq-hook-linux-x64": "5.345.
|
|
122
|
-
"@indigoai-us/hq-hook-linux-arm64": "5.345.
|
|
117
|
+
"@indigoai-us/hq-hook-win32-x64": "5.345.151",
|
|
118
|
+
"@indigoai-us/hq-hook-win32-arm64": "5.345.151",
|
|
119
|
+
"@indigoai-us/hq-hook-darwin-x64": "5.345.151",
|
|
120
|
+
"@indigoai-us/hq-hook-darwin-arm64": "5.345.151",
|
|
121
|
+
"@indigoai-us/hq-hook-linux-x64": "5.345.151",
|
|
122
|
+
"@indigoai-us/hq-hook-linux-arm64": "5.345.151"
|
|
123
123
|
}
|
|
124
124
|
}
|