@indigoai-us/hq-cli 5.122.2 → 5.123.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README.md +46 -0
- package/dist/command-catalog.generated.d.ts +6 -3
- package/dist/command-catalog.generated.js +7 -3
- package/dist/commands/dm.d.ts +54 -1
- package/dist/commands/dm.js +204 -8
- package/dist/commands/whoami.d.ts +2 -2
- package/dist/commands/whoami.js +18 -19
- package/dist/lib/billing/plan-lock.d.ts +64 -69
- package/dist/lib/billing/plan-lock.js +230 -161
- package/dist/lib/mesh/api.js +96 -28
- package/dist/lib/mesh/live/daemon/board-refresh.d.ts +4 -1
- package/dist/lib/mesh/live/daemon/board-refresh.js +71 -30
- package/dist/lib/mesh/live/daemon/credentials.d.ts +6 -1
- package/dist/lib/mesh/live/daemon/credentials.js +20 -4
- package/dist/lib/mesh/live/daemon/doctor.d.ts +2 -0
- package/dist/lib/mesh/live/daemon/doctor.js +12 -0
- package/dist/lib/mesh/live/daemon/run.js +6 -1
- package/dist/lib/mesh/live/daemon/state.d.ts +3 -0
- package/dist/utils/cli-telemetry.d.ts +21 -0
- package/dist/utils/cli-telemetry.js +77 -1
- package/package.json +3 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,44 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.123.0] — 2026-09-19
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- `hq mesh check --project` lists that project's open threads. The CLI queries
|
|
10
|
+
`/v1/work-mesh/threads` with `projectId` (and `status=open`) and merges the
|
|
11
|
+
result with the status listing, because the status index can omit project
|
|
12
|
+
threads.
|
|
13
|
+
|
|
14
|
+
- First signed-in interactive run prints a one-line telemetry disclosure on
|
|
15
|
+
stderr (once per machine). `HQ_TELEMETRY=0` (also `false` or `off`) disables
|
|
16
|
+
CLI telemetry posts. After the notice has been shown, events send
|
|
17
|
+
`consentBasis: "disclosed"` instead of `"no-consent"`.
|
|
18
|
+
|
|
19
|
+
### Added
|
|
20
|
+
|
|
21
|
+
- Channel messages can now be posted as thread replies: `hq dm <channel>
|
|
22
|
+
--thread <eventId> "…"` sends `rootEventId` on the POST, and `--thread last`
|
|
23
|
+
replies to your most recent top-level message in that channel. `hq dm channel
|
|
24
|
+
<name>` prints each root's eventId and indents replies under their root
|
|
25
|
+
(`--json` is still the ungrouped oldest-first array). `@name` mentions were
|
|
26
|
+
already resolved against the roster into the structured `mentions` array;
|
|
27
|
+
unknown names still fail the send and list the roster (`--no-mentions` posts
|
|
28
|
+
the text literally).
|
|
29
|
+
|
|
30
|
+
### Changed
|
|
31
|
+
|
|
32
|
+
- `hq whoami --company` and the bind notice no longer read `planLock`. They
|
|
33
|
+
render the Starter over-limit nag from `GET /v1/billing/usage-limits`
|
|
34
|
+
(including the top-level `freeMonth` CTA). Customer copy does not say
|
|
35
|
+
"locked" or "read-only".
|
|
36
|
+
|
|
37
|
+
### Fixed
|
|
38
|
+
|
|
39
|
+
- Work Mesh board snapshots now read live story status from ProjectView before
|
|
40
|
+
falling back to the vault board, and `hq mesh daemon doctor` warns when a
|
|
41
|
+
presence credential omitted companies.
|
|
42
|
+
|
|
5
43
|
## [5.122.2] — 2026-09-19
|
|
6
44
|
|
|
7
45
|
### Fixed
|
package/README.md
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# hq-cli
|
|
2
|
+
|
|
3
|
+
HQ management CLI — modules, packages, and cloud sync.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npm install -g @indigoai-us/hq-cli
|
|
7
|
+
hq --help
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
## Telemetry
|
|
11
|
+
|
|
12
|
+
When you are signed in, `hq` may POST one `cli_session_started` event to
|
|
13
|
+
`/v1/telemetry/events`. The event body is:
|
|
14
|
+
|
|
15
|
+
- `eventName`: `cli_session_started`
|
|
16
|
+
- `app`: `hq-cli`
|
|
17
|
+
- `source`: `cli`
|
|
18
|
+
- `occurredAt`: ISO timestamp
|
|
19
|
+
- `consentBasis`: `disclosed` after the first-run notice, otherwise `no-consent`
|
|
20
|
+
- `schemaVersion`: `1`
|
|
21
|
+
- `properties.version`: the CLI version
|
|
22
|
+
|
|
23
|
+
The request is authenticated with your cached session token so the control
|
|
24
|
+
plane can accept the event. The body does not include command names, arguments,
|
|
25
|
+
timings, file contents, or secrets.
|
|
26
|
+
|
|
27
|
+
The first signed-in interactive run (stdout is a TTY, and the command is not
|
|
28
|
+
`--json`) prints this line once on stderr and records that it was shown under
|
|
29
|
+
`~/.hq/cli-telemetry-disclosed`:
|
|
30
|
+
|
|
31
|
+
```
|
|
32
|
+
HQ sends a usage event when you run a signed-in command (CLI version and a timestamp, never command names, file contents, or secrets) to improve the CLI. Set HQ_TELEMETRY=0 to turn this off.
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Turn telemetry off:
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
export HQ_TELEMETRY=0
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`false` and `off` are accepted as well (case-insensitive). That disables every
|
|
42
|
+
CLI POST to `/v1/telemetry/events`. `HQ_TELEMETRY_TIMEOUT_MS` is a separate
|
|
43
|
+
timeout knob and is unchanged.
|
|
44
|
+
|
|
45
|
+
The same kill switch and event shape should be documented on hqforwork.com
|
|
46
|
+
when that site's telemetry page is added (indigo-marketing; not this repo).
|
|
@@ -833,7 +833,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
833
833
|
readonly description: "Output identity metadata as JSON (never tokens)";
|
|
834
834
|
}, {
|
|
835
835
|
readonly flags: "--company <slug>";
|
|
836
|
-
readonly description: "Also report this company's workspace plan state (Starter
|
|
836
|
+
readonly description: "Also report this company's workspace plan state (Starter nag)";
|
|
837
837
|
}];
|
|
838
838
|
readonly subcommands: readonly [];
|
|
839
839
|
}, {
|
|
@@ -2209,7 +2209,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2209
2209
|
readonly options: readonly [];
|
|
2210
2210
|
readonly subcommands: readonly [{
|
|
2211
2211
|
readonly name: "send";
|
|
2212
|
-
readonly description: "Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: \"a@x.com,b@y.com\"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev \"hi\"), hash form (hq dm \"#vyg-dev\" \"hi\"), or via --channel (hq dm --channel vyg-dev \"hi\"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren't connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @\"Jacob Posel\") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare \"@\" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.";
|
|
2212
|
+
readonly description: "Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: \"a@x.com,b@y.com\"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev \"hi\"), hash form (hq dm \"#vyg-dev\" \"hi\"), or via --channel (hq dm --channel vyg-dev \"hi\"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren't connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @\"Jacob Posel\") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare \"@\" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.\n\nCHANNEL THREADS: `hq dm <channel> --thread <eventId> \"…\"` replies under an existing root (the request carries rootEventId). `--thread last` replies to your most recent top-level message in that channel. Copy a root eventId from `hq dm channel <name>`, which prints each root's id and indents replies underneath.";
|
|
2213
2213
|
readonly aliases: readonly [];
|
|
2214
2214
|
readonly hidden: true;
|
|
2215
2215
|
readonly usage: "[options] [recipient] [message]";
|
|
@@ -2228,6 +2228,9 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2228
2228
|
}, {
|
|
2229
2229
|
readonly flags: "--no-mentions";
|
|
2230
2230
|
readonly description: "Do not resolve `@name` in a channel message into structured mentions — post the text literally";
|
|
2231
|
+
}, {
|
|
2232
|
+
readonly flags: "--thread <eventId>";
|
|
2233
|
+
readonly description: "Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel";
|
|
2231
2234
|
}, {
|
|
2232
2235
|
readonly flags: "--prompt <text>";
|
|
2233
2236
|
readonly description: "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)";
|
|
@@ -2293,7 +2296,7 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
2293
2296
|
readonly subcommands: readonly [];
|
|
2294
2297
|
}, {
|
|
2295
2298
|
readonly name: "channel";
|
|
2296
|
-
readonly description: "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.";
|
|
2299
|
+
readonly description: "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`. Roots print their eventId; replies indent under their root. --json is the raw oldest-first array (ungrouped).";
|
|
2297
2300
|
readonly aliases: readonly ["history"];
|
|
2298
2301
|
readonly hidden: false;
|
|
2299
2302
|
readonly usage: "[options] <target>";
|
|
@@ -1064,7 +1064,7 @@ export const COMMAND_CATALOG = [
|
|
|
1064
1064
|
},
|
|
1065
1065
|
{
|
|
1066
1066
|
"flags": "--company <slug>",
|
|
1067
|
-
"description": "Also report this company's workspace plan state (Starter
|
|
1067
|
+
"description": "Also report this company's workspace plan state (Starter nag)"
|
|
1068
1068
|
}
|
|
1069
1069
|
],
|
|
1070
1070
|
"subcommands": []
|
|
@@ -2836,7 +2836,7 @@ export const COMMAND_CATALOG = [
|
|
|
2836
2836
|
"subcommands": [
|
|
2837
2837
|
{
|
|
2838
2838
|
"name": "send",
|
|
2839
|
-
"description": "Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: \"a@x.com,b@y.com\"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev \"hi\"), hash form (hq dm \"#vyg-dev\" \"hi\"), or via --channel (hq dm --channel vyg-dev \"hi\"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren't connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @\"Jacob Posel\") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare \"@\" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.",
|
|
2839
|
+
"description": "Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: \"a@x.com,b@y.com\"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev \"hi\"), hash form (hq dm \"#vyg-dev\" \"hi\"), or via --channel (hq dm --channel vyg-dev \"hi\"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren't connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @\"Jacob Posel\") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare \"@\" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.\n\nCHANNEL THREADS: `hq dm <channel> --thread <eventId> \"…\"` replies under an existing root (the request carries rootEventId). `--thread last` replies to your most recent top-level message in that channel. Copy a root eventId from `hq dm channel <name>`, which prints each root's id and indents replies underneath.",
|
|
2840
2840
|
"aliases": [],
|
|
2841
2841
|
"hidden": true,
|
|
2842
2842
|
"usage": "[options] [recipient] [message]",
|
|
@@ -2861,6 +2861,10 @@ export const COMMAND_CATALOG = [
|
|
|
2861
2861
|
"flags": "--no-mentions",
|
|
2862
2862
|
"description": "Do not resolve `@name` in a channel message into structured mentions — post the text literally"
|
|
2863
2863
|
},
|
|
2864
|
+
{
|
|
2865
|
+
"flags": "--thread <eventId>",
|
|
2866
|
+
"description": "Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel"
|
|
2867
|
+
},
|
|
2864
2868
|
{
|
|
2865
2869
|
"flags": "--prompt <text>",
|
|
2866
2870
|
"description": "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)"
|
|
@@ -2948,7 +2952,7 @@ export const COMMAND_CATALOG = [
|
|
|
2948
2952
|
},
|
|
2949
2953
|
{
|
|
2950
2954
|
"name": "channel",
|
|
2951
|
-
"description": "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.",
|
|
2955
|
+
"description": "Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`. Roots print their eventId; replies indent under their root. --json is the raw oldest-first array (ungrouped).",
|
|
2952
2956
|
"aliases": [
|
|
2953
2957
|
"history"
|
|
2954
2958
|
],
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -228,6 +228,8 @@ export interface ChannelMessageItem {
|
|
|
228
228
|
fromDisplayName?: string;
|
|
229
229
|
body: string;
|
|
230
230
|
createdAt: string;
|
|
231
|
+
/** Present on a reply; absent on a top-level (root) message. */
|
|
232
|
+
rootEventId?: string;
|
|
231
233
|
attachments?: DmAttachment[];
|
|
232
234
|
attachment?: DmAttachment;
|
|
233
235
|
}
|
|
@@ -296,7 +298,58 @@ export declare function formatInboxEvent(e: DmInboxEvent, nowMs: number): string
|
|
|
296
298
|
/** Render one 1:1 thread line, tagged by direction. */
|
|
297
299
|
export declare function formatThreadMessage(m: DmThreadMessage, nowMs: number): string;
|
|
298
300
|
/** Render one channel/group message line. */
|
|
299
|
-
export declare function formatChannelMessage(m: ChannelMessageItem, nowMs: number
|
|
301
|
+
export declare function formatChannelMessage(m: ChannelMessageItem, nowMs: number, opts?: {
|
|
302
|
+
indent?: string;
|
|
303
|
+
showEventId?: boolean;
|
|
304
|
+
}): string;
|
|
305
|
+
/** Identity we can match against a channel message's from* fields. Pure. */
|
|
306
|
+
export interface CallerIdentity {
|
|
307
|
+
personUid?: string;
|
|
308
|
+
email?: string;
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* Read the caller's person/agent uid and email from a JWT we already hold.
|
|
312
|
+
* Used to resolve `--thread last` (most recent top-level message *by us*).
|
|
313
|
+
* Pure → unit-testable.
|
|
314
|
+
*/
|
|
315
|
+
export declare function callerFromToken(token: string): CallerIdentity;
|
|
316
|
+
export declare function isCallerMessage(m: ChannelMessageItem, me: CallerIdentity): boolean;
|
|
317
|
+
/**
|
|
318
|
+
* Newest-first channel history → the caller's most recent top-level eventId.
|
|
319
|
+
* Replies (those with rootEventId) are skipped. Pure → unit-testable.
|
|
320
|
+
*/
|
|
321
|
+
export declare function lastCallerRootEventId(messagesNewestFirst: ChannelMessageItem[], me: CallerIdentity): string | undefined;
|
|
322
|
+
export type ThreadFlagResolution = {
|
|
323
|
+
ok: true;
|
|
324
|
+
rootEventId?: string;
|
|
325
|
+
} | {
|
|
326
|
+
ok: false;
|
|
327
|
+
error: string;
|
|
328
|
+
};
|
|
329
|
+
/**
|
|
330
|
+
* Resolve `--thread <eventId|last>` against a newest-first message page.
|
|
331
|
+
* An explicit eventId is passed through; `last` picks our most recent
|
|
332
|
+
* top-level message. Pure → unit-testable (the fetch is the caller's job).
|
|
333
|
+
*/
|
|
334
|
+
export declare function resolveThreadFlag(thread: string | undefined, messagesNewestFirst: ChannelMessageItem[], me: CallerIdentity): ThreadFlagResolution;
|
|
335
|
+
export interface ChannelThread {
|
|
336
|
+
root: ChannelMessageItem;
|
|
337
|
+
replies: ChannelMessageItem[];
|
|
338
|
+
/** True when the root is not in this page — replies hang off a placeholder. */
|
|
339
|
+
missingRoot?: boolean;
|
|
340
|
+
}
|
|
341
|
+
/**
|
|
342
|
+
* Group a channel history page into roots with their replies underneath.
|
|
343
|
+
* Orphan replies (root not in this page) hang off a placeholder carrying the
|
|
344
|
+
* missing root's eventId. Pure → unit-testable.
|
|
345
|
+
*/
|
|
346
|
+
export declare function groupChannelMessages(messages: ChannelMessageItem[]): ChannelThread[];
|
|
347
|
+
/**
|
|
348
|
+
* Human channel history: each root prints its eventId so `--thread` can copy
|
|
349
|
+
* it; replies render indented under their root. `--json` does not use this.
|
|
350
|
+
* Pure → unit-testable.
|
|
351
|
+
*/
|
|
352
|
+
export declare function formatChannelTranscript(messages: ChannelMessageItem[], nowMs: number): string;
|
|
300
353
|
/**
|
|
301
354
|
* The agent uid when the caller is an agent (machine) identity, else null.
|
|
302
355
|
* Agents may not read /v1/notify/inbox or /v1/notify/thread (the server
|
package/dist/commands/dm.js
CHANGED
|
@@ -515,6 +515,20 @@ async function fetchChannelMembers(token, channelId) {
|
|
|
515
515
|
const data = (await res.json());
|
|
516
516
|
return data.members ?? [];
|
|
517
517
|
}
|
|
518
|
+
/** Fetch a channel history page (GET /v1/notify/channels/{id}/messages). Newest-first. */
|
|
519
|
+
async function fetchChannelMessages(token, channelId, query = {}) {
|
|
520
|
+
const res = await vaultApiFetch({
|
|
521
|
+
token,
|
|
522
|
+
path: `/v1/notify/channels/${encodeURIComponent(channelId)}/messages`,
|
|
523
|
+
query,
|
|
524
|
+
});
|
|
525
|
+
if (!res.ok) {
|
|
526
|
+
const err = (await res.json().catch(() => ({})));
|
|
527
|
+
throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
|
|
528
|
+
}
|
|
529
|
+
const data = (await res.json());
|
|
530
|
+
return data.messages ?? [];
|
|
531
|
+
}
|
|
518
532
|
/**
|
|
519
533
|
* Channel DM path: `hq dm vyg-dev "msg"`, `hq dm '#vyg-dev' "msg"`, or
|
|
520
534
|
* `hq dm --channel vyg-dev "msg"`. Resolves the caller's channel by name via
|
|
@@ -552,6 +566,20 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
552
566
|
process.exit(1);
|
|
553
567
|
}
|
|
554
568
|
const channel = matches[0];
|
|
569
|
+
let rootEventId;
|
|
570
|
+
if (opts.thread !== undefined) {
|
|
571
|
+
let recent = [];
|
|
572
|
+
if (opts.thread.trim().toLowerCase() === "last") {
|
|
573
|
+
recent = await fetchChannelMessages(token, channel.channelId);
|
|
574
|
+
}
|
|
575
|
+
const resolved = resolveThreadFlag(opts.thread, recent, callerFromToken(token));
|
|
576
|
+
if (!resolved.ok) {
|
|
577
|
+
console.error(chalk.red(resolved.error));
|
|
578
|
+
process.exit(1);
|
|
579
|
+
return;
|
|
580
|
+
}
|
|
581
|
+
rootEventId = resolved.rootEventId;
|
|
582
|
+
}
|
|
555
583
|
// Mentions are structured-only server-side: resolve `@name` against the
|
|
556
584
|
// roster and send the array, or fail before posting. --no-mentions opts out
|
|
557
585
|
// and posts the text literally.
|
|
@@ -572,11 +600,16 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
572
600
|
// Send the human-readable form: the quotes only existed for parsing.
|
|
573
601
|
if (mentions.length > 0)
|
|
574
602
|
body = stripMentionQuotes(body);
|
|
603
|
+
const payload = { body };
|
|
604
|
+
if (mentions.length > 0)
|
|
605
|
+
payload.mentions = mentions;
|
|
606
|
+
if (rootEventId)
|
|
607
|
+
payload.rootEventId = rootEventId;
|
|
575
608
|
const sendRes = await vaultApiFetch({
|
|
576
609
|
token,
|
|
577
610
|
path: `/v1/notify/channels/${encodeURIComponent(channel.channelId)}/messages`,
|
|
578
611
|
method: "POST",
|
|
579
|
-
body:
|
|
612
|
+
body: payload,
|
|
580
613
|
});
|
|
581
614
|
if (!sendRes.ok) {
|
|
582
615
|
const err = (await sendRes.json().catch(() => ({})));
|
|
@@ -586,7 +619,8 @@ async function runChannelSend(channelName, message, opts) {
|
|
|
586
619
|
const mentionSuffix = mentions.length > 0
|
|
587
620
|
? ` Mentioned ${mentions.map((m) => m.displayName).join(", ")}.`
|
|
588
621
|
: "";
|
|
589
|
-
|
|
622
|
+
const threadSuffix = rootEventId ? ` Reply in thread ${rootEventId}.` : "";
|
|
623
|
+
console.log(chalk.green(`Message posted to #${channel.name ?? channelName}.${mentionSuffix}${threadSuffix}`));
|
|
590
624
|
}
|
|
591
625
|
catch (err) {
|
|
592
626
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
@@ -608,6 +642,11 @@ async function runDmSend(recipient, message, opts) {
|
|
|
608
642
|
// A comma in the recipient means a group DM — fan into the channel path.
|
|
609
643
|
const group = parseGroupRecipients(recipient);
|
|
610
644
|
if (group) {
|
|
645
|
+
if (opts.thread !== undefined) {
|
|
646
|
+
console.error(chalk.red("--thread is only supported for named channel messages (hq dm <channel> --thread <eventId|last> \"…\")."));
|
|
647
|
+
process.exit(1);
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
611
650
|
await runGroupSend(group, message);
|
|
612
651
|
return;
|
|
613
652
|
}
|
|
@@ -617,6 +656,11 @@ async function runDmSend(recipient, message, opts) {
|
|
|
617
656
|
await runChannelSend(channelName, message, opts);
|
|
618
657
|
return;
|
|
619
658
|
}
|
|
659
|
+
if (opts.thread !== undefined) {
|
|
660
|
+
console.error(chalk.red("--thread is only supported for channel messages (hq dm <channel> --thread <eventId|last> \"…\")."));
|
|
661
|
+
process.exit(1);
|
|
662
|
+
return;
|
|
663
|
+
}
|
|
620
664
|
try {
|
|
621
665
|
// Resolve prompt/details from inline text or a file.
|
|
622
666
|
let prompt = opts.prompt;
|
|
@@ -804,10 +848,162 @@ export function formatThreadMessage(m, nowMs) {
|
|
|
804
848
|
return `${arrow} ${chalk.bold(who)} ${when}\n ${firstLine(m.body)}${formatAttachments(m)}`;
|
|
805
849
|
}
|
|
806
850
|
/** Render one channel/group message line. */
|
|
807
|
-
export function formatChannelMessage(m, nowMs) {
|
|
851
|
+
export function formatChannelMessage(m, nowMs, opts = {}) {
|
|
852
|
+
const indent = opts.indent ?? "";
|
|
808
853
|
const who = chalk.bold(senderLabel(m));
|
|
809
854
|
const when = chalk.dim(formatRelativeTime(m.createdAt, nowMs));
|
|
810
|
-
|
|
855
|
+
const id = opts.showEventId ? (m.eventId ?? m.messageId) : undefined;
|
|
856
|
+
const idPart = id ? ` ${chalk.dim(id)}` : "";
|
|
857
|
+
const bodyIndent = `${indent} `;
|
|
858
|
+
return `${indent}${who} ${when}${idPart}\n${bodyIndent}${firstLine(m.body)}${formatAttachments(m, bodyIndent)}`;
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Read the caller's person/agent uid and email from a JWT we already hold.
|
|
862
|
+
* Used to resolve `--thread last` (most recent top-level message *by us*).
|
|
863
|
+
* Pure → unit-testable.
|
|
864
|
+
*/
|
|
865
|
+
export function callerFromToken(token) {
|
|
866
|
+
const claims = peekIdToken(token);
|
|
867
|
+
const uid = claims["custom:entityUid"];
|
|
868
|
+
const email = claims.email;
|
|
869
|
+
const personUid = typeof uid === "string" && uid.trim() ? uid.trim() : undefined;
|
|
870
|
+
const emailNorm = typeof email === "string" && email.trim()
|
|
871
|
+
? email.trim().toLowerCase()
|
|
872
|
+
: undefined;
|
|
873
|
+
return {
|
|
874
|
+
...(personUid ? { personUid } : {}),
|
|
875
|
+
...(emailNorm ? { email: emailNorm } : {}),
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
export function isCallerMessage(m, me) {
|
|
879
|
+
if (me.personUid && m.fromPersonUid === me.personUid)
|
|
880
|
+
return true;
|
|
881
|
+
if (me.email && (m.fromEmail ?? "").trim().toLowerCase() === me.email) {
|
|
882
|
+
return true;
|
|
883
|
+
}
|
|
884
|
+
return false;
|
|
885
|
+
}
|
|
886
|
+
function messageEventId(m) {
|
|
887
|
+
const id = (m.eventId ?? m.messageId ?? "").trim();
|
|
888
|
+
return id || undefined;
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Newest-first channel history → the caller's most recent top-level eventId.
|
|
892
|
+
* Replies (those with rootEventId) are skipped. Pure → unit-testable.
|
|
893
|
+
*/
|
|
894
|
+
export function lastCallerRootEventId(messagesNewestFirst, me) {
|
|
895
|
+
for (const m of messagesNewestFirst) {
|
|
896
|
+
if ((m.rootEventId ?? "").trim())
|
|
897
|
+
continue;
|
|
898
|
+
if (!isCallerMessage(m, me))
|
|
899
|
+
continue;
|
|
900
|
+
const id = messageEventId(m);
|
|
901
|
+
if (id)
|
|
902
|
+
return id;
|
|
903
|
+
}
|
|
904
|
+
return undefined;
|
|
905
|
+
}
|
|
906
|
+
/**
|
|
907
|
+
* Resolve `--thread <eventId|last>` against a newest-first message page.
|
|
908
|
+
* An explicit eventId is passed through; `last` picks our most recent
|
|
909
|
+
* top-level message. Pure → unit-testable (the fetch is the caller's job).
|
|
910
|
+
*/
|
|
911
|
+
export function resolveThreadFlag(thread, messagesNewestFirst, me) {
|
|
912
|
+
if (thread === undefined)
|
|
913
|
+
return { ok: true };
|
|
914
|
+
const value = thread.trim();
|
|
915
|
+
if (!value) {
|
|
916
|
+
return { ok: false, error: "--thread requires an eventId or 'last'." };
|
|
917
|
+
}
|
|
918
|
+
if (value.toLowerCase() !== "last") {
|
|
919
|
+
return { ok: true, rootEventId: value };
|
|
920
|
+
}
|
|
921
|
+
if (!me.personUid && !me.email) {
|
|
922
|
+
return {
|
|
923
|
+
ok: false,
|
|
924
|
+
error: "Cannot resolve --thread last: the session token has no person uid or email. Pass --thread <eventId> from `hq dm channel`.",
|
|
925
|
+
};
|
|
926
|
+
}
|
|
927
|
+
const id = lastCallerRootEventId(messagesNewestFirst, me);
|
|
928
|
+
if (!id) {
|
|
929
|
+
return {
|
|
930
|
+
ok: false,
|
|
931
|
+
error: "No top-level message from you in this channel's recent history. Pass --thread <eventId> (copy it from `hq dm channel <name>`).",
|
|
932
|
+
};
|
|
933
|
+
}
|
|
934
|
+
return { ok: true, rootEventId: id };
|
|
935
|
+
}
|
|
936
|
+
function sortByCreatedAt(a, b) {
|
|
937
|
+
return (a.createdAt ?? "").localeCompare(b.createdAt ?? "");
|
|
938
|
+
}
|
|
939
|
+
/**
|
|
940
|
+
* Group a channel history page into roots with their replies underneath.
|
|
941
|
+
* Orphan replies (root not in this page) hang off a placeholder carrying the
|
|
942
|
+
* missing root's eventId. Pure → unit-testable.
|
|
943
|
+
*/
|
|
944
|
+
export function groupChannelMessages(messages) {
|
|
945
|
+
const roots = [];
|
|
946
|
+
const repliesByRoot = new Map();
|
|
947
|
+
for (const m of messages) {
|
|
948
|
+
const rootId = (m.rootEventId ?? "").trim();
|
|
949
|
+
if (!rootId) {
|
|
950
|
+
roots.push(m);
|
|
951
|
+
continue;
|
|
952
|
+
}
|
|
953
|
+
const bucket = repliesByRoot.get(rootId) ?? [];
|
|
954
|
+
bucket.push(m);
|
|
955
|
+
repliesByRoot.set(rootId, bucket);
|
|
956
|
+
}
|
|
957
|
+
const used = new Set();
|
|
958
|
+
const threads = [];
|
|
959
|
+
roots.sort(sortByCreatedAt);
|
|
960
|
+
for (const root of roots) {
|
|
961
|
+
const id = messageEventId(root) ?? "";
|
|
962
|
+
if (id)
|
|
963
|
+
used.add(id);
|
|
964
|
+
threads.push({
|
|
965
|
+
root,
|
|
966
|
+
replies: (repliesByRoot.get(id) ?? []).slice().sort(sortByCreatedAt),
|
|
967
|
+
});
|
|
968
|
+
}
|
|
969
|
+
for (const [rootId, replies] of repliesByRoot) {
|
|
970
|
+
if (used.has(rootId))
|
|
971
|
+
continue;
|
|
972
|
+
const sorted = replies.slice().sort(sortByCreatedAt);
|
|
973
|
+
threads.push({
|
|
974
|
+
root: {
|
|
975
|
+
eventId: rootId,
|
|
976
|
+
body: "",
|
|
977
|
+
createdAt: sorted[0]?.createdAt ?? "",
|
|
978
|
+
},
|
|
979
|
+
replies: sorted,
|
|
980
|
+
missingRoot: true,
|
|
981
|
+
});
|
|
982
|
+
}
|
|
983
|
+
return threads;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Human channel history: each root prints its eventId so `--thread` can copy
|
|
987
|
+
* it; replies render indented under their root. `--json` does not use this.
|
|
988
|
+
* Pure → unit-testable.
|
|
989
|
+
*/
|
|
990
|
+
export function formatChannelTranscript(messages, nowMs) {
|
|
991
|
+
const threads = groupChannelMessages(messages);
|
|
992
|
+
const blocks = [];
|
|
993
|
+
for (const t of threads) {
|
|
994
|
+
const parts = [];
|
|
995
|
+
if (t.missingRoot) {
|
|
996
|
+
parts.push(chalk.dim(`… ${t.root.eventId ?? ""} (root not in this page)`));
|
|
997
|
+
}
|
|
998
|
+
else {
|
|
999
|
+
parts.push(formatChannelMessage(t.root, nowMs, { showEventId: true }));
|
|
1000
|
+
}
|
|
1001
|
+
for (const reply of t.replies) {
|
|
1002
|
+
parts.push(formatChannelMessage(reply, nowMs, { indent: " " }));
|
|
1003
|
+
}
|
|
1004
|
+
blocks.push(parts.join("\n"));
|
|
1005
|
+
}
|
|
1006
|
+
return blocks.join("\n\n");
|
|
811
1007
|
}
|
|
812
1008
|
/** POST /v1/notify/inbox/ack — idempotently mark messages read. */
|
|
813
1009
|
async function ackEvents(token, eventIds) {
|
|
@@ -1053,8 +1249,7 @@ async function runDmChannel(target, opts) {
|
|
|
1053
1249
|
else {
|
|
1054
1250
|
console.log(chalk.green(`${ordered.length} message${ordered.length === 1 ? "" : "s"}:`));
|
|
1055
1251
|
const now = Date.now();
|
|
1056
|
-
|
|
1057
|
-
console.log(`\n${formatChannelMessage(m, now)}`);
|
|
1252
|
+
console.log(`\n${formatChannelTranscript(ordered, now)}`);
|
|
1058
1253
|
}
|
|
1059
1254
|
if (opts.markRead) {
|
|
1060
1255
|
// messages are newest-first from the server; advance the read cursor to
|
|
@@ -1082,9 +1277,10 @@ export function registerDmCommand(program) {
|
|
|
1082
1277
|
.description("Send and read direct messages, and manage connection requests.");
|
|
1083
1278
|
dm
|
|
1084
1279
|
.command("send [recipient] [message]", { isDefault: true, hidden: true })
|
|
1085
|
-
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.')
|
|
1280
|
+
.description('Send a direct message. RECIPIENT can be a person (email, personUid, or agentUid), a GROUP DM (comma-separated: "a@x.com,b@y.com"), or one of your DM CHANNELS by name — bare (hq dm vyg-dev "hi"), hash form (hq dm "#vyg-dev" "hi"), or via --channel (hq dm --channel vyg-dev "hi"). A person receives a DM as an HQ Sync notification; an agent receives it in its durable box inbox. If you aren\'t connected yet, it sends a connection request that holds your message. See your channels with `hq channels`.\n\nCHANNEL MENTIONS: HQ mentions are structured — the server never reads `@name` out of your text, so a channel message has to carry the participants alongside the body. When you write `@name` in a channel message, this command looks the name up in the channel roster (case-insensitive on display name and slug/handle; quote multi-word names as @"Jacob Posel") and sends it as a real mention, which notifies people and wakes agents. If a name is unknown or matches more than one member, the send FAILS with the candidates instead of posting something that mentions nobody. A bare "@" or an email address in your prose is never treated as a mention. Pass --no-mentions to post the text literally.\n\nCHANNEL THREADS: `hq dm <channel> --thread <eventId> "…"` replies under an existing root (the request carries rootEventId). `--thread last` replies to your most recent top-level message in that channel. Copy a root eventId from `hq dm channel <name>`, which prints each root\'s id and indents replies underneath.')
|
|
1086
1281
|
.option("--channel <name>", "Post the message to one of your DM channels by name (e.g. --channel vyg-dev)")
|
|
1087
1282
|
.option("--no-mentions", "Do not resolve `@name` in a channel message into structured mentions — post the text literally")
|
|
1283
|
+
.option("--thread <eventId>", 'Reply in a channel thread. Pass a root eventId from `hq dm channel`, or `last` for your most recent top-level message in that channel')
|
|
1088
1284
|
.option("--prompt <text>", "Agent-context prompt the recipient can one-click copy into their agent (1:1 DMs only)")
|
|
1089
1285
|
.option("--prompt-file <path>", "Read the agent prompt from a file (1:1 DMs only)")
|
|
1090
1286
|
.option("--details <text>", "Longer detail shown in the recipient's DM detail window (1:1 DMs only)")
|
|
@@ -1117,7 +1313,7 @@ export function registerDmCommand(program) {
|
|
|
1117
1313
|
dm
|
|
1118
1314
|
.command("channel <target>")
|
|
1119
1315
|
.alias("history")
|
|
1120
|
-
.description("Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`.")
|
|
1316
|
+
.description("Show recent messages in a DM channel or group DM — by name, #name, or a channel id from `hq channels`. Roots print their eventId; replies indent under their root. --json is the raw oldest-first array (ungrouped).")
|
|
1121
1317
|
.option("--limit <n>", "Max messages to fetch (server-capped)")
|
|
1122
1318
|
.option("--mark-read", "Advance your read marker to the newest message")
|
|
1123
1319
|
.option("--json", "Output raw JSON instead of a transcript")
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
* hq whoami — displays current user or 'not logged in'
|
|
3
3
|
*/
|
|
4
4
|
import { Command } from 'commander';
|
|
5
|
-
import { type
|
|
5
|
+
import { type PlanLimitStatus } from "../lib/billing/plan-lock.js";
|
|
6
6
|
export interface WhoamiTokenIdentity {
|
|
7
7
|
email?: string;
|
|
8
8
|
sub?: string;
|
|
@@ -25,6 +25,6 @@ export declare function resolveWhoamiIdentity(identity: WhoamiTokenIdentity): Wh
|
|
|
25
25
|
* workspace is over its Starter limits, the one-line `Plan: Starter — …`
|
|
26
26
|
* orientation when it is Starter and healthy, and nothing at all otherwise.
|
|
27
27
|
*/
|
|
28
|
-
export declare function renderWhoamiPlanBlock(status:
|
|
28
|
+
export declare function renderWhoamiPlanBlock(status: PlanLimitStatus): string | null;
|
|
29
29
|
export declare function registerWhoamiCommand(program: Command): void;
|
|
30
30
|
//# sourceMappingURL=whoami.d.ts.map
|
package/dist/commands/whoami.js
CHANGED
|
@@ -5,7 +5,7 @@ import chalk from 'chalk';
|
|
|
5
5
|
import { loadCachedTokens, isExpiring, loadMachineCreds, } from '@indigoai-us/hq-cloud';
|
|
6
6
|
import { peekIdToken as decodeIdToken } from "../utils/id-token.js";
|
|
7
7
|
import { ensureCognitoToken, loadMachineCachedTokens, resolveCognitoTokenSource, } from "../utils/cognito-session.js";
|
|
8
|
-
import { colorizePlanLimitNotice,
|
|
8
|
+
import { colorizePlanLimitNotice, fetchPlanLimitStatus, renderPlanLine, renderPlanLimitNotice, } from "../lib/billing/plan-lock.js";
|
|
9
9
|
/**
|
|
10
10
|
* Resolves the person represented by an ID token without pairing a delegated
|
|
11
11
|
* email with the token subject of the delegating machine.
|
|
@@ -42,27 +42,27 @@ function peekIdToken(idToken) {
|
|
|
42
42
|
};
|
|
43
43
|
}
|
|
44
44
|
/**
|
|
45
|
-
* starter-plan-hard-limits / US-011 — read the workspace plan
|
|
46
|
-
* `--company <slug
|
|
45
|
+
* starter-plan-hard-limits / US-011 — read the workspace plan-limit nag for
|
|
46
|
+
* `--company <slug>` (the bind notice).
|
|
47
47
|
*
|
|
48
|
-
* hq-pro owns the
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
48
|
+
* hq-pro owns the numbers (`GET /v1/billing/usage-limits`); this only reads
|
|
49
|
+
* them. Best-effort by construction: no session, a network failure, or a
|
|
50
|
+
* company the caller is not a member of all resolve to null — reported as
|
|
51
|
+
* UNKNOWN and rendered as nothing, never as a guessed over-limit or a guessed
|
|
52
|
+
* all-clear, and never as a command failure. The command exists to answer
|
|
53
|
+
* "who am I"; the plan line is decoration on top.
|
|
54
54
|
*/
|
|
55
|
-
async function
|
|
55
|
+
async function readPlanLimits(company) {
|
|
56
56
|
const slug = company?.trim();
|
|
57
57
|
if (!slug)
|
|
58
58
|
return null;
|
|
59
59
|
try {
|
|
60
60
|
// NEVER interactive: this runs inside `--json` (before the authenticated
|
|
61
|
-
// check) and inside hq-core's per-turn plan-
|
|
61
|
+
// check) and inside hq-core's per-turn plan-limit refresh hook. With the
|
|
62
62
|
// default `interactive: true`, a box with no cached session would launch a
|
|
63
63
|
// browser sign-in and block the caller instead of resolving to UNKNOWN.
|
|
64
64
|
const token = await ensureCognitoToken({ interactive: false });
|
|
65
|
-
return await
|
|
65
|
+
return await fetchPlanLimitStatus(token, slug);
|
|
66
66
|
}
|
|
67
67
|
catch {
|
|
68
68
|
return null;
|
|
@@ -74,13 +74,13 @@ async function readPlanLock(company) {
|
|
|
74
74
|
* orientation when it is Starter and healthy, and nothing at all otherwise.
|
|
75
75
|
*/
|
|
76
76
|
export function renderWhoamiPlanBlock(status) {
|
|
77
|
-
if (status.
|
|
77
|
+
if (status.over) {
|
|
78
78
|
return colorizePlanLimitNotice(renderPlanLimitNotice(status));
|
|
79
79
|
}
|
|
80
80
|
return renderPlanLine(status);
|
|
81
81
|
}
|
|
82
82
|
async function printPlanBlock(company) {
|
|
83
|
-
const status = await
|
|
83
|
+
const status = await readPlanLimits(company);
|
|
84
84
|
if (!status)
|
|
85
85
|
return;
|
|
86
86
|
const block = renderWhoamiPlanBlock(status);
|
|
@@ -92,7 +92,7 @@ export function registerWhoamiCommand(program) {
|
|
|
92
92
|
.command('whoami')
|
|
93
93
|
.description('Show the currently authenticated user')
|
|
94
94
|
.option('--json', 'Output identity metadata as JSON (never tokens)')
|
|
95
|
-
.option('--company <slug>', 'Also report this company\'s workspace plan state (Starter
|
|
95
|
+
.option('--company <slug>', 'Also report this company\'s workspace plan state (Starter nag)')
|
|
96
96
|
.action(async (opts) => {
|
|
97
97
|
try {
|
|
98
98
|
// Effective source (same as auth status / ensureCognitoToken): env
|
|
@@ -115,9 +115,8 @@ export function registerWhoamiCommand(program) {
|
|
|
115
115
|
const agentUid = machineCreds?.entityUid !== undefined
|
|
116
116
|
? (machineCreds.entityUid.startsWith('agt_') ? machineCreds.entityUid : null)
|
|
117
117
|
: boundLegacyAgent;
|
|
118
|
-
//
|
|
119
|
-
|
|
120
|
-
const planLock = await readPlanLock(opts.company);
|
|
118
|
+
// planLimits is additive: absent-or-null means UNKNOWN.
|
|
119
|
+
const planLimits = await readPlanLimits(opts.company);
|
|
121
120
|
console.log(JSON.stringify({
|
|
122
121
|
schemaVersion: 1,
|
|
123
122
|
authenticated: machine ? Boolean(machineCreds) : Boolean(cached && !isExpiring(cached, 0)),
|
|
@@ -126,7 +125,7 @@ export function registerWhoamiCommand(program) {
|
|
|
126
125
|
personUid: !machine && identity.entityType === 'person' ? identity.entityUid ?? null : null,
|
|
127
126
|
agentUid,
|
|
128
127
|
username: machineCreds?.username ?? null,
|
|
129
|
-
|
|
128
|
+
planLimits,
|
|
130
129
|
}, null, 2));
|
|
131
130
|
return;
|
|
132
131
|
}
|