@indigoai-us/hq-cli 5.345.119 → 5.345.120

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,11 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [5.345.120] — 2026-10-13
6
+ - `hq dm forward --company <slug|cmp_id>` names the company a person or agent
7
+ destination belongs to. The CLI sends it as `companyUid`, so the server runs
8
+ its destination-side cross-company check. Without it the destination is
9
+ treated as personal. `--company` is refused for channel destinations, which
10
+ use the channel's own company.
11
+
5
12
  ## [5.345.119] — 2026-10-13
6
13
  - The checkpoint sibling no longer falls back to Codex. `--backend auto` now picks only Claude or grok. Codex runs only with `--backend codex` or the new `HQ_CHECKPOINT_BACKEND=codex` setting (process environment or the HQ settings file env block). A sibling run that left no report stops counting as a crash after 6 hours, so one transient failure no longer pins the choice. When no allowed backend is available the sibling is skipped with a log line.
7
14
  - Add native TypeScript hq core pipeline conductor and driver commands with the bundled shell companions as the flag-off fallback.
8
15
  - Fix native policy retirement reports for partly stale references, accept shell-written newline-terminated trigger cooldown stamps, and resolve derive-trigger-facts session metadata from `HQ_ROOT` when the caller cwd differs, including repo detection for Windows path separators.
9
16
 
17
+ - `hq core session-journal tool-counter` now skips updates when its bounded lock retries are exhausted and reclaims stale counter locks like the shell implementation.
18
+
10
19
  ## [5.345.118] — 2026-10-13
11
20
 
12
21
  - Remove the bundled worker-skill shell fallbacks. `hq core worker lint` and
@@ -128,12 +128,57 @@ resolve_session_key() {
128
128
  }
129
129
 
130
130
  # Read-modify-write on the counter must not interleave across concurrent tasks.
131
+ counter_lock_is_aged() {
132
+ local lockdir="$1" lock_mtime now
133
+ lock_mtime="$(stat -c %Y "$lockdir" 2>/dev/null)" || lock_mtime="$(stat -f %m "$lockdir" 2>/dev/null)" || return 1
134
+ now="$(date -u +%s 2>/dev/null)" || return 1
135
+ case "$lock_mtime:$now" in *[!0-9:]*|:*|*:) return 1 ;; esac
136
+ [ "$now" -ge "$lock_mtime" ] && [ "$((now - lock_mtime))" -ge 60 ]
137
+ }
138
+
139
+ counter_windows_pid_is_alive() {
140
+ local pid="$1" output
141
+ case "${OSTYPE:-}" in msys*|cygwin*|win32*) ;; *) return 1 ;; esac
142
+ case "$pid" in ''|*[!0-9]*) return 1 ;; esac
143
+ output="$(MSYS_NO_PATHCONV=1 tasklist.exe /FO CSV /NH /FI "PID eq $pid" 2>/dev/null)" || return 1
144
+ printf '%s\n' "$output" | tr -d '\r"' | cut -d, -f2 | grep -Fxq "$pid"
145
+ }
146
+
147
+ counter_lock_owner_is_alive() {
148
+ local lockdir="$1" owner_pid="$2" windows_pid
149
+ kill -0 "$owner_pid" 2>/dev/null && return 0
150
+ windows_pid="$(cat "$lockdir/winpid" 2>/dev/null || true)"
151
+ if [ -n "$windows_pid" ]; then
152
+ counter_windows_pid_is_alive "$windows_pid" && return 0
153
+ elif counter_windows_pid_is_alive "$owner_pid"; then
154
+ # Old native clients wrote only a Win32 process.pid to pid.
155
+ return 0
156
+ fi
157
+ return 1
158
+ }
159
+
131
160
  reclaim_stale_counter_lock() {
132
- local lockdir="$1" owner_pid current_owner claim stale_dir suffix=0
161
+ local lockdir="$1" owner_pid current_owner current_windows_pid windows_pid claim stale_dir suffix=0 pidless=0
133
162
  owner_pid="$(cat "$lockdir/pid" 2>/dev/null || true)"
134
- case "$owner_pid" in ''|*[!0-9]*) return 1 ;; esac
135
- [ "$owner_pid" -gt 1 ] 2>/dev/null || return 1
136
- kill -0 "$owner_pid" 2>/dev/null && return 1
163
+ case "$owner_pid" in
164
+ '')
165
+ windows_pid="$(cat "$lockdir/winpid" 2>/dev/null || true)"
166
+ if [ -n "$windows_pid" ]; then
167
+ counter_windows_pid_is_alive "$windows_pid" && return 1
168
+ fi
169
+ # The complete counter retry window is 5 seconds (250 x 20 ms). A
170
+ # PID-less lock is reclaimable after one minute, twelve retry windows,
171
+ # allowing for a creator paused between mkdir and writing pid. The stat
172
+ # fallback keeps this elapsed-time threshold consistent on GNU/BSD find.
173
+ counter_lock_is_aged "$lockdir" || return 1
174
+ pidless=1
175
+ ;;
176
+ *[!0-9]*) return 1 ;;
177
+ *)
178
+ [ "$owner_pid" -gt 1 ] 2>/dev/null || return 1
179
+ counter_lock_owner_is_alive "$lockdir" "$owner_pid" && return 1
180
+ ;;
181
+ esac
137
182
 
138
183
  claim="${lockdir}.reclaim"
139
184
  mkdir "$claim" 2>/dev/null || return 1
@@ -143,7 +188,19 @@ reclaim_stale_counter_lock() {
143
188
  fi
144
189
 
145
190
  current_owner="$(cat "$lockdir/pid" 2>/dev/null || true)"
146
- if [ "$current_owner" = "$owner_pid" ] && ! kill -0 "$owner_pid" 2>/dev/null; then
191
+ current_windows_pid="$(cat "$lockdir/winpid" 2>/dev/null || true)"
192
+ if [ "$pidless" -eq 1 ]; then
193
+ # Recheck both identity and age after winning the claim, before renaming.
194
+ if [ -z "$current_owner" ] && [ "$current_windows_pid" = "$windows_pid" ] \
195
+ && { [ -z "$current_windows_pid" ] || ! counter_windows_pid_is_alive "$current_windows_pid"; } \
196
+ && counter_lock_is_aged "$lockdir"; then
197
+ owner_pid=""
198
+ else
199
+ owner_pid="changed"
200
+ fi
201
+ fi
202
+ if [ "$current_owner" = "$owner_pid" ] && [ "$current_windows_pid" = "${windows_pid:-$(cat "$lockdir/winpid" 2>/dev/null || true)}" ] \
203
+ && { [ "$pidless" -eq 1 ] || ! counter_lock_owner_is_alive "$lockdir" "$owner_pid"; }; then
147
204
  stale_dir="${lockdir}.stale.$$.$RANDOM"
148
205
  while [ -e "$stale_dir" ]; do
149
206
  suffix=$((suffix + 1))
@@ -167,6 +224,11 @@ with_counter_lock() {
167
224
  while [ "$i" -lt "$tries" ]; do
168
225
  if mkdir "$lockdir" 2>/dev/null; then
169
226
  acquired=1
227
+ if [[ "${OSTYPE:-}" == msys* ]] || [[ "${OSTYPE:-}" == cygwin* ]]; then
228
+ local windows_pid
229
+ windows_pid="$(cat "/proc/$$/winpid" 2>/dev/null || true)"
230
+ case "$windows_pid" in ''|*[!0-9]*) ;; *) printf '%s\n' "$windows_pid" > "$lockdir/winpid" || { rm -rf "$lockdir" 2>/dev/null || true; return 0; } ;; esac
231
+ fi
170
232
  if ! printf '%s\n' "$$" > "$lockdir/pid" 2>/dev/null; then
171
233
  rm -rf "$lockdir" 2>/dev/null || true
172
234
  return 0
@@ -3611,6 +3611,10 @@ export declare const COMMAND_CATALOG: readonly [{
3611
3611
  readonly flags: "--cross-company";
3612
3612
  readonly description: "Confirm forwarding across company boundaries";
3613
3613
  readonly hidden: false;
3614
+ }, {
3615
+ readonly flags: "--company <slug|cmp_id>";
3616
+ readonly description: "Company the person or agent destination belongs to, so cross-company rules apply";
3617
+ readonly hidden: false;
3614
3618
  }];
3615
3619
  readonly subcommands: readonly [];
3616
3620
  }, {
@@ -4370,6 +4370,11 @@ export const COMMAND_CATALOG = [
4370
4370
  "flags": "--cross-company",
4371
4371
  "description": "Confirm forwarding across company boundaries",
4372
4372
  "hidden": false
4373
+ },
4374
+ {
4375
+ "flags": "--company <slug|cmp_id>",
4376
+ "description": "Company the person or agent destination belongs to, so cross-company rules apply",
4377
+ "hidden": false
4373
4378
  }
4374
4379
  ],
4375
4380
  "subcommands": []
@@ -89,6 +89,18 @@ function processIsAlive(pid) {
89
89
  return error.code !== "ESRCH";
90
90
  }
91
91
  }
92
+ function counterLockOwnerIsAlive(lock, pid) {
93
+ if (processIsAlive(pid))
94
+ return true;
95
+ if (process.platform !== "win32")
96
+ return false;
97
+ let windowsPid = 0;
98
+ try {
99
+ windowsPid = Number.parseInt(fs.readFileSync(path.join(lock, "winpid"), "utf8"), 10) || 0;
100
+ }
101
+ catch { /* older shell locks did not record the Win32 PID */ }
102
+ return windowsPid > 1 && processIsAlive(windowsPid);
103
+ }
92
104
  function acquireJournalWriteLock(dir) {
93
105
  const lock = path.join(dir, ".session-journal-write.lock");
94
106
  const ownerFile = path.join(lock, "pid");
@@ -142,26 +154,144 @@ function releaseJournalWriteLock(lock) {
142
154
  }
143
155
  catch { /* shell lock cleanup is best-effort */ }
144
156
  }
145
- function withCounterLock(lock, action) {
157
+ const COUNTER_LOCK_PIDLESS_STALE_MS = 60_000;
158
+ function isAgedPidlessCounterLock(lock, now) {
159
+ try {
160
+ return now().getTime() - fs.statSync(lock).mtimeMs >= COUNTER_LOCK_PIDLESS_STALE_MS;
161
+ }
162
+ catch {
163
+ return false;
164
+ }
165
+ }
166
+ function reclaimStaleCounterLock(lock, now) {
167
+ let rawOwner;
168
+ try {
169
+ rawOwner = fs.readFileSync(path.join(lock, "pid"), "utf8").trim();
170
+ }
171
+ catch (error) {
172
+ if (error.code !== "ENOENT" || !isAgedPidlessCounterLock(lock, now))
173
+ return false;
174
+ rawOwner = "";
175
+ }
176
+ if (rawOwner !== "" && !/^\d+$/u.test(rawOwner))
177
+ return false;
178
+ let rawWindowsPid = "";
179
+ try {
180
+ rawWindowsPid = fs.readFileSync(path.join(lock, "winpid"), "utf8").trim();
181
+ }
182
+ catch { /* older shell locks did not record the Win32 PID */ }
183
+ if (rawWindowsPid !== "" && !/^\d+$/u.test(rawWindowsPid))
184
+ return false;
185
+ const owner = rawOwner ? Number.parseInt(rawOwner, 10) : 0;
186
+ const windowsOwner = rawWindowsPid ? Number.parseInt(rawWindowsPid, 10) : 0;
187
+ if (rawOwner && (owner <= 1 || counterLockOwnerIsAlive(lock, owner)))
188
+ return false;
189
+ if (!rawOwner && windowsOwner > 1 && processIsAlive(windowsOwner))
190
+ return false;
191
+ const claim = `${lock}.reclaim`;
192
+ try {
193
+ fs.mkdirSync(claim);
194
+ }
195
+ catch {
196
+ return false;
197
+ }
198
+ try {
199
+ fs.writeFileSync(path.join(claim, "pid"), `${process.pid}\n`, { flag: "wx" });
200
+ }
201
+ catch {
202
+ try {
203
+ fs.rmSync(claim, { recursive: true, force: true });
204
+ }
205
+ catch { /* best effort */ }
206
+ return false;
207
+ }
208
+ let currentRawOwner;
209
+ try {
210
+ currentRawOwner = fs.readFileSync(path.join(lock, "pid"), "utf8").trim();
211
+ }
212
+ catch {
213
+ currentRawOwner = "";
214
+ }
215
+ let currentRawWindowsPid;
216
+ try {
217
+ currentRawWindowsPid = fs.readFileSync(path.join(lock, "winpid"), "utf8").trim();
218
+ }
219
+ catch {
220
+ currentRawWindowsPid = "";
221
+ }
222
+ const sameOwner = currentRawOwner === rawOwner && currentRawWindowsPid === rawWindowsPid;
223
+ const stillStale = rawOwner
224
+ ? sameOwner && !counterLockOwnerIsAlive(lock, owner)
225
+ : sameOwner && !(windowsOwner > 1 && processIsAlive(windowsOwner)) && isAgedPidlessCounterLock(lock, now);
226
+ if (stillStale) {
227
+ const stale = `${lock}.stale-${process.pid}-${Math.random().toString(16).slice(2)}`;
228
+ try {
229
+ fs.renameSync(lock, stale);
230
+ fs.rmSync(stale, { recursive: true, force: true });
231
+ fs.rmSync(claim, { recursive: true, force: true });
232
+ return true;
233
+ }
234
+ catch { /* another waiter reclaimed it */ }
235
+ }
236
+ try {
237
+ fs.rmSync(claim, { recursive: true, force: true });
238
+ }
239
+ catch { /* best effort */ }
240
+ return false;
241
+ }
242
+ function withCounterLock(lock, env, writers, now, action) {
146
243
  let i = 0;
147
244
  let acquired = false;
148
- while (!acquired) {
245
+ let tries = env.HQ_JOURNAL_COUNTER_LOCK_TRIES ?? "250";
246
+ if (!/^\d+$/u.test(tries))
247
+ tries = "250";
248
+ const retryLimit = Number.parseInt(tries, 10);
249
+ while (i < retryLimit) {
149
250
  try {
150
251
  fs.mkdirSync(lock);
151
252
  acquired = true;
253
+ try {
254
+ if (process.platform === "win32")
255
+ fs.writeFileSync(path.join(lock, "winpid"), `${process.pid}\n`, { flag: "wx" });
256
+ fs.writeFileSync(path.join(lock, "pid"), `${process.pid}\n`, { flag: "wx" });
257
+ }
258
+ catch {
259
+ try {
260
+ fs.rmSync(lock, { recursive: true, force: true });
261
+ }
262
+ catch { /* best effort */ }
263
+ return;
264
+ }
265
+ break;
152
266
  }
153
267
  catch {
268
+ if (reclaimStaleCounterLock(lock, now))
269
+ continue;
154
270
  i++;
155
- if (i >= 50)
156
- break;
157
- sleepSync(20);
271
+ if (i < retryLimit)
272
+ sleepSync(20);
158
273
  }
159
274
  }
160
- action();
275
+ if (!acquired) {
276
+ writers.stderr("session-journal: tool-counter: lock remained busy; skipped update\n");
277
+ return;
278
+ }
161
279
  try {
162
- fs.rmdirSync(lock);
280
+ action();
281
+ }
282
+ finally {
283
+ let owner = 0;
284
+ try {
285
+ owner = Number.parseInt(fs.readFileSync(path.join(lock, "pid"), "utf8"), 10) || 0;
286
+ }
287
+ catch { /* lock disappeared */ }
288
+ if (owner === process.pid) {
289
+ try {
290
+ fs.rmSync(lock, { recursive: true, force: true });
291
+ }
292
+ catch { /* best effort */ }
293
+ }
163
294
  }
164
- catch { /* shell rmdir is best-effort */ }
165
295
  }
166
296
  function nextSequence(dir) {
167
297
  let names = [];
@@ -205,12 +335,12 @@ function runToolCounter(root, action, sessionArg, env, now, writers) {
205
335
  writers.stderr(`${sessionJournalAsset}: line ${line}: ${printableCounter}: Is a directory\n`);
206
336
  };
207
337
  if (action === "increment") {
208
- withCounterLock(lock, () => {
338
+ withCounterLock(lock, env, writers, now, () => {
209
339
  try {
210
340
  fs.writeFileSync(counter, `${readCounter(counter) + 1}\n`);
211
341
  }
212
342
  catch (error) {
213
- reportDirectoryWriteFailure(error, 395);
343
+ reportDirectoryWriteFailure(error, 457);
214
344
  }
215
345
  });
216
346
  }
@@ -218,12 +348,12 @@ function runToolCounter(root, action, sessionArg, env, now, writers) {
218
348
  writers.stdout(`${readCounter(counter)}\n`);
219
349
  }
220
350
  else if (action === "reset") {
221
- withCounterLock(lock, () => {
351
+ withCounterLock(lock, env, writers, now, () => {
222
352
  try {
223
353
  fs.writeFileSync(counter, "0\n");
224
354
  }
225
355
  catch (error) {
226
- reportDirectoryWriteFailure(error, 403);
356
+ reportDirectoryWriteFailure(error, 465);
227
357
  }
228
358
  });
229
359
  }
@@ -1,7 +1,7 @@
1
1
  import chalk from "chalk";
2
2
  import { readFileSync } from "node:fs";
3
3
  import { ensureCognitoToken } from "../utils/cognito-session.js";
4
- import { vaultApiFetch } from "../utils/vault-api.js";
4
+ import { resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
5
5
  import { isMachineIdentity } from "@indigoai-us/hq-cloud";
6
6
  import { peekIdToken } from "../utils/id-token.js";
7
7
  import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN } from "../lib/notify-recipient.js";
@@ -830,12 +830,20 @@ async function runDmForward(eventId, opts) {
830
830
  console.error(chalk.red("--to accepts one recipient or channel, not a group recipient list."));
831
831
  process.exit(1);
832
832
  }
833
+ const destinationChannelName = parseChannelName(opts.to);
834
+ if (opts.company !== undefined && destinationChannelName !== null) {
835
+ console.error(chalk.red("--company applies to person or agent destinations; a channel uses its own company."));
836
+ process.exit(1);
837
+ }
838
+ if (opts.company !== undefined && !opts.company.trim()) {
839
+ console.error(chalk.red("--company requires a company slug or cmp_ ID."));
840
+ process.exit(1);
841
+ }
833
842
  const note = opts.noteFile !== undefined
834
843
  ? readFileSync(opts.noteFile, "utf8")
835
844
  : opts.note;
836
845
  const token = await ensureCognitoToken();
837
846
  const source = await resolveForwardSourceConversation(token, opts.from);
838
- const destinationChannelName = parseChannelName(opts.to);
839
847
  const fileAccess = opts.grantFiles ? "grant" : opts.omitFiles ? "omit" : undefined;
840
848
  const shared = {
841
849
  ...(note !== undefined ? { body: note } : {}),
@@ -869,8 +877,19 @@ async function runDmForward(eventId, opts) {
869
877
  console.error(chalk.red("--to must be a person email, person/agent UID, or channel name/ID."));
870
878
  process.exit(1);
871
879
  }
880
+ // The server runs the destination-side cross-company check only when the
881
+ // request names the destination company; without it the destination is
882
+ // treated as personal. Not defaulted from the source, which would make a
883
+ // forward into another company look same-company.
884
+ const destinationCompanyUid = opts.company !== undefined
885
+ ? await resolveDestinationCompanyUid(token, opts.company.trim())
886
+ : undefined;
872
887
  path = "/v1/notify/dm";
873
- payload = { ...recipient, ...shared };
888
+ payload = {
889
+ ...recipient,
890
+ ...shared,
891
+ ...(destinationCompanyUid ? { companyUid: destinationCompanyUid } : {}),
892
+ };
874
893
  destinationLabel = opts.to;
875
894
  }
876
895
  const res = await vaultApiFetch({ token, path, method: "POST", body: payload });
@@ -1529,6 +1548,7 @@ export function registerDmCommand(program) {
1529
1548
  .option("--grant-files", "Grant the recipient access to files they cannot currently open")
1530
1549
  .option("--omit-files", "Omit files the recipient cannot currently open")
1531
1550
  .option("--cross-company", "Confirm forwarding across company boundaries")
1551
+ .option("--company <slug|cmp_id>", "Company the person or agent destination belongs to, so cross-company rules apply")
1532
1552
  .action(async (eventId, opts) => {
1533
1553
  await runDmForward(eventId, opts);
1534
1554
  });
@@ -68,6 +68,16 @@ export declare function vaultApiFetchPublic(opts: {
68
68
  }): Promise<Response>;
69
69
  export declare function looksLikeCompanyUid(ref: string): boolean;
70
70
  export declare function looksLikePersonVaultUid(ref: string): boolean;
71
+ /**
72
+ * Resolve a company that may not be the caller's own — the destination of a
73
+ * cross-company DM forward. `getCompanyUid` prefers the caller's namespace, so
74
+ * when the destination company shares a slug with one of the caller's
75
+ * companies it would silently return the caller's company. Here a `cmp_` uid
76
+ * resolves directly; a slug goes to the global lookup first, and a slug that
77
+ * matches more than one company is refused with the candidate uids instead of
78
+ * guessing. Any other global failure falls back to `getCompanyUid`.
79
+ */
80
+ export declare function resolveDestinationCompanyUid(token: string, ref: string, baseUrl?: string): Promise<string>;
71
81
  export declare function getCompanyUid(token: string, companySlug: string | undefined,
72
82
  /**
73
83
  * Control-plane origin for the lookups. Defaults to DEFAULT_VAULT_API_URL;
@@ -609,6 +609,33 @@ async function resolveCompanyUid(token, ref, baseUrl) {
609
609
  const data = (await res.json());
610
610
  return data.entity.uid;
611
611
  }
612
+ /**
613
+ * Resolve a company that may not be the caller's own — the destination of a
614
+ * cross-company DM forward. `getCompanyUid` prefers the caller's namespace, so
615
+ * when the destination company shares a slug with one of the caller's
616
+ * companies it would silently return the caller's company. Here a `cmp_` uid
617
+ * resolves directly; a slug goes to the global lookup first, and a slug that
618
+ * matches more than one company is refused with the candidate uids instead of
619
+ * guessing. Any other global failure falls back to `getCompanyUid`.
620
+ */
621
+ export async function resolveDestinationCompanyUid(token, ref, baseUrl) {
622
+ if (looksLikeCompanyUid(ref)) {
623
+ return resolveCompanyByUid(token, ref, baseUrl);
624
+ }
625
+ const res = await fetchGlobalCompanyBySlug(token, ref, baseUrl);
626
+ if (res.ok) {
627
+ const data = (await res.json());
628
+ return data.entity.uid;
629
+ }
630
+ raiseIfUnauthorized(res);
631
+ const body = (await res.json().catch(() => ({})));
632
+ if (res.status === 409 && Array.isArray(body.uids) && body.uids.length > 0) {
633
+ throw new CompanySelectionError(`Company slug '${ref}' matches ${body.uids.length} companies. ` +
634
+ `Re-run with --company <cmp_id> to name the destination company:\n` +
635
+ body.uids.map((u) => ` --company ${u}`).join('\n'));
636
+ }
637
+ return getCompanyUid(token, ref, baseUrl);
638
+ }
612
639
  async function resolveCompanyFromMemberships(token, baseUrl) {
613
640
  const res = await vaultApiFetch({
614
641
  token,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@indigoai-us/hq-cli",
3
- "version": "5.345.119",
3
+ "version": "5.345.120",
4
4
  "description": "HQ by Indigo management CLI — modules and cloud sync",
5
5
  "main": "dist/index.js",
6
6
  "bin": {