@indigoai-us/hq-cli 5.345.47 → 5.345.48

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 CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.48] — 2026-10-08
6
+ - Add default-off multi-company session locks with ordered `hq-session add/remove company` updates and scope-capability enforcement.
7
+ - Give a sync runner that reports stopping 60 seconds to persist pending pushes before SIGKILL. The daemon shutdown deadline includes that grace; other services remain capped at 15 seconds.
8
+ - The desktop now uses the 60-second idle cadence when only outpost-hosted lanes need reconciliation.
9
+
5
10
  ## [5.345.47] — 2026-10-08
6
11
  - `hq files share` now shows the server's reason when a private folder conflicts with its shared folder glob, for example that the glob still has grants or that only its creator or a company admin can replace it.
7
12
  - After `hq install --global`, unauthenticated `claude` and `codex` commands show provider-specific sign-in instructions; Codex HTTP 401 transport text is kept in the HQ log.
@@ -7,6 +7,9 @@
7
7
  # core/scripts/hq-session.sh path # print path to current meta.yaml
8
8
  # core/scripts/hq-session.sh get <key> # read a key from meta.yaml
9
9
  # core/scripts/hq-session.sh set <key> <value> # set/replace a top-level key
10
+ # add company <slug> # add a company to the session lock set
11
+ # remove company <slug> # remove a company from the session lock set
12
+ # get company_slugs # print locked companies, primary first
10
13
  #
11
14
  # Reserved value:
12
15
  # set company_slug personal # bind the session to no-company (personal) work
@@ -125,7 +128,7 @@ hq_session_has_case_alias() {
125
128
  SESSION_ID_OVERRIDE=""
126
129
 
127
130
  usage() {
128
- sed -n '2,15p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
131
+ sed -n '2,18p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//'
129
132
  }
130
133
 
131
134
  current_id() {
@@ -160,6 +163,12 @@ cmd_get() {
160
163
  # Unreadable meta.yaml: awk fails and this function exits non-zero under
161
164
  # set -e. Those two are not the same case — do not default either.
162
165
  [ -n "$meta" ] && [ -f "$meta" ] || return 0
166
+ if [ "$key" = company_slugs ]; then
167
+ local lock_set primary
168
+ lock_set="$(awk '$1 == "company_slugs:" {sub(/^[^:]+:[[:space:]]*/, ""); gsub(/^"|"$/, ""); print; exit}' "$meta")"
169
+ if [ -n "$lock_set" ]; then printf '%s' "$lock_set"; else cmd_get company_slug; fi
170
+ return 0
171
+ fi
163
172
  # Absent key: print nothing. For `senior`, a record written before that
164
173
  # field existed has no line; that is a successful read of a file that does
165
174
  # not contain the key, not a failed read. §3.1 defaults `senior` to `user`
@@ -179,6 +188,90 @@ cmd_get() {
179
188
  ' "$meta"
180
189
  }
181
190
 
191
+ multi_company_enabled() {
192
+ local primary="${1:-}"
193
+ [ -n "$primary" ] || primary="$(cmd_get company_slug)"
194
+ session_scope_multi_company_enabled "$REPO_ROOT" "$primary"
195
+ }
196
+
197
+ validate_company_slug() {
198
+ local slug="${1:-}"
199
+ case "$slug" in ''|*[!a-zA-Z0-9_-]*) echo "hq-session: invalid company_slug: '$slug' (allowed characters: A-Z a-z 0-9 - _)" >&2; return 1 ;; esac
200
+ [ "$slug" != personal ] || { echo "hq-session: personal cannot be combined with a company" >&2; return 1; }
201
+ if [ -L "$REPO_ROOT/companies/$slug" ] || ! hq_session_has_exact_company_dir "$REPO_ROOT" "$slug" || hq_session_has_case_alias "$REPO_ROOT" "$slug"; then
202
+ echo "hq-session: company_slug must use the exact real directory name: '$slug'" >&2
203
+ return 1
204
+ fi
205
+ }
206
+
207
+ write_company_lock_set() {
208
+ local meta="$1" set="$2" primary="$3" tmp
209
+ tmp="$(mktemp)"
210
+ awk -v primary="$primary" -v slugs="$set" '
211
+ BEGIN { has_primary=0; has_slugs=0 }
212
+ $1 == "company_slug:" { if (!has_primary) print "company_slug: " primary; has_primary=1; next }
213
+ $1 == "company_slugs:" { if (!has_slugs) print "company_slugs: " slugs; has_slugs=1; next }
214
+ { print }
215
+ END { if (!has_primary) print "company_slug: " primary; if (!has_slugs) print "company_slugs: " slugs }
216
+ ' "$meta" >"$tmp"
217
+ mv "$tmp" "$meta"
218
+ }
219
+
220
+ emit_lock_set_context() {
221
+ local sid="$1" slugs="$2" slug
222
+ IFS=, read -r -a __session_slugs <<< "$slugs"
223
+ for slug in "${__session_slugs[@]}"; do
224
+ [ "$slug" = personal ] && continue
225
+ emit_company_hard_policies "$slug"
226
+ done
227
+ spawn_work_mesh_register "$sid" || true
228
+ }
229
+
230
+ cmd_add_company() {
231
+ local slug="${1:-}" id meta current slugs next
232
+ [ -n "$slug" ] || { echo "usage: hq-session.sh add company <slug>" >&2; exit 1; }
233
+ if ! multi_company_enabled; then
234
+ echo "hq-session: add company is disabled; hooks.multi-company-session-lock is off" >&2
235
+ exit 1
236
+ fi
237
+ validate_company_slug "$slug" || exit 1
238
+ id="$(current_id)"
239
+ [ -n "$id" ] || { echo "hq-session: no current session" >&2; exit 1; }
240
+ meta="$SESSIONS_DIR/$id/meta.yaml"
241
+ [ -f "$meta" ] || { echo "hq-session: session is not company-bound" >&2; exit 1; }
242
+ current="$(cmd_get company_slug)"
243
+ [ -n "$current" ] || { echo "hq-session: session is not company-bound" >&2; exit 1; }
244
+ slugs="$(cmd_get company_slugs)"
245
+ if [ "$current" = personal ]; then next="$slug"; else
246
+ case ",$slugs," in *",$slug,"*) next="$slugs" ;; *) next="$slugs,$slug" ;; esac
247
+ fi
248
+ write_company_lock_set "$meta" "$next" "${next%%,*}"
249
+ session_scope_mint_set "$REPO_ROOT" "$id" "$next" || { echo "hq-session: failed to mint scope-capability for session $id" >&2; exit 1; }
250
+ emit_lock_set_context "$id" "$next"
251
+ }
252
+
253
+ cmd_remove_company() {
254
+ local slug="${1:-}" id meta current slugs next out entry removed=0
255
+ [ -n "$slug" ] || { echo "usage: hq-session.sh remove company <slug>" >&2; exit 1; }
256
+ id="$(current_id)"
257
+ [ -n "$id" ] || { echo "hq-session: no current session" >&2; exit 1; }
258
+ meta="$SESSIONS_DIR/$id/meta.yaml"
259
+ current="$(cmd_get company_slug)"
260
+ slugs="$(cmd_get company_slugs)"
261
+ [ -n "$slugs" ] || { echo "hq-session: session has no company lock" >&2; exit 1; }
262
+ out=""
263
+ IFS=, read -r -a __session_slugs <<< "$slugs"
264
+ for entry in "${__session_slugs[@]}"; do
265
+ if [ "$entry" = "$slug" ]; then removed=1; else out="${out:+$out,}$entry"; fi
266
+ done
267
+ [ "$removed" = 1 ] || { echo "hq-session: company is not locked: $slug" >&2; exit 1; }
268
+ [ -n "$out" ] || { echo "hq-session: cannot remove the last company; use set company_slug" >&2; exit 1; }
269
+ next="$out"
270
+ write_company_lock_set "$meta" "$next" "${next%%,*}"
271
+ session_scope_mint_set "$REPO_ROOT" "$id" "$next" || { echo "hq-session: failed to mint scope-capability for session $id" >&2; exit 1; }
272
+ emit_lock_set_context "$id" "$next"
273
+ }
274
+
182
275
  cmd_set() {
183
276
  local key="${1:-}" value="${2:-}"
184
277
  [ -n "$key" ] && [ $# -ge 2 ] || { echo "usage: hq-session.sh set <key> <value>" >&2; exit 1; }
@@ -254,15 +347,26 @@ cmd_set() {
254
347
  local prev=""
255
348
  prev="$(cmd_get "$key" 2>/dev/null || true)"
256
349
 
350
+ # A company switch invalidates the old project/task; clear both in the same
351
+ # metadata rewrite before the company bind can register with Work Mesh.
352
+ local clear_project_task=0
353
+ if [ "$key" = "company_slug" ] && [ -n "$prev" ] && [ "$value" != "$prev" ]; then
354
+ clear_project_task=1
355
+ fi
356
+
257
357
  local tmp
258
358
  tmp="$(mktemp)"
259
- awk -v k="$key" -v v="$value" '
359
+ awk -v clear_project_task="$clear_project_task" -v k="$key" -v v="$value" '
260
360
  BEGIN { found = 0 }
361
+ clear_project_task && ($1 == "project:" || $1 == "task:") { next }
261
362
  $1 == k":" { print k": " v; found = 1; next }
262
363
  { print }
263
364
  END { if (!found) print k": " v }
264
365
  ' "$meta" > "$tmp"
265
366
  mv "$tmp" "$meta"
367
+ if [ "$key" = company_slug ]; then
368
+ write_company_lock_set "$meta" "$value" "$value"
369
+ fi
266
370
 
267
371
  # When a company is bound (or rebound) mid-session, surface that company's
268
372
  # hard-enforcement policies into this Bash tool result. SessionStart only
@@ -271,7 +375,7 @@ cmd_set() {
271
375
  # do company infra/deploy/credential work blind to hard rules. This closes
272
376
  # that gap. Emits policy text only — never secrets.
273
377
  if [ "$key" = "company_slug" ] && [ -n "$value" ] && [ "$value" != "$prev" ]; then
274
- session_scope_mint "$REPO_ROOT" "$id" "$value" || {
378
+ session_scope_mint_set "$REPO_ROOT" "$id" "$value" || {
275
379
  echo "hq-session: failed to mint scope-capability for session $id" >&2
276
380
  exit 1
277
381
  }
@@ -285,7 +389,7 @@ cmd_set() {
285
389
  # Fire-and-forget: register this company bind with the Work Mesh (US-003).
286
390
  # Fully silent (all output → the hook's own bounded log) and guarded so it
287
391
  # can neither fail cmd_set under `set -euo pipefail` nor delay its return.
288
- spawn_work_mesh_register "$id" || true
392
+ spawn_work_mesh_register "$id" "$value" || true
289
393
  fi
290
394
  fi
291
395
 
@@ -318,13 +422,13 @@ cmd_set() {
318
422
  # tests copy only hq-session.sh). Never blocks: the hook itself is fire-and-forget
319
423
  # and this backgrounds even its fast foreground path off cmd_set's return path.
320
424
  spawn_work_mesh_register() {
321
- local sid="$1"
425
+ local sid="$1" company="${2:-}"
322
426
  local hook="$REPO_ROOT/core/hooks/SessionStart/35-work-mesh-session-start.sh"
323
427
  local logf="$REPO_ROOT/workspace/logs/work-mesh-hook.log"
324
428
  [ -f "$hook" ] || return 0
325
429
  command -v jq >/dev/null 2>&1 || return 0
326
430
  local ev
327
- ev="$(jq -nc --arg sid "$sid" --arg cwd "${PWD:-}" '{session_id:$sid,cwd:$cwd}' 2>/dev/null)" || return 0
431
+ ev="$(jq -nc --arg sid "$sid" --arg company "$company" --arg cwd "${PWD:-}" '{session_id:$sid,cwd:$cwd} + (if $company == "" then {} else {company_slug:$company} end)' 2>/dev/null)" || return 0
328
432
  [ -n "$ev" ] || return 0
329
433
  mkdir -p "$(dirname "$logf")" 2>/dev/null || true
330
434
  HQ_ROOT="$REPO_ROOT" CLAUDE_CODE_SESSION_ID="$sid" \
@@ -373,7 +477,7 @@ emit_company_hard_policies() {
373
477
  /^---[ \t]*$/ { d++; next }
374
478
  d==1 && /^id:/ { s=$0; sub(/^id:[ \t]*/,"",s); gsub(/^["'"'"']|["'"'"']$/,"",s); id=s; next }
375
479
  d==1 && /^enforcement:/ { s=$0; sub(/^enforcement:[ \t]*/,"",s); gsub(/[ \t]/,"",s); enf=s; next }
376
- d==1 && /^status:[ \t]*retired/ { retired=1; next }
480
+ d==1 && /^status:[ \t]*retired/ { retired=1 ; next }
377
481
  d>=2 && /^## Rule[ \t]*$/ { rsec=1; next }
378
482
  d>=2 && rsec && /^## / { rsec=0 }
379
483
  d>=2 && rsec && !rcap && NF { line=$0; gsub(/\*\*/,"",line); if(length(line)>160)line=substr(line,1,157)"..."; rule=line; rcap=1 }
@@ -438,6 +542,8 @@ case "$sub" in
438
542
  path) cmd_path "$@" ;;
439
543
  get) cmd_get "$@" ;;
440
544
  set) cmd_set "$@" ;;
545
+ add) [ "${1:-}" = company ] || { echo "usage: hq-session.sh add company <slug>" >&2; exit 1; }; shift; cmd_add_company "$@" ;;
546
+ remove) [ "${1:-}" = company ] || { echo "usage: hq-session.sh remove company <slug>" >&2; exit 1; }; shift; cmd_remove_company "$@" ;;
441
547
  ""|-h|--help|help) usage ;;
442
548
  *) echo "hq-session: unknown subcommand '$sub'" >&2; usage >&2; exit 1 ;;
443
549
  esac
@@ -5,6 +5,24 @@
5
5
  # Sourced by core/scripts/hq-session.sh and .claude/hooks/mandatory-scope-authorizer.sh.
6
6
  # Never execute directly.
7
7
 
8
+ session_scope_multi_company_enabled() {
9
+ local root="${1:-}" primary="${2:-}" flag_script
10
+ flag_script="$root/.codex/hooks/codex-explicit-path-flag.cjs"
11
+ if ! command -v hqd_hook_flag_enabled_for >/dev/null 2>&1 && [ -f "$root/core/scripts/hqd-hook-flag-cache-lib.sh" ]; then
12
+ # shellcheck source=../hqd-hook-flag-cache-lib.sh
13
+ . "$root/core/scripts/hqd-hook-flag-cache-lib.sh"
14
+ fi
15
+ if command -v hqd_hook_flag_enabled_for >/dev/null 2>&1; then
16
+ hqd_hook_flag_enabled_for multi-company-session-lock hooks.multi-company-session-lock \
17
+ "$flag_script" "$root" "$primary"
18
+ [ "${HQD_FLAG_ENABLED:-false}" = true ]
19
+ return $?
20
+ fi
21
+ # The cache library is part of the live HQ tree. Missing it is a lookup error,
22
+ # so keep the gate off instead of introducing uncached Node work on a hook.
23
+ return 1
24
+ }
25
+
8
26
  # session_scope_identity_is_valid <identity>
9
27
  # Identities become path segments; accept only a conservative portable set.
10
28
  session_scope_identity_is_valid() {
@@ -40,6 +58,55 @@ session_scope_read() {
40
58
  "$cap" 2>/dev/null || true
41
59
  }
42
60
 
61
+ # session_scope_read_companies <root> <session_id> [agent_id]
62
+ # Print the ordered, validated lock set. Legacy capabilities remain singleton
63
+ # locks; malformed company_slugs fail closed instead of falling back to primary.
64
+ session_scope_read_companies() {
65
+ local root="${1:-}" sid="${2:-}" aid="${3:-}" cap lock_info primary has_set result slug out=""
66
+ local -a slugs
67
+ cap="$(session_scope_capability_path "$root" "$sid" "$aid")" || return 0
68
+ [ -f "$cap" ] || return 0
69
+ lock_info="$(jq -er --arg sid "$sid" --arg aid "$aid" '
70
+ select(.session_id == $sid and (.agent_id // "") == $aid)
71
+ | if (.company_slug | type) != "string" or (.company_slug | test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$") | not) then error("invalid primary")
72
+ elif has("company_slugs") then
73
+ if (.company_slugs | type) == "array" and all(.company_slugs[]; type == "string" and test("^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$"))
74
+ and (.company_slugs | length) > 0 and .company_slugs[0] == .company_slug
75
+ and ((.company_slugs | unique | length) == (.company_slugs | length))
76
+ and ((.company_slug == "personal" and (.company_slugs | length) == 1)
77
+ or (.company_slugs | all(. != "personal"))) then
78
+ [.company_slug, "true", (.company_slugs | join(","))] | @tsv
79
+ else error("invalid company_slugs") end
80
+ else [.company_slug, "false", .company_slug] | @tsv end
81
+ ' "$cap" 2>/dev/null)" || return 0
82
+ IFS="$(printf '\t')" read -r primary has_set result <<EOF
83
+ $lock_info
84
+ EOF
85
+ case "$primary" in ''|*[!A-Za-z0-9_-]*) return 0 ;; esac
86
+ IFS=, read -r -a slugs <<< "$result"
87
+ # A singleton is identical whether the gate is on or off; skip the flag
88
+ # client on the session-start hot path.
89
+ if [ "${#slugs[@]}" -le 1 ]; then
90
+ if [ "$primary" = personal ] || { [ -d "$root/companies/$primary" ] && [ ! -L "$root/companies/$primary" ]; }; then
91
+ printf '%s\n' "$primary"
92
+ fi
93
+ return 0
94
+ fi
95
+ if [ "$has_set" != true ] || ! session_scope_multi_company_enabled "$root" "$primary"; then
96
+ if [ "$primary" = personal ] || { [ -d "$root/companies/$primary" ] && [ ! -L "$root/companies/$primary" ]; }; then
97
+ printf '%s\n' "$primary"
98
+ fi
99
+ return 0
100
+ fi
101
+ for slug in "${slugs[@]}"; do
102
+ case "$slug" in ''|*[!a-zA-Z0-9_-]*) return 0 ;; esac
103
+ if [ "$slug" = personal ] || { [ -d "$root/companies/$slug" ] && [ ! -L "$root/companies/$slug" ]; }; then
104
+ case ",$out," in *",$slug,"*) ;; *) out="${out:+$out,}$slug" ;; esac
105
+ fi
106
+ done
107
+ [ -n "$out" ] && printf '%s\n' "$out" | tr ',' '\n'
108
+ }
109
+
43
110
  # session_scope_resolve_agent_company <root> <session_id> <agent_id> <session_company> <event>
44
111
  # Existing tuple bindings always win. Session metadata may bind a caller only
45
112
  # on SessionStart, when that agent has not been bound before.
@@ -53,6 +120,16 @@ session_scope_resolve_agent_company() {
53
120
  fi
54
121
  }
55
122
 
123
+ session_scope_resolve_agent_companies() {
124
+ local root="${1:-}" sid="${2:-}" aid="${3:-}" session_companies="${4:-}" event="${5:-}" bound=""
125
+ bound="$(session_scope_read_companies "$root" "$sid" "$aid" 2>/dev/null || true)"
126
+ if [ -n "$bound" ]; then
127
+ printf '%s\n' "$bound"
128
+ elif [ "$event" = "SessionStart" ] && [ -n "$session_companies" ]; then
129
+ printf '%s\n' "$session_companies" | tr ',' '\n' | awk 'NF && !seen[$0]++'
130
+ fi
131
+ }
132
+
56
133
  # session_scope_mint <root> <session_id> <company_slug> [agent_id]
57
134
  # Write scope-capability.json for the exact caller. Main-thread writes retain
58
135
  # the legacy shape/path for one-restart compatibility.
@@ -89,3 +166,27 @@ session_scope_mint() {
89
166
  || { rm -f "$tmp"; return 1; }
90
167
  mv "$tmp" "$cap"
91
168
  }
169
+
170
+ # session_scope_mint_set <root> <session_id> <comma-separated-slugs> [agent_id]
171
+ session_scope_mint_set() {
172
+ local root="${1:-}" sid="${2:-}" slugs="${3:-}" aid="${4:-}" first rest slug json
173
+ [ -n "$slugs" ] || return 1
174
+ first="${slugs%%,*}"
175
+ case "$first" in ''|*[!a-zA-Z0-9_-]*) return 1 ;; esac
176
+ rest="${slugs#"$first"}"
177
+ while [ -n "$rest" ]; do
178
+ case "$rest" in ,*) rest="${rest#,}" ;; *) return 1 ;; esac
179
+ slug="${rest%%,*}"
180
+ case "$slug" in ''|*[!a-zA-Z0-9_-]*) return 1 ;; esac
181
+ rest="${rest#"$slug"}"
182
+ [ "$rest" = "$slug" ] && break
183
+ done
184
+ json="$(jq -nc --arg slugs "$slugs" '$slugs | split(",") | reduce .[] as $slug ([]; if index($slug) == null then . + [$slug] else . end)')" || return 1
185
+ session_scope_mint "$root" "$sid" "$first" "$aid" || return 1
186
+ local cap tmp minted_at
187
+ cap="$(session_scope_capability_path "$root" "$sid" "$aid")" || return 1
188
+ minted_at="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
189
+ tmp="$(mktemp)"
190
+ jq --argjson slugs "$json" --arg minted_at "$minted_at" '.company_slugs=$slugs | .minted_at=$minted_at' "$cap" >"$tmp" || { rm -f "$tmp"; return 1; }
191
+ mv "$tmp" "$cap"
192
+ }
@@ -1723,6 +1723,10 @@ export declare const COMMAND_CATALOG: readonly [{
1723
1723
  readonly flags: "--only <keys>";
1724
1724
  readonly description: "Secret names to inject (comma-separated; may be repeated) (required)";
1725
1725
  readonly hidden: false;
1726
+ }, {
1727
+ readonly flags: "--company <slug>";
1728
+ readonly description: "Company slug (resolves to companyUid)";
1729
+ readonly hidden: false;
1726
1730
  }, {
1727
1731
  readonly flags: "--script <path>";
1728
1732
  readonly description: "Attach local script identity for script-locked secrets";
@@ -2065,6 +2065,11 @@ export const COMMAND_CATALOG = [
2065
2065
  "description": "Secret names to inject (comma-separated; may be repeated) (required)",
2066
2066
  "hidden": false
2067
2067
  },
2068
+ {
2069
+ "flags": "--company <slug>",
2070
+ "description": "Company slug (resolves to companyUid)",
2071
+ "hidden": false
2072
+ },
2068
2073
  {
2069
2074
  "flags": "--script <path>",
2070
2075
  "description": "Attach local script identity for script-locked secrets",
@@ -28,7 +28,9 @@ export type HqSessionDependencies = {
28
28
  stderr: string;
29
29
  error?: Error;
30
30
  };
31
+ multiCompanyLockEnabled: boolean;
31
32
  };
33
+ export { validSessionId } from "../lib/core-utils/session-id.js";
32
34
  /** In-process command body, with injectable file access for parity tests. */
33
35
  export declare function executeHqSession(argv: readonly string[], context: {
34
36
  cwd: string;