@indigoai-us/hq-cli 5.345.188 → 5.345.189
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 +97 -0
- package/dist/command-catalog.generated.d.ts +34 -0
- package/dist/command-catalog.generated.js +42 -0
- package/dist/commands/bot.js +15 -3
- package/dist/commands/core-company-index-scope-flag.d.ts +2 -1
- package/dist/commands/core-company-index-scope-flag.js +2 -1
- package/dist/commands/core.js +0 -5
- package/dist/commands/dm-attach.d.ts +73 -0
- package/dist/commands/dm-attach.js +104 -0
- package/dist/commands/dm.d.ts +10 -0
- package/dist/commands/dm.js +114 -2
- package/dist/commands/sources.js +15 -0
- package/dist/hq-cloud-sync-runner/bin/sync-runner.js +45054 -36140
- package/dist/hq-cloud-sync-runner/package.json +1 -1
- package/dist/lib/bot/fleet-local.d.ts +3 -1
- package/dist/lib/bot/fleet-local.js +9 -3
- package/dist/lib/bot/fleet-python.d.ts +6 -0
- package/dist/lib/bot/fleet-python.js +24 -5
- package/dist/lib/bot/probe.d.ts +7 -1
- package/dist/lib/bot/probe.js +11 -3
- package/dist/lib/bot/run.d.ts +9 -0
- package/dist/lib/bot/run.js +114 -8
- package/dist/lib/bot/runtime-failure.d.ts +3 -0
- package/dist/lib/bot/runtime-failure.js +20 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/index-render/session-scope.d.ts +3 -3
- package/dist/lib/index-render/session-scope.js +11 -9
- package/dist/lib/index-render/shared.d.ts +6 -4
- package/dist/lib/index-render/shared.js +33 -4
- package/dist/utils/cognito-session.d.ts +4 -2
- package/dist/utils/cognito-session.js +27 -42
- package/dist/utils/hq-root-resolver.d.ts +27 -1
- package/dist/utils/hq-root-resolver.js +65 -33
- package/package.json +14 -14
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,103 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [5.345.189] — 2026-11-01
|
|
6
|
+
- A local bot that cannot reach HQ when it starts now retries the identity check up to five times
|
|
7
|
+
(waits of 5 s, 15 s, 45 s and 2 min) on a network error (connection refused, reset, DNS failure,
|
|
8
|
+
timeout), 408, 429 or 5xx. Before, one network error at startup failed the bot with exit 0, which
|
|
9
|
+
launchd never restarts, so bots stayed down until someone restarted them. After the last try the bot
|
|
10
|
+
stops in the failed state with a plain reason that says to check the network and run
|
|
11
|
+
`hq bot start <name>`. Other 4xx answers, sign-in errors and internal errors fail at once, as before.
|
|
12
|
+
A stop during a retry wait ends the bot cleanly as stopped.
|
|
13
|
+
- Hosted agent Docker image now runs on Node 26 (was Node 22).
|
|
14
|
+
- Updated 16 bundled libraries to their latest minor and patch releases. The local search engine stays on its current version so installing HQ still works on machines without build tools.
|
|
15
|
+
- On Windows, the CLI's last-resort HQ folder is now `C:\hq` (`%SystemDrive%\hq`) instead of
|
|
16
|
+
`%USERPROFILE%\hq`. `$HQ_ROOT`, the HQ folder you are inside, and the location recorded in
|
|
17
|
+
`~/.hq/menubar.json` or `~/.hq/config.json` still win. Discovery checks the home folders first
|
|
18
|
+
(`C:\Users\<you>\hq` and the usual variants), then `C:\hq`, then `%LOCALAPPDATA%\hq`, so an
|
|
19
|
+
existing home install keeps working and is preferred over a stray `C:\hq`.
|
|
20
|
+
macOS and Linux are unchanged.
|
|
21
|
+
- `hq bot create` no longer hangs before it reaches the server. The Fleet
|
|
22
|
+
Runtime setup steps (`gh auth token`, `tar`, `uv python install`,
|
|
23
|
+
`uv python find`) now time out and report "timed out after Ns".
|
|
24
|
+
- Under `--json`, any create failure prints a `{ ok: false, reason:
|
|
25
|
+
"create-failed" }` document on stdout, so HQ Desktop can show it.
|
|
26
|
+
- `hq bot probe` and bot failure messages now recognize a coding tool's plan
|
|
27
|
+
usage limit (for example Claude Code's "You've hit your weekly limit · resets
|
|
28
|
+
9pm"). They used to call it `unknown`. It is now classed `transient`, the
|
|
29
|
+
probe's JSON gains `usageLimit: true` and `resetsAt` when the tool gives a
|
|
30
|
+
reset time, and the detail says the plan hit its limit and when it resets.
|
|
31
|
+
- `hq dm attachment <eventId|vaultPath>` downloads a file attached to a DM.
|
|
32
|
+
- The recipient can now open a file held in the sender's vault.
|
|
33
|
+
- The sender can open its own attachment.
|
|
34
|
+
- Several files with the same name on one message are saved as
|
|
35
|
+
name.ext, name-1.ext, and so on.
|
|
36
|
+
- Every destination is checked before any file is written.
|
|
37
|
+
- A company's `INDEX.md` now shows a project's own recorded status instead of listing every unfinished project as active. `hq core rebuild-index companies` reads the project's `state.json` status first, then `prd.json` `status` or `metadata.status`, and only then falls back to the story-pass check.
|
|
38
|
+
- `hq core rebuild-index companies` in a company-bound session now rebuilds only that session's companies, even when the company-scope kill switch is off. Before, it could rewrite every company's INDEX.md. `--company <slug>` works again for a company inside the bound set.
|
|
39
|
+
- `hq sources schedule --at HH:MM` now turns the daily hot-channel sync on when a company doesn't have it yet. It registers the sync on your personal setup bot and sets the time. Before this change, the command failed with "No hot-channel sync job", so the desktop's Change time couldn't start the sync.
|
|
40
|
+
- `hq-hook dispatch <EVENT>` runs a hook event the way master-hook.sh does,
|
|
41
|
+
in one process (US-007).
|
|
42
|
+
- Registry hooks come from the compiled index, through the hook-gate
|
|
43
|
+
profiles, `HQ_DISABLED_HOOKS` and the prefilters. Folder hooks run after
|
|
44
|
+
them in master-hook's order.
|
|
45
|
+
- A fail-closed PreToolUse guard that is missing, crashes or times out
|
|
46
|
+
blocks the event and names the guard. Every hook failure is recorded in
|
|
47
|
+
`~/.hq/logs/hook-engine.jsonl`.
|
|
48
|
+
- Built-in hook ports run in-process under the bridge flags; a port that
|
|
49
|
+
panics makes the bash hook run.
|
|
50
|
+
- Events the engine cannot reproduce exactly (session start, per-agent
|
|
51
|
+
scope, the timeout sentry, HQ Anywhere routing) run through
|
|
52
|
+
master-hook.sh with the same input.
|
|
53
|
+
- Nothing is registered to use it yet; exec-form registration (US-015) and
|
|
54
|
+
the cutover (US-017) come later.
|
|
55
|
+
- `hq-hook` gains a compiled hook index (US-006): one memory-mapped file per
|
|
56
|
+
HQ root and policy-directory list with the hook registry rows, the
|
|
57
|
+
`policy_vocab` prefilter vocabulary and the policy injector's parsed records.
|
|
58
|
+
- It rebuilds only when a source file's metadata changes. A symlink is
|
|
59
|
+
tracked by the file it points to, so re-linking the personal overlay does
|
|
60
|
+
not rebuild it.
|
|
61
|
+
- Each company's index holds only that company's policies.
|
|
62
|
+
- `hq-hook index records|vocab|registry` print the same bytes as the shell
|
|
63
|
+
code they replace, which the new parity test checks.
|
|
64
|
+
- `hq-hook index probe|bench` measure the per-event cost for the
|
|
65
|
+
go/no-go report. Nothing in the hook path uses the index yet; the
|
|
66
|
+
dispatcher (US-007) will.
|
|
67
|
+
- `hq-hook run block-credential-file-read` is now a full native port of the
|
|
68
|
+
bash guard and its arithmetic library, under flag
|
|
69
|
+
`hook-engine-port-block-credential-file-read`, which stays off.
|
|
70
|
+
- It answers every call itself and never exits 75 (hand back to bash).
|
|
71
|
+
- Exit code, stdout and stderr match the guard byte for byte on the guard's
|
|
72
|
+
corpus (about 1,500 payloads), 330 recorded real calls and about 6,800
|
|
73
|
+
fuzz payloads.
|
|
74
|
+
- The bridge now also pins `bash-arith-subst-guard-lib.sh`. A changed or
|
|
75
|
+
missing library sends the call to bash, as a changed guard does.
|
|
76
|
+
- `hq-hook run block-credential-file-read` is the second hook port
|
|
77
|
+
master-hook.sh can bridge (US-022, flag
|
|
78
|
+
`hook-engine-port-block-credential-file-read`, default off).
|
|
79
|
+
- It answers natively the Read and Bash calls that the guard's own fast path
|
|
80
|
+
allows and that name no credential file, pass through no symlink and use
|
|
81
|
+
no substitution.
|
|
82
|
+
- Every other call exits 75 and the bash guard decides, keeping its
|
|
83
|
+
fail-closed behavior.
|
|
84
|
+
- The port never blocks.
|
|
85
|
+
- It is pinned to the guard version it was checked against.
|
|
86
|
+
- The guard's 400-payload corpus shows zero verdict differences with the
|
|
87
|
+
port in front.
|
|
88
|
+
- `hq-hook`: the `block-credential-file-read` port accepts the guard script on
|
|
89
|
+
hq-core-staging main (9be16758, #1727). The guard's fast path gained the
|
|
90
|
+
arithmetic guard, the split-name rule, the `git show <ref>:<path>` rule and
|
|
91
|
+
an xargs/parallel prefilter term since the port was written; the port now
|
|
92
|
+
declines every command those can refuse (`$`, `((`, declaration keywords,
|
|
93
|
+
the new prefilter term) and keeps answering only what bash's fast path
|
|
94
|
+
allows. The vendored fixture and corpus are refreshed to that version.
|
|
95
|
+
- `hq-hook`: the `policy-enforcement-gate` port accepts the gate script from
|
|
96
|
+
hq-core-staging #1717 (read-only `hq dm` subcommands skipped by the outbound
|
|
97
|
+
scan). Without this pin the bridge logged `script-mismatch` on every current
|
|
98
|
+
HQ and ran bash alone. The port's allow set is unchanged: it already leaves
|
|
99
|
+
every command naming `hq` and `dm` to bash. The vendored fixture is refreshed
|
|
100
|
+
to that version and the pin test covers it.
|
|
101
|
+
|
|
5
102
|
## [5.345.188] — 2026-10-30
|
|
6
103
|
- `hq agent inbox` and the `hq_inbox_read` MCP tool now bound the server read (token mint plus GET) at 30 s
|
|
7
104
|
(`HQ_AGENT_INBOX_TIMEOUT_MS`, floor 1000) and retry it once after a short delay, honouring the server's
|
|
@@ -4175,6 +4175,40 @@ export declare const COMMAND_CATALOG: readonly [{
|
|
|
4175
4175
|
readonly hidden: false;
|
|
4176
4176
|
}];
|
|
4177
4177
|
readonly subcommands: readonly [];
|
|
4178
|
+
}, {
|
|
4179
|
+
readonly name: "attachment";
|
|
4180
|
+
readonly description: "Download a file attached to a DM. REF is the message eventId from `hq dm inbox` (downloads every file on it) or the attachment's vault path (chat/attachments/...). The file is read from the sender's vault; only the two people in the conversation can open a DM attachment.";
|
|
4181
|
+
readonly aliases: readonly [];
|
|
4182
|
+
readonly hidden: false;
|
|
4183
|
+
readonly helpOption: true;
|
|
4184
|
+
readonly helpCommand: false;
|
|
4185
|
+
readonly usage: "[options] <ref>";
|
|
4186
|
+
readonly helpText: readonly [];
|
|
4187
|
+
readonly arguments: readonly [{
|
|
4188
|
+
readonly name: "ref";
|
|
4189
|
+
readonly required: true;
|
|
4190
|
+
readonly variadic: false;
|
|
4191
|
+
readonly description: "";
|
|
4192
|
+
}];
|
|
4193
|
+
readonly options: readonly [{
|
|
4194
|
+
readonly flags: "--out <path>";
|
|
4195
|
+
readonly description: "Where to write: a file path for one attachment, else a directory (default: current directory)";
|
|
4196
|
+
readonly hidden: false;
|
|
4197
|
+
}, {
|
|
4198
|
+
readonly flags: "--company <uid>";
|
|
4199
|
+
readonly description: "Vault scope the file lives in (the companyUid shown on the message). Derived from the DM pair when omitted";
|
|
4200
|
+
readonly hidden: false;
|
|
4201
|
+
}, {
|
|
4202
|
+
readonly flags: "--force";
|
|
4203
|
+
readonly description: "Overwrite an existing file";
|
|
4204
|
+
readonly hidden: false;
|
|
4205
|
+
}, {
|
|
4206
|
+
readonly flags: "--limit <n>";
|
|
4207
|
+
readonly description: "How many recent inbox messages to search for the eventId";
|
|
4208
|
+
readonly hidden: false;
|
|
4209
|
+
readonly defaultValue: "100";
|
|
4210
|
+
}];
|
|
4211
|
+
readonly subcommands: readonly [];
|
|
4178
4212
|
}, {
|
|
4179
4213
|
readonly name: "thread";
|
|
4180
4214
|
readonly description: "Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.";
|
|
@@ -5059,6 +5059,48 @@ export const COMMAND_CATALOG = [
|
|
|
5059
5059
|
],
|
|
5060
5060
|
"subcommands": []
|
|
5061
5061
|
},
|
|
5062
|
+
{
|
|
5063
|
+
"name": "attachment",
|
|
5064
|
+
"description": "Download a file attached to a DM. REF is the message eventId from `hq dm inbox` (downloads every file on it) or the attachment's vault path (chat/attachments/...). The file is read from the sender's vault; only the two people in the conversation can open a DM attachment.",
|
|
5065
|
+
"aliases": [],
|
|
5066
|
+
"hidden": false,
|
|
5067
|
+
"helpOption": true,
|
|
5068
|
+
"helpCommand": false,
|
|
5069
|
+
"usage": "[options] <ref>",
|
|
5070
|
+
"helpText": [],
|
|
5071
|
+
"arguments": [
|
|
5072
|
+
{
|
|
5073
|
+
"name": "ref",
|
|
5074
|
+
"required": true,
|
|
5075
|
+
"variadic": false,
|
|
5076
|
+
"description": ""
|
|
5077
|
+
}
|
|
5078
|
+
],
|
|
5079
|
+
"options": [
|
|
5080
|
+
{
|
|
5081
|
+
"flags": "--out <path>",
|
|
5082
|
+
"description": "Where to write: a file path for one attachment, else a directory (default: current directory)",
|
|
5083
|
+
"hidden": false
|
|
5084
|
+
},
|
|
5085
|
+
{
|
|
5086
|
+
"flags": "--company <uid>",
|
|
5087
|
+
"description": "Vault scope the file lives in (the companyUid shown on the message). Derived from the DM pair when omitted",
|
|
5088
|
+
"hidden": false
|
|
5089
|
+
},
|
|
5090
|
+
{
|
|
5091
|
+
"flags": "--force",
|
|
5092
|
+
"description": "Overwrite an existing file",
|
|
5093
|
+
"hidden": false
|
|
5094
|
+
},
|
|
5095
|
+
{
|
|
5096
|
+
"flags": "--limit <n>",
|
|
5097
|
+
"description": "How many recent inbox messages to search for the eventId",
|
|
5098
|
+
"hidden": false,
|
|
5099
|
+
"defaultValue": "100"
|
|
5100
|
+
}
|
|
5101
|
+
],
|
|
5102
|
+
"subcommands": []
|
|
5103
|
+
},
|
|
5062
5104
|
{
|
|
5063
5105
|
"name": "thread",
|
|
5064
5106
|
"description": "Show your two-way conversation with a person (email, personUid, or agentUid). Reads oldest-first and marks their messages read unless --no-ack.",
|
package/dist/commands/bot.js
CHANGED
|
@@ -665,8 +665,20 @@ async function promptFleetProvider() {
|
|
|
665
665
|
* its half-written folder.
|
|
666
666
|
*/
|
|
667
667
|
export async function runBotCreate(nameArg, opts) {
|
|
668
|
-
|
|
669
|
-
|
|
668
|
+
try {
|
|
669
|
+
const name = validateBotName(nameArg);
|
|
670
|
+
await withBotLock(name, () => runBotCreateLocked(name, opts), { command: "hq bot create" });
|
|
671
|
+
}
|
|
672
|
+
catch (err) {
|
|
673
|
+
// Under --json the desktop app reads stdout: a failure, including one in
|
|
674
|
+
// the local steps before the create reaches HQ, must leave a document
|
|
675
|
+
// there too, not only a line on stderr.
|
|
676
|
+
if (opts.json && !err?.jsonReported) {
|
|
677
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
678
|
+
console.log(JSON.stringify({ ok: false, name: nameArg, reason: "create-failed", message }, null, 2));
|
|
679
|
+
}
|
|
680
|
+
throw err;
|
|
681
|
+
}
|
|
670
682
|
}
|
|
671
683
|
/**
|
|
672
684
|
* Write a bot's new name to the cloud. Never fails the caller's command when
|
|
@@ -880,7 +892,7 @@ async function runBotCreateLocked(name, opts) {
|
|
|
880
892
|
return false;
|
|
881
893
|
if (opts.json)
|
|
882
894
|
console.log(JSON.stringify({ ok: false, ...failure }, null, 2));
|
|
883
|
-
throw Object.assign(new Error(failure.message), { expected: true });
|
|
895
|
+
throw Object.assign(new Error(failure.message), { expected: true, jsonReported: opts.json === true });
|
|
884
896
|
}
|
|
885
897
|
const matches = matchRemoteBots(mine, name);
|
|
886
898
|
if (matches.length > 1) {
|
|
@@ -4,7 +4,8 @@ export { CORE_COMPANY_INDEX_SCOPE_FLAG };
|
|
|
4
4
|
* Kill switch for company-scoped index rebuilds, default ON
|
|
5
5
|
* (indigo-feature-flags-default-on-as-fail-safes). An absent, archived or
|
|
6
6
|
* unreadable flag row keeps scoping on; only an explicit false turns it off,
|
|
7
|
-
* and then `companies`
|
|
7
|
+
* and then an unbound `companies` rebuild writes every company as it did
|
|
8
|
+
* before scoping. A bound session stays scoped either way.
|
|
8
9
|
*/
|
|
9
10
|
export declare function coreCompanyIndexScopeEnabled(): boolean;
|
|
10
11
|
//# sourceMappingURL=core-company-index-scope-flag.d.ts.map
|
|
@@ -5,7 +5,8 @@ export { CORE_COMPANY_INDEX_SCOPE_FLAG };
|
|
|
5
5
|
* Kill switch for company-scoped index rebuilds, default ON
|
|
6
6
|
* (indigo-feature-flags-default-on-as-fail-safes). An absent, archived or
|
|
7
7
|
* unreadable flag row keeps scoping on; only an explicit false turns it off,
|
|
8
|
-
* and then `companies`
|
|
8
|
+
* and then an unbound `companies` rebuild writes every company as it did
|
|
9
|
+
* before scoping. A bound session stays scoped either way.
|
|
9
10
|
*/
|
|
10
11
|
export function coreCompanyIndexScopeEnabled() {
|
|
11
12
|
return resolveProcessFlagGate(CORE_COMPANY_INDEX_SCOPE_FLAG, { fallback: true }, () => true, undefined, { acceptStaleSnapshot: true, killSwitchWithoutCompany: true });
|
package/dist/commands/core.js
CHANGED
|
@@ -668,7 +668,6 @@ export function registerCoreCommands(program) {
|
|
|
668
668
|
const passthrough = operands.slice(1);
|
|
669
669
|
const force = passthrough.includes("--accept");
|
|
670
670
|
const rendererArgs = passthrough.filter((arg) => arg !== "--accept");
|
|
671
|
-
const isCompaniesTarget = entry.renderer === "companies";
|
|
672
671
|
const wantsHelp = rendererArgs.some((arg) => arg === "--help" || arg === "-h");
|
|
673
672
|
if (wantsHelp && ["companies", "company-knowledge", "projects"].includes(entry.renderer)) {
|
|
674
673
|
renderIndexTarget(entry.renderer, { root: "", output: (message) => process.stdout.write(message) }, rendererArgs);
|
|
@@ -681,10 +680,6 @@ export function registerCoreCommands(program) {
|
|
|
681
680
|
process.stdout.write(`Usage: hq core rebuild-index ${entry.target} [--accept]\n\n${entry.summary}. Help performs no writes.\n`);
|
|
682
681
|
return;
|
|
683
682
|
}
|
|
684
|
-
const requestsCompanyScope = isCompaniesTarget && rendererArgs.some((arg) => arg === "--company" || arg.startsWith("--company="));
|
|
685
|
-
if (requestsCompanyScope && !coreCompanyIndexScopeEnabled()) {
|
|
686
|
-
throw expectedUserError("rebuild-index companies --company is disabled; enable cli.rebuild-index-company-scope for a canary.");
|
|
687
|
-
}
|
|
688
683
|
const hqRoot = resolveLiveRoot({ hqRoot: scope.hqRoot });
|
|
689
684
|
const refused = [];
|
|
690
685
|
try {
|
|
@@ -103,4 +103,77 @@ export declare function attachmentScopeForChannel(channel: {
|
|
|
103
103
|
export declare function personalScopeForCaller(callerUid: string | undefined): string;
|
|
104
104
|
/** Render the confirmation lines listing where each attachment landed. */
|
|
105
105
|
export declare function formatUploadedAttachments(files: readonly DmAttachmentWire[]): string;
|
|
106
|
+
/** A parsed chat attachment key: chat/attachments/<dm|chan>/<scopeId>/<file>. */
|
|
107
|
+
export interface ParsedChatAttachmentKey {
|
|
108
|
+
scope: "dm" | "chan";
|
|
109
|
+
scopeId: string;
|
|
110
|
+
file: string;
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Parse a chat attachment vault key. Returns null for anything that is not a
|
|
114
|
+
* single file directly under a conversation folder (no nesting, no `..`). Pure.
|
|
115
|
+
*/
|
|
116
|
+
export declare function parseChatAttachmentKey(key: string): ParsedChatAttachmentKey | null;
|
|
117
|
+
/** One attachment reference as it appears on a message row. */
|
|
118
|
+
export interface AttachmentRef {
|
|
119
|
+
vaultPath: string;
|
|
120
|
+
companyUid?: string;
|
|
121
|
+
name?: string;
|
|
122
|
+
/** Who sent the message carrying this attachment, when known. */
|
|
123
|
+
senderUid?: string;
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The vault scopes to try, in order, for a READ of a chat attachment. An
|
|
127
|
+
* explicit `companyUid` (message metadata or `--company`) wins. Otherwise a
|
|
128
|
+
* 1:1 DM attachment lives in the SENDER's personal vault (see
|
|
129
|
+
* `personalScopeForCaller`): when `senderUid` is known and in the pair, that
|
|
130
|
+
* is the only scope. With no sender (a bare vault path), the pair id is sorted
|
|
131
|
+
* and does not say who sent it, so the counterparty's vault is tried first and
|
|
132
|
+
* then the caller's own (the caller may be the sender). The server enforces
|
|
133
|
+
* that the caller is one of the two; this only picks the vault. Throws when no
|
|
134
|
+
* scope can be decided. Pure.
|
|
135
|
+
*/
|
|
136
|
+
export declare function attachmentReadScopes(args: {
|
|
137
|
+
vaultPath: string;
|
|
138
|
+
companyUid?: string;
|
|
139
|
+
callerUid?: string;
|
|
140
|
+
senderUid?: string;
|
|
141
|
+
}): string[];
|
|
142
|
+
/** The first (most likely) read scope from `attachmentReadScopes`. Pure. */
|
|
143
|
+
export declare function attachmentReadScope(args: {
|
|
144
|
+
vaultPath: string;
|
|
145
|
+
companyUid?: string;
|
|
146
|
+
callerUid?: string;
|
|
147
|
+
senderUid?: string;
|
|
148
|
+
}): string;
|
|
149
|
+
/**
|
|
150
|
+
* The attachments on the message with `eventId` (or `messageId`) in a list of
|
|
151
|
+
* message rows. Returns null when no row matches. Pure.
|
|
152
|
+
*/
|
|
153
|
+
export declare function findMessageAttachments(rows: ReadonlyArray<{
|
|
154
|
+
eventId?: string;
|
|
155
|
+
messageId?: string;
|
|
156
|
+
fromPersonUid?: string;
|
|
157
|
+
attachments?: AttachmentRef[];
|
|
158
|
+
attachment?: AttachmentRef;
|
|
159
|
+
}>, eventId: string): AttachmentRef[] | null;
|
|
160
|
+
export interface AttachmentDownloadDeps {
|
|
161
|
+
/** POST /v1/files/presign with `{ company, keys }`. */
|
|
162
|
+
presign: (token: string, body: Record<string, unknown>) => Promise<Response>;
|
|
163
|
+
/** GET the presigned URL. */
|
|
164
|
+
get: (url: string) => Promise<Response>;
|
|
165
|
+
}
|
|
166
|
+
export declare const defaultAttachmentDownloadDeps: AttachmentDownloadDeps;
|
|
167
|
+
/**
|
|
168
|
+
* Presign a GET for one chat attachment in `scopeUid` and return its bytes.
|
|
169
|
+
* hq-pro files-presign admits a counterparty read of the sender's personal
|
|
170
|
+
* vault only when the caller is a member of the DM pair; a refusal (whole
|
|
171
|
+
* request or per key) surfaces as an Error.
|
|
172
|
+
*/
|
|
173
|
+
export declare function downloadChatAttachment(args: {
|
|
174
|
+
token: string;
|
|
175
|
+
scopeUid: string;
|
|
176
|
+
vaultPath: string;
|
|
177
|
+
deps?: AttachmentDownloadDeps;
|
|
178
|
+
}): Promise<Buffer>;
|
|
106
179
|
//# sourceMappingURL=dm-attach.d.ts.map
|
|
@@ -193,4 +193,108 @@ export function formatUploadedAttachments(files) {
|
|
|
193
193
|
.map((f) => ` [file] ${f.name} (${formatBytes(f.sizeBytes)})\n vault: ${f.vaultPath}`)
|
|
194
194
|
.join("\n");
|
|
195
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* Parse a chat attachment vault key. Returns null for anything that is not a
|
|
198
|
+
* single file directly under a conversation folder (no nesting, no `..`). Pure.
|
|
199
|
+
*/
|
|
200
|
+
export function parseChatAttachmentKey(key) {
|
|
201
|
+
const parts = key.trim().split("/");
|
|
202
|
+
if (parts.length !== 5)
|
|
203
|
+
return null;
|
|
204
|
+
const [a, b, scope, scopeId, file] = parts;
|
|
205
|
+
if (a !== "chat" || b !== "attachments")
|
|
206
|
+
return null;
|
|
207
|
+
if (scope !== "dm" && scope !== "chan")
|
|
208
|
+
return null;
|
|
209
|
+
if (!scopeId || scopeId === "." || scopeId === "..")
|
|
210
|
+
return null;
|
|
211
|
+
if (!file || file === "." || file === "..")
|
|
212
|
+
return null;
|
|
213
|
+
return { scope, scopeId, file };
|
|
214
|
+
}
|
|
215
|
+
/**
|
|
216
|
+
* The vault scopes to try, in order, for a READ of a chat attachment. An
|
|
217
|
+
* explicit `companyUid` (message metadata or `--company`) wins. Otherwise a
|
|
218
|
+
* 1:1 DM attachment lives in the SENDER's personal vault (see
|
|
219
|
+
* `personalScopeForCaller`): when `senderUid` is known and in the pair, that
|
|
220
|
+
* is the only scope. With no sender (a bare vault path), the pair id is sorted
|
|
221
|
+
* and does not say who sent it, so the counterparty's vault is tried first and
|
|
222
|
+
* then the caller's own (the caller may be the sender). The server enforces
|
|
223
|
+
* that the caller is one of the two; this only picks the vault. Throws when no
|
|
224
|
+
* scope can be decided. Pure.
|
|
225
|
+
*/
|
|
226
|
+
export function attachmentReadScopes(args) {
|
|
227
|
+
const parsed = parseChatAttachmentKey(args.vaultPath);
|
|
228
|
+
if (!parsed) {
|
|
229
|
+
throw new Error(`Not a chat attachment path: ${args.vaultPath}. Expected chat/attachments/dm/<uidA--uidB>/<file> or chat/attachments/chan/<channelId>/<file>.`);
|
|
230
|
+
}
|
|
231
|
+
const explicit = args.companyUid?.trim();
|
|
232
|
+
if (explicit)
|
|
233
|
+
return [explicit];
|
|
234
|
+
if (parsed.scope === "chan") {
|
|
235
|
+
throw new Error("A channel attachment needs its vault scope. Pass --company <uid> with the companyUid shown on the message.");
|
|
236
|
+
}
|
|
237
|
+
const pair = parsed.scopeId.split("--").filter(Boolean);
|
|
238
|
+
const me = args.callerUid?.trim();
|
|
239
|
+
if (pair.length !== 2 || !me || !pair.includes(me)) {
|
|
240
|
+
throw new Error("This DM attachment is not from a conversation you are part of, or your uid could not be read. Pass --company <uid> with the companyUid shown on the message.");
|
|
241
|
+
}
|
|
242
|
+
const sender = args.senderUid?.trim();
|
|
243
|
+
if (sender && pair.includes(sender))
|
|
244
|
+
return [sender];
|
|
245
|
+
const other = pair[0] === me ? pair[1] : pair[0];
|
|
246
|
+
return [other, me];
|
|
247
|
+
}
|
|
248
|
+
/** The first (most likely) read scope from `attachmentReadScopes`. Pure. */
|
|
249
|
+
export function attachmentReadScope(args) {
|
|
250
|
+
return attachmentReadScopes(args)[0];
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* The attachments on the message with `eventId` (or `messageId`) in a list of
|
|
254
|
+
* message rows. Returns null when no row matches. Pure.
|
|
255
|
+
*/
|
|
256
|
+
export function findMessageAttachments(rows, eventId) {
|
|
257
|
+
const id = eventId.trim();
|
|
258
|
+
const row = rows.find((r) => r.eventId === id || r.messageId === id);
|
|
259
|
+
if (!row)
|
|
260
|
+
return null;
|
|
261
|
+
const list = row.attachments?.length ? row.attachments : row.attachment ? [row.attachment] : [];
|
|
262
|
+
const sender = row.fromPersonUid?.trim();
|
|
263
|
+
return list
|
|
264
|
+
.filter((a) => Boolean(a?.vaultPath))
|
|
265
|
+
.map((a) => (sender && !a.senderUid ? { ...a, senderUid: sender } : a));
|
|
266
|
+
}
|
|
267
|
+
export const defaultAttachmentDownloadDeps = {
|
|
268
|
+
presign: (token, body) => vaultApiFetch({ token, path: "/v1/files/presign", method: "POST", body }),
|
|
269
|
+
get: (url) => fetch(url),
|
|
270
|
+
};
|
|
271
|
+
/**
|
|
272
|
+
* Presign a GET for one chat attachment in `scopeUid` and return its bytes.
|
|
273
|
+
* hq-pro files-presign admits a counterparty read of the sender's personal
|
|
274
|
+
* vault only when the caller is a member of the DM pair; a refusal (whole
|
|
275
|
+
* request or per key) surfaces as an Error.
|
|
276
|
+
*/
|
|
277
|
+
export async function downloadChatAttachment(args) {
|
|
278
|
+
const deps = args.deps ?? defaultAttachmentDownloadDeps;
|
|
279
|
+
const res = await deps.presign(args.token, {
|
|
280
|
+
company: args.scopeUid,
|
|
281
|
+
keys: [{ key: args.vaultPath, op: "get" }],
|
|
282
|
+
});
|
|
283
|
+
if (!res.ok) {
|
|
284
|
+
const err = (await res.json().catch(() => ({})));
|
|
285
|
+
const message = typeof err.error === "string" ? err.error : typeof err.message === "string" ? err.message : res.statusText;
|
|
286
|
+
throw new Error(`Could not open the attachment (HTTP ${res.status}): ${message}`);
|
|
287
|
+
}
|
|
288
|
+
const data = (await res.json().catch(() => ({})));
|
|
289
|
+
const row = (data.results ?? []).find((r) => Boolean(r) && typeof r === "object" && r.key === args.vaultPath);
|
|
290
|
+
if (!row || typeof row.url !== "string") {
|
|
291
|
+
const reason = typeof row?.error === "string" ? row.error : "the vault refused the read";
|
|
292
|
+
throw new Error(`Attachment refused: ${args.vaultPath}: ${reason}`);
|
|
293
|
+
}
|
|
294
|
+
const got = await deps.get(row.url);
|
|
295
|
+
if (!got.ok) {
|
|
296
|
+
throw new Error(`Download failed for ${args.vaultPath} (HTTP ${got.status}).`);
|
|
297
|
+
}
|
|
298
|
+
return Buffer.from(await got.arrayBuffer());
|
|
299
|
+
}
|
|
196
300
|
//# sourceMappingURL=dm-attach.js.map
|
package/dist/commands/dm.d.ts
CHANGED
|
@@ -443,5 +443,15 @@ export declare function formatChannelTranscript(messages: ChannelMessageItem[],
|
|
|
443
443
|
*/
|
|
444
444
|
export declare function agentCallerUid(token: string, machine?: () => boolean): string | null;
|
|
445
445
|
export declare const AGENT_THREAD_UNAVAILABLE: string;
|
|
446
|
+
/**
|
|
447
|
+
* Where to write each downloaded attachment. One file and an `--out` that is
|
|
448
|
+
* not an existing directory: write exactly there. Otherwise `--out` (default
|
|
449
|
+
* cwd) is a directory and each file keeps its sanitized name. Pure apart from
|
|
450
|
+
* the injected `isDir`.
|
|
451
|
+
*/
|
|
452
|
+
export declare function attachmentOutputPaths(refs: ReadonlyArray<{
|
|
453
|
+
vaultPath: string;
|
|
454
|
+
name?: string;
|
|
455
|
+
}>, out: string | undefined, isDir: (p: string) => boolean): string[];
|
|
446
456
|
export declare function registerDmCommand(program: Command): void;
|
|
447
457
|
//# sourceMappingURL=dm.d.ts.map
|
package/dist/commands/dm.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from "chalk";
|
|
2
|
-
import { readFileSync } from "node:fs";
|
|
2
|
+
import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join as pathJoin } from "node:path";
|
|
3
4
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
4
5
|
import { resolveCallerPersonUid, resolveDestinationCompanyUid, vaultApiFetch } from "../utils/vault-api.js";
|
|
5
6
|
import { isMachineIdentity } from "@indigoai-us/hq-cloud";
|
|
@@ -10,7 +11,7 @@ import { EMAIL_PATTERN, RECIPIENT_UID_PATTERN, PERSON_AGENT_AUDIENCE_MESSAGE, PE
|
|
|
10
11
|
import { callerIsMachinePrincipal } from "../lib/caller-principal.js";
|
|
11
12
|
export { defaultAudienceFor, recipientKindOf };
|
|
12
13
|
import { dmForwardCliEnabled } from "./dm-forward-flag.js";
|
|
13
|
-
import { attachmentScopeForChannel, formatUploadedAttachments, personalScopeForCaller, uploadDmAttachments, validateAttachmentPaths, } from "./dm-attach.js";
|
|
14
|
+
import { attachmentReadScopes, attachmentScopeForChannel, downloadChatAttachment, findMessageAttachments, parseChatAttachmentKey, sanitizeAttachmentName, formatUploadedAttachments, personalScopeForCaller, uploadDmAttachments, validateAttachmentPaths, } from "./dm-attach.js";
|
|
14
15
|
import { ChannelNotFoundError, suggestChannels, withChannelSuggestions, } from "./channel-suggest.js";
|
|
15
16
|
/**
|
|
16
17
|
* Classify a recipient arg as an email or a person/agent uid. Mirrors the
|
|
@@ -1679,6 +1680,107 @@ async function runDmInbox(opts) {
|
|
|
1679
1680
|
process.exit(1);
|
|
1680
1681
|
}
|
|
1681
1682
|
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Where to write each downloaded attachment. One file and an `--out` that is
|
|
1685
|
+
* not an existing directory: write exactly there. Otherwise `--out` (default
|
|
1686
|
+
* cwd) is a directory and each file keeps its sanitized name. Pure apart from
|
|
1687
|
+
* the injected `isDir`.
|
|
1688
|
+
*/
|
|
1689
|
+
export function attachmentOutputPaths(refs, out, isDir) {
|
|
1690
|
+
const nameOf = (r) => sanitizeAttachmentName(r.name?.trim() || r.vaultPath.split("/").pop() || "file");
|
|
1691
|
+
if (refs.length === 1 && out && !isDir(out))
|
|
1692
|
+
return [out];
|
|
1693
|
+
const dir = out ?? ".";
|
|
1694
|
+
// Two files with the same sanitized name get distinct targets: name-1.ext, name-2.ext.
|
|
1695
|
+
const used = new Set();
|
|
1696
|
+
return refs.map((r) => {
|
|
1697
|
+
const base = nameOf(r);
|
|
1698
|
+
let name = base;
|
|
1699
|
+
for (let n = 1; used.has(name); n++) {
|
|
1700
|
+
const dot = base.lastIndexOf(".");
|
|
1701
|
+
name = dot > 0 ? `${base.slice(0, dot)}-${n}${base.slice(dot)}` : `${base}-${n}`;
|
|
1702
|
+
}
|
|
1703
|
+
used.add(name);
|
|
1704
|
+
return pathJoin(dir, name);
|
|
1705
|
+
});
|
|
1706
|
+
}
|
|
1707
|
+
async function runDmAttachment(ref, opts) {
|
|
1708
|
+
try {
|
|
1709
|
+
const token = await ensureCognitoToken();
|
|
1710
|
+
const callerUid = agentCallerUid(token) ?? (await callerUidForAttach(token));
|
|
1711
|
+
let refs;
|
|
1712
|
+
if (parseChatAttachmentKey(ref)) {
|
|
1713
|
+
refs = [{ vaultPath: ref.trim() }];
|
|
1714
|
+
}
|
|
1715
|
+
else {
|
|
1716
|
+
if (agentCallerUid(token)) {
|
|
1717
|
+
throw new Error("Agents cannot look up an eventId in the person inbox. Pass the attachment's vault path instead.");
|
|
1718
|
+
}
|
|
1719
|
+
const res = await vaultApiFetch({
|
|
1720
|
+
token,
|
|
1721
|
+
path: "/v1/notify/inbox",
|
|
1722
|
+
query: { limit: opts.limit ?? "100" },
|
|
1723
|
+
});
|
|
1724
|
+
if (!res.ok) {
|
|
1725
|
+
const err = (await res.json().catch(() => ({})));
|
|
1726
|
+
throw new Error(friendlyDmError(res.status, err.code, err.error ?? err.message ?? res.statusText));
|
|
1727
|
+
}
|
|
1728
|
+
const data = (await res.json());
|
|
1729
|
+
const found = findMessageAttachments(data.events ?? [], ref);
|
|
1730
|
+
if (found === null) {
|
|
1731
|
+
throw new Error(`No message ${ref} in your last ${opts.limit ?? "100"} inbox messages. Raise --limit, or pass the attachment's vault path.`);
|
|
1732
|
+
}
|
|
1733
|
+
if (found.length === 0)
|
|
1734
|
+
throw new Error(`Message ${ref} has no attachments.`);
|
|
1735
|
+
refs = found;
|
|
1736
|
+
}
|
|
1737
|
+
const targets = attachmentOutputPaths(refs, opts.out, (p) => {
|
|
1738
|
+
try {
|
|
1739
|
+
return statSync(p).isDirectory();
|
|
1740
|
+
}
|
|
1741
|
+
catch {
|
|
1742
|
+
return false;
|
|
1743
|
+
}
|
|
1744
|
+
});
|
|
1745
|
+
// Check every destination before writing anything, so a clash never leaves
|
|
1746
|
+
// a partial download behind.
|
|
1747
|
+
if (!opts.force) {
|
|
1748
|
+
const clash = targets.find((dest) => existsSync(dest));
|
|
1749
|
+
if (clash) {
|
|
1750
|
+
throw new Error(`${clash} already exists. Pass --force to overwrite, or --out to choose another path.`);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
const scopes = refs.map((r) => attachmentReadScopes({
|
|
1754
|
+
vaultPath: r.vaultPath,
|
|
1755
|
+
companyUid: opts.company ?? r.companyUid,
|
|
1756
|
+
callerUid,
|
|
1757
|
+
senderUid: r.senderUid,
|
|
1758
|
+
}));
|
|
1759
|
+
for (let i = 0; i < refs.length; i++) {
|
|
1760
|
+
const r = refs[i];
|
|
1761
|
+
const dest = targets[i];
|
|
1762
|
+
let bytes;
|
|
1763
|
+
let lastErr;
|
|
1764
|
+
for (const scopeUid of scopes[i]) {
|
|
1765
|
+
try {
|
|
1766
|
+
bytes = await downloadChatAttachment({ token, scopeUid, vaultPath: r.vaultPath });
|
|
1767
|
+
break;
|
|
1768
|
+
}
|
|
1769
|
+
catch (e) {
|
|
1770
|
+
lastErr = e;
|
|
1771
|
+
}
|
|
1772
|
+
}
|
|
1773
|
+
if (!bytes)
|
|
1774
|
+
throw lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
1775
|
+
writeFileSync(dest, bytes);
|
|
1776
|
+
console.log(chalk.green(`Saved ${dest}`) + chalk.dim(` (${bytes.length} bytes)`));
|
|
1777
|
+
}
|
|
1778
|
+
}
|
|
1779
|
+
catch (err) {
|
|
1780
|
+
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
1781
|
+
process.exit(1);
|
|
1782
|
+
}
|
|
1783
|
+
}
|
|
1682
1784
|
async function runDmThread(identifier, opts) {
|
|
1683
1785
|
try {
|
|
1684
1786
|
const query = buildThreadQuery(identifier);
|
|
@@ -1879,6 +1981,16 @@ export function registerDmCommand(program) {
|
|
|
1879
1981
|
.action(async (opts) => {
|
|
1880
1982
|
await runDmInbox(opts);
|
|
1881
1983
|
});
|
|
1984
|
+
dm
|
|
1985
|
+
.command("attachment <ref>")
|
|
1986
|
+
.description("Download a file attached to a DM. REF is the message eventId from `hq dm inbox` (downloads every file on it) or the attachment's vault path (chat/attachments/...). The file is read from the sender's vault; only the two people in the conversation can open a DM attachment.")
|
|
1987
|
+
.option("--out <path>", "Where to write: a file path for one attachment, else a directory (default: current directory)")
|
|
1988
|
+
.option("--company <uid>", "Vault scope the file lives in (the companyUid shown on the message). Derived from the DM pair when omitted")
|
|
1989
|
+
.option("--force", "Overwrite an existing file")
|
|
1990
|
+
.option("--limit <n>", "How many recent inbox messages to search for the eventId", "100")
|
|
1991
|
+
.action(async (ref, opts) => {
|
|
1992
|
+
await runDmAttachment(ref, opts);
|
|
1993
|
+
});
|
|
1882
1994
|
dm
|
|
1883
1995
|
.command("thread <person>")
|
|
1884
1996
|
.alias("read")
|
package/dist/commands/sources.js
CHANGED
|
@@ -43,6 +43,7 @@ import { createPersonVaultReader } from "../lib/hot-channels/person-vault-reader
|
|
|
43
43
|
import { S3Client } from "@aws-sdk/client-s3";
|
|
44
44
|
import { resolveCanonicalPersonUid } from "./cloud.js";
|
|
45
45
|
import { botsRoot as defaultBotsRoot } from "../lib/bot/paths.js";
|
|
46
|
+
import { ensureHotSignalSyncJob } from "../lib/bot/jobs/hot-signal-sync.js";
|
|
46
47
|
import { findHotSignalSyncJobs, lastRunsPerAdapter, parseDailyAt, renderScheduleReport, rescheduleDaily, toJobView, } from "../lib/hot-channels/schedule.js";
|
|
47
48
|
import { listActiveMembers } from "./members.js";
|
|
48
49
|
import { runOnDemandSync, } from "../lib/hot-channels/run.js";
|
|
@@ -619,6 +620,20 @@ export async function runSchedule(options, overrides = {}) {
|
|
|
619
620
|
parseDailyAt(options.at);
|
|
620
621
|
let found = findHotSignalSyncJobs(overrides.botsRoot ?? defaultBotsRoot(), company);
|
|
621
622
|
if (options.at !== undefined) {
|
|
623
|
+
if (found.length === 0) {
|
|
624
|
+
// Setting a time turns the daily sync on: register it on the personal setup bot.
|
|
625
|
+
const root = overrides.botsRoot ?? defaultBotsRoot();
|
|
626
|
+
const setupDir = path.join(root, "setup");
|
|
627
|
+
if (fs.existsSync(setupDir)) {
|
|
628
|
+
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
629
|
+
const otherBotDirs = fs
|
|
630
|
+
.readdirSync(root, { withFileTypes: true })
|
|
631
|
+
.filter((d) => d.isDirectory())
|
|
632
|
+
.map((d) => path.join(root, d.name));
|
|
633
|
+
ensureHotSignalSyncJob(setupDir, { company, timezone, otherBotDirs }, now);
|
|
634
|
+
found = findHotSignalSyncJobs(root, company);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
622
637
|
if (found.length === 0)
|
|
623
638
|
throw new NoHotSignalSyncJobError(company);
|
|
624
639
|
found = rescheduleDaily(found, options.at, now);
|