quoxcli 0.4.0 → 0.6.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 +110 -0
- package/README.md +2 -2
- package/bin/quox.js +19 -1
- package/bin/quoxon.js +6 -1
- package/lib/api/endpoints/admin.js +7 -0
- package/lib/api/endpoints/agent-attach.js +7 -0
- package/lib/api/endpoints/compliance.js +25 -0
- package/lib/api/endpoints/governance.js +7 -0
- package/lib/api/endpoints/inbox.js +6 -0
- package/lib/api/endpoints/inboxq.js +58 -0
- package/lib/api/endpoints/index.js +4 -0
- package/lib/api/endpoints/lucid.js +17 -0
- package/lib/api/endpoints/mcp.js +35 -0
- package/lib/api/endpoints/mon.js +122 -0
- package/lib/api/endpoints/notesq.js +2 -0
- package/lib/api/endpoints/quoxplan.js +51 -0
- package/lib/api/endpoints/tools.js +13 -0
- package/lib/api/endpoints/v1.js +13 -0
- package/lib/commands/actions.js +51 -13
- package/lib/commands/admin.js +35 -0
- package/lib/commands/agentAttach.js +15 -0
- package/lib/commands/approvals.js +193 -3
- package/lib/commands/ask.js +1 -1
- package/lib/commands/auth.js +64 -11
- package/lib/commands/backup.js +11 -4
- package/lib/commands/capabilities.js +3 -2
- package/lib/commands/chat.js +15 -0
- package/lib/commands/claude.js +4 -1
- package/lib/commands/compliance.js +109 -0
- package/lib/commands/connect.js +69 -0
- package/lib/commands/contacts.js +2 -0
- package/lib/commands/dev.js +6 -8
- package/lib/commands/doctor.js +22 -3
- package/lib/commands/evidence.js +2 -1
- package/lib/commands/gripe.js +24 -13
- package/lib/commands/inbox.js +37 -3
- package/lib/commands/inboxq.js +147 -0
- package/lib/commands/kuma.js +78 -5
- package/lib/commands/licenceLinkView.js +311 -0
- package/lib/commands/linkedin.js +19 -0
- package/lib/commands/loop.js +30 -3
- package/lib/commands/lucid.js +7 -0
- package/lib/commands/lucidGoals.js +389 -0
- package/lib/commands/mail.js +427 -25
- package/lib/commands/mcp.js +190 -0
- package/lib/commands/memory.js +68 -4
- package/lib/commands/memorySync.js +158 -0
- package/lib/commands/mon.js +69 -144
- package/lib/commands/monitoring.js +68 -0
- package/lib/commands/notes.js +29 -7
- package/lib/commands/notesSeal.js +617 -0
- package/lib/commands/objective.js +5 -2
- package/lib/commands/offload.js +52 -12
- package/lib/commands/plan.js +487 -52
- package/lib/commands/provider.js +29 -8
- package/lib/commands/qlarity.js +32 -1
- package/lib/commands/run.js +2 -1
- package/lib/commands/server.js +37 -0
- package/lib/commands/setup.js +127 -75
- package/lib/commands/skills.js +4 -1
- package/lib/commands/slot.js +49 -9
- package/lib/commands/status.js +92 -44
- package/lib/commands/tool.js +30 -0
- package/lib/commands/vault.js +6 -3
- package/lib/commands/volt.js +6 -1
- package/lib/commands/walkthrough.js +14 -2
- package/lib/commands/watch.js +11 -2
- package/lib/commands/wazuh.js +880 -0
- package/lib/commands/wazuhIntake.js +358 -0
- package/lib/commands/wazuhReport.js +192 -0
- package/lib/commands/workflow.js +47 -4
- package/lib/commands/x.js +50 -0
- package/lib/core/announce.js +86 -0
- package/lib/core/auth.js +20 -2
- package/lib/core/callMe.js +118 -0
- package/lib/core/client.js +27 -1
- package/lib/core/commandRegistrars.js +9 -0
- package/lib/core/config.js +42 -3
- package/lib/core/confirm.js +68 -7
- package/lib/core/crossOrgApprovals.js +69 -0
- package/lib/core/errors.js +52 -1
- package/lib/core/governedSession.js +135 -5
- package/lib/core/inboxResolution.js +3 -2
- package/lib/core/loginGuard.js +73 -0
- package/lib/core/offload.js +120 -0
- package/lib/core/org-override.js +5 -1
- package/lib/core/output.js +30 -5
- package/lib/core/profileTrust.js +182 -0
- package/lib/core/quoxglyph.js +22 -1
- package/lib/core/router.js +14 -0
- package/lib/core/selfUpdate.js +2 -0
- package/lib/core/stepUpPrompt.js +99 -0
- package/lib/core/version.js +1 -1
- package/lib/core/walkthrough.js +9 -5
- package/lib/dragon/select.js +2 -2
- package/lib/quoxon/EXTENSIONS.md +142 -19
- package/lib/quoxon/api.js +189 -34
- package/lib/quoxon/app.js +1277 -188
- package/lib/quoxon/approvalBatch.js +117 -0
- package/lib/quoxon/approvalFacts.js +56 -2
- package/lib/quoxon/approvalSentence.js +8 -4
- package/lib/quoxon/approvalsView.js +72 -12
- package/lib/quoxon/ask.js +4 -2
- package/lib/quoxon/autoOpen.js +94 -0
- package/lib/quoxon/backgroundWork.js +59 -0
- package/lib/quoxon/bang.js +8 -2
- package/lib/quoxon/capabilities.js +7 -2
- package/lib/quoxon/commandShort.js +8 -1
- package/lib/quoxon/commands.js +42 -12
- package/lib/quoxon/connect.js +5 -3
- package/lib/quoxon/deckLazy.js +58 -0
- package/lib/quoxon/delegationDragon.js +49 -0
- package/lib/quoxon/destructive.js +35 -0
- package/lib/quoxon/dnd.js +118 -0
- package/lib/quoxon/dragonCompanion.js +37 -7
- package/lib/quoxon/dragonMoments.js +78 -0
- package/lib/quoxon/echo.js +1 -0
- package/lib/quoxon/events.js +55 -8
- package/lib/quoxon/extensions.js +400 -38
- package/lib/quoxon/foreignText.js +49 -0
- package/lib/quoxon/gripeQueue.js +135 -0
- package/lib/quoxon/inboxView.js +125 -22
- package/lib/quoxon/keys.js +2 -2
- package/lib/quoxon/launcher.js +2 -1
- package/lib/quoxon/loopCard.js +7 -2
- package/lib/quoxon/loopCards.js +17 -8
- package/lib/quoxon/loopStream.js +20 -9
- package/lib/quoxon/mailView.js +71 -0
- package/lib/quoxon/main.js +27 -6
- package/lib/quoxon/markdown.js +1 -1
- package/lib/quoxon/model.js +15 -0
- package/lib/quoxon/neverAnswered.js +20 -0
- package/lib/quoxon/objects/away.js +43 -5
- package/lib/quoxon/onboarding/connectServer.js +162 -0
- package/lib/quoxon/onboarding/copy.js +6 -0
- package/lib/quoxon/onboarding/dockerOffer.js +3 -1
- package/lib/quoxon/onboarding/index.js +5 -43
- package/lib/quoxon/onboarding/probe.js +20 -1
- package/lib/quoxon/onboarding/view.js +16 -3
- package/lib/quoxon/orgSwitch.js +22 -0
- package/lib/quoxon/print.js +2 -0
- package/lib/quoxon/profileSwitch.js +7 -0
- package/lib/quoxon/promptHistory.js +3 -1
- package/lib/quoxon/render.js +68 -16
- package/lib/quoxon/resend.js +14 -0
- package/lib/quoxon/roster.js +10 -5
- package/lib/quoxon/rosterSessions.js +44 -0
- package/lib/quoxon/rows.js +3 -1
- package/lib/quoxon/screenContext.js +1 -1
- package/lib/quoxon/secretShape.js +88 -0
- package/lib/quoxon/slash/agents.js +2 -1
- package/lib/quoxon/slash/argSources.js +9 -2
- package/lib/quoxon/slash/args.js +4 -2
- package/lib/quoxon/slash/broadcast.js +48 -11
- package/lib/quoxon/slash/broadcastRun.js +24 -7
- package/lib/quoxon/slash/cliLeaves.json +98 -1
- package/lib/quoxon/slash/dashboard.js +2 -0
- package/lib/quoxon/slash/hatWalk.js +1 -1
- package/lib/quoxon/slash/listView.js +2 -1
- package/lib/quoxon/slash/palette.js +63 -1
- package/lib/quoxon/slash/paletteController.js +15 -5
- package/lib/quoxon/slash/restart.js +9 -4
- package/lib/quoxon/slash/stepUp.js +5 -5
- package/lib/quoxon/slash/stepUpCmds.js +74 -1
- package/lib/quoxon/slashSpinner.js +35 -0
- package/lib/quoxon/slotAttach.js +8 -0
- package/lib/quoxon/slotChat.js +24 -3
- package/lib/quoxon/slotFormat.js +1 -1
- package/lib/quoxon/slotOutbox.js +113 -0
- package/lib/quoxon/slotRun.js +2 -0
- package/lib/quoxon/suggestCommand.js +1 -1
- package/lib/quoxon/term.js +25 -1
- package/lib/quoxon/transcript.js +2 -1
- package/lib/quoxon/webPty.js +15 -0
- package/lib/quoxon/welcome.js +1 -1
- package/lib/quoxon/whatsnew.js +1 -1
- package/lib/quoxon/whatsnew.jsonl +42 -0
- package/lib/render/planBrief.js +614 -0
- package/lib/server/installFlow.js +168 -0
- package/lib/server/status.js +15 -1
- package/lib/toolOutputShape.js +61 -3
- package/package.json +3 -2
- package/vendor/dragonlab/SOURCE +3 -3
- package/vendor/dragonlab/design/quoxperticus/v2/quoxperticus-master.svg +59 -0
- package/vendor/dragonlab/design/quoxperticus/v3/character.mjs +43 -0
- package/vendor/dragonlab/design/quoxperticus/v4/character.mjs +57 -0
- package/vendor/dragonlab/design/quoxperticus/v4/review.mjs +35 -0
- package/vendor/dragonlab/design/quoxperticus/v4/watch.mjs +56 -0
- package/vendor/dragonlab/design/vulcan/v1/character.mjs +34 -0
- package/vendor/dragonlab/design/vulcan/v1/vulcan-master.svg +57 -0
- package/vendor/dragonlab/design/vulcan/v2/character.mjs +39 -0
- package/vendor/dragonlab/design/vulcan/v3/character.mjs +41 -0
- package/vendor/dragonlab/design/vulcan/v3/review.mjs +35 -0
- package/vendor/dragonlab/design/vulcan/v3/watch.mjs +56 -0
- package/vendor/dragonlab/factory/svg-rig-contour.mjs +64 -0
- package/vendor/dragonlab/integration/companions-v1/characters.mjs +20 -0
- package/vendor/dragonlab/integration/companions-v1/quoxperticus.mjs +2 -0
- package/vendor/dragonlab/integration/companions-v1/view.mjs +1 -1
- package/vendor/dragonlab/integration/companions-v1/vulcan.mjs +2 -0
package/lib/dragon/select.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher): which one, whether to show it, and where its art lives.
|
|
2
|
+
* Dragon companions (Lucid, Hyperion, Seer, Sentinel, Drago, Cipher, Vulcan, Quoxperticus): which one, whether to show it, and where its art lives.
|
|
3
3
|
* Pure. The art, animation and activity reducer stay in quoxai/dragonlab (integration/companions-v1);
|
|
4
4
|
* Quox only chooses and loads. See docs/dragons/COMPANION-UX.md for the decisions behind each rule.
|
|
5
5
|
*/
|
|
@@ -10,7 +10,7 @@ import { fileURLToPath } from 'node:url';
|
|
|
10
10
|
export const VENDORED_DRAGONLAB = fileURLToPath(new URL('../../vendor/dragonlab', import.meta.url));
|
|
11
11
|
|
|
12
12
|
/** Registry agent ids (and display names) to approved dragon. Others, including quox/commanderq, get none. */
|
|
13
|
-
export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher' });
|
|
13
|
+
export const AGENT_DRAGON = Object.freeze({ lucid: 'lucid', hyperion: 'hyperion', atlas: 'hyperion', seer: 'seer', sentinel: 'sentinel', warden: 'sentinel', security_lead: 'drago', drago: 'drago', cipher: 'cipher', vulcan: 'vulcan', forge: 'vulcan', quoxperticus: 'quoxperticus' });
|
|
14
14
|
export const COMPANION_MODES = ['on', 'greeting', 'off'];
|
|
15
15
|
/** One-line change: the owner decides this after his Mac check (docs/dragons/COMPANION-UX.md section 4). */
|
|
16
16
|
export const DEFAULT_COMPANION_MODE = 'on';
|
package/lib/quoxon/EXTENSIONS.md
CHANGED
|
@@ -1,23 +1,146 @@
|
|
|
1
|
-
# quoxon extensions
|
|
1
|
+
# quoxon extensions: contract v1
|
|
2
2
|
|
|
3
|
-
Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar). Core ships none and knows none by name.
|
|
3
|
+
Optional npm packages that add a surface to quoxon (a MIDI pad deck, a status bar, a fleet board). Core ships none and knows none by name.
|
|
4
|
+
An extension can OPEN an approval or PRESELECT an answer. It can never resolve one, and it never polls Quox: data is pushed to it.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
## Loading
|
|
7
|
+
|
|
8
|
+
Nothing loads unless the package is installed AND listed in `~/.quox/extensions.json`
|
|
6
9
|
(`quox ext add|remove|list <pkg>`; `/ext list|on|off <name>` inside quoxon). Install with `npm i -g <pkg>`.
|
|
10
|
+
Set env `QUOX_EXT_LIST=<path>` to use another list file (default `~/.quox/extensions.json`), for `quox ext` and quoxon alike (dev and tests).
|
|
7
11
|
Resolution order: env `QUOX_EXT_<NAME>_MODULE` (name upper-cased, non-alphanumerics to `_`), a local `import(name)`,
|
|
8
|
-
then the global npm root
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
12
|
+
then the global npm root. `/ext list` names where each was found; a miss lists where it looked.
|
|
13
|
+
|
|
14
|
+
**contractVersion** (integer, this document is `1`). The host refuses an extension whose `contractVersion` is above what it supports,
|
|
15
|
+
with a line in `/ext list`: `needs extension contract v2, this quoxon supports v1`. An extension with no `contractVersion` is a
|
|
16
|
+
pre-contract (legacy) extension: it loads, and `emit` may use `n` without `id` (see F2). Declare `contractVersion: 1` to get the strict rules.
|
|
17
|
+
|
|
18
|
+
## Module
|
|
19
|
+
|
|
20
|
+
Default export (or the module itself):
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
interface Extension {
|
|
24
|
+
name: string; version?: string;
|
|
25
|
+
contractVersion?: 1;
|
|
26
|
+
commands?: Command[];
|
|
27
|
+
settings?: SettingDef[];
|
|
28
|
+
create(ctx: { emit(msg: Emit): boolean; log(text: string): void; settings: Record<string, string|number|boolean>; invalidate(): void }): Instance;
|
|
29
|
+
}
|
|
30
|
+
interface Instance {
|
|
31
|
+
update?(state: State): void; // deduped; see Freshness
|
|
32
|
+
close?(): void | Promise<void>; // see Close
|
|
33
|
+
panel?: Panel; // see Panels (one panel per extension)
|
|
34
|
+
onSettings?(next: Record<string, string|number|boolean>): void;
|
|
35
|
+
onEmitResult?(r: { ok: boolean; reason?: string; msg: Emit }): void;
|
|
36
|
+
}
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
`panel`, `onSettings` and `onEmitResult` live on the object `create()` returns. There is no module-level `onSettings`.
|
|
40
|
+
|
|
41
|
+
## Commands
|
|
42
|
+
|
|
43
|
+
```ts
|
|
44
|
+
interface Command {
|
|
45
|
+
name: `/${string}`; help?: string;
|
|
46
|
+
subcommands?: { name: string; help?: string }[]; // feed /help and Tab completion
|
|
47
|
+
run(args: string[], ctx: { raw: string; say(text: string, tone?: 'ok'|'warn'|'accent'|'dim'): void; extension: string; loaded: string[] }): string | { text: string; tone?: string } | Promise<...>;
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`args` is the text after the command split on whitespace; `ctx.raw` is the same text unsplit. Commands exist only while the extension is loaded.
|
|
52
|
+
A thrown error is printed as `<cmd>: <message>`, never crashes the window.
|
|
53
|
+
|
|
54
|
+
## State
|
|
55
|
+
|
|
56
|
+
`update(state)` is deduped (a push identical except `at` is dropped) and sent at most once per second.
|
|
57
|
+
|
|
58
|
+
```ts
|
|
59
|
+
interface State {
|
|
60
|
+
at: string; // ISO time of this push
|
|
61
|
+
stale?: true; // present only when the host's last successful server read is over 60 s old
|
|
62
|
+
org: { id: string|null; name: string|null };
|
|
63
|
+
approvals: { count: number; items: { n: number; id: string|null; risk: 'low'|'high'; title: string; agent: string|null; options?: { key: string; label: string }[] }[] };
|
|
64
|
+
health: Record<string, 'ok'|'warn'|'down'>;
|
|
65
|
+
loops: { id: string; title: string; turn: number; max: number; status: string }[];
|
|
66
|
+
fleet: { total: number; online: number; offline: number; warn: number; hosts: { name: string; state: 'ok'|'warn'|'down' }[] }; // hosts max 16, may be empty
|
|
67
|
+
evidence: { chain: 'ok'|'broken'|'unknown'; checked_at: string|null }; // PLACEHOLDER: always 'unknown' until quoxon holds a chain status
|
|
68
|
+
sessions: { active: number };
|
|
69
|
+
event?: { seq: number; id?: string; kind: string; text: string; level: 'info'|'ok'|'warn'|'error' };
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Freshness.** `state.at` is set on every push. While any extension is loaded the host pushes a heartbeat every 15 s, even when nothing
|
|
74
|
+
changed, so `at` keeps moving. `stale: true` means the data may be old (the server was not read for over 60 s): show it as such.
|
|
75
|
+
|
|
76
|
+
**Event ids.** `event.seq` is a positive integer, monotonic within one quoxon window session and never reused; a new event gets the next number,
|
|
77
|
+
the seq changes only when `kind`, `text`, `level` or `id` changes: a repeat of the held event keeps its seq until `alert_ack`. `event.id` appears only when the server supplied one.
|
|
78
|
+
|
|
79
|
+
`fleet` comes from a 30 s poll of the host list while an extension is loaded.
|
|
80
|
+
|
|
81
|
+
## emit
|
|
82
|
+
|
|
83
|
+
```ts
|
|
84
|
+
type Emit =
|
|
85
|
+
| { type: 'open_approval'; id: string; n?: number }
|
|
86
|
+
| { type: 'pick'; id: string; n?: number; option: string }
|
|
87
|
+
| { type: 'alert_ack' };
|
|
88
|
+
```
|
|
89
|
+
|
|
90
|
+
`id` (from `approvals.items[].id`) is REQUIRED for `open_approval` and `pick`. `n` is display-only; when `id` and `n` disagree, `id` wins
|
|
91
|
+
(logged). An unknown, missing or already-resolved `id` is refused through `onEmitResult({ ok:false, reason, msg })`, and is never retargeted
|
|
92
|
+
to another item. `pick` only fills the composer with the answer command: the user presses Enter, high risk still needs the step-up PIN.
|
|
93
|
+
`alert_ack` clears the event. emit returns `true` when accepted and is ignored once the extension is unloaded.
|
|
94
|
+
(Legacy extensions without `contractVersion` may omit `id` and use `n`.)
|
|
95
|
+
|
|
96
|
+
## Panels and tabs
|
|
97
|
+
|
|
98
|
+
```ts
|
|
99
|
+
interface Panel {
|
|
100
|
+
title: string;
|
|
101
|
+
render(a: { width: number; height: number; focused: boolean; theme: Theme }): string[]; // pure
|
|
102
|
+
onKey?(key: string): boolean; // true = consumed, see Keys
|
|
103
|
+
onFocus?(): void; onBlur?(): void;
|
|
104
|
+
}
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
```ts
|
|
108
|
+
interface Theme { name: 'dark'|'light'|'mono'; color: boolean; unicode: boolean }
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
The host owns the tab strip, shown only when one or more panels exist. The panel region sits above the composer, height
|
|
112
|
+
`min(12, 40% of terminal rows)`, collapsed until a panel is focused or opened. `/ext tab <name>` focuses (and opens) a panel; `/ext tab off`
|
|
113
|
+
collapses; Ctrl+] cycles focus through the panels; Esc returns keys to the composer (the panel stays open). While focused, keys go to
|
|
114
|
+
`onKey` first; a key it does not consume is dropped. The panel never receives `escape` or `ctrl+]`: the host consumes both.
|
|
115
|
+
|
|
116
|
+
**Keys.** `onKey(key)` gets one of `up down left right enter tab backspace space home end pageup pagedown`, otherwise the single typed
|
|
117
|
+
character (`a`, `?`, `/`), or `ctrl+<c>` / `alt+<c>` for the modifier combinations the terminal reports. Pasted text arrives one
|
|
118
|
+
character at a time.
|
|
119
|
+
|
|
120
|
+
`render` must be pure and fast. The 50 ms budget is wall-clock per render call (`performance.now`). The host clips the result to `height` lines and `width` columns. A render that throws, or takes over 50 ms,
|
|
121
|
+
shows `panel error` in its place and is retried no sooner than 5 s later; it never crashes the window. The panel re-renders on every state
|
|
122
|
+
push and on `invalidate()` (passed to `create`, throttled to 4 per second).
|
|
123
|
+
|
|
124
|
+
## Settings
|
|
125
|
+
|
|
126
|
+
```ts
|
|
127
|
+
interface SettingDef { key: string; type: 'string'|'number'|'bool'|'enum'; default: string|number|boolean; help: string; choices?: string[] }
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Values live in `~/.quox/extensions.json` on the extension's entry as `settings: { <key>: value }`. `create({ settings })` receives the resolved
|
|
131
|
+
object (defaults overlaid with stored values). `/ext set <name> <key> <value>` validates by type (number: `Number()` and it must be a finite number; bool: `true|yes|on|1` or
|
|
132
|
+
`false|no|off|0`, case-insensitive, anything else is rejected with a message; enum: an exact match to one of `choices`; string: any text), stores it, and calls `onSettings(next)` on the running instance.
|
|
133
|
+
`/ext settings <name>` lists each key with its value, default and help. The extension must be loaded and listed.
|
|
134
|
+
|
|
135
|
+
## Close
|
|
136
|
+
|
|
137
|
+
`close()` may return a promise. The host awaits it for at most 500 ms, then drops the instance anyway and logs it. It must be idempotent
|
|
138
|
+
(the host may call it twice) and must free every timer, interval, watcher, device and process listener, because hot reload calls it on each change.
|
|
139
|
+
|
|
140
|
+
## Hot reload
|
|
141
|
+
|
|
142
|
+
While an extension is loaded, quoxon watches its `package.json` (`fs.watch`, 2 s debounce, plus a 30 s stat poll because global installs
|
|
143
|
+
replace directories). When the version or mtime changes it awaits `close()` on the old instance (500 ms cap), drops its commands and panel,
|
|
144
|
+
re-imports the entry with a cache-busting `?v=<version>-<mtime>`, and calls `create()` again, then prints `↻ <name> updated <old> -> <new>`.
|
|
145
|
+
Only the entry module is re-imported. A failed reload leaves the old instance closed, prints the error once, and shows `failed: <error>` in
|
|
146
|
+
`/ext list` until the next change. Watchers and timers are cleared on `/ext off`, unload and exit.
|
package/lib/quoxon/api.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
* QUOXON server calls. Thin wrapper over the CLI client so the app can be
|
|
3
3
|
* driven by a fake (demo + tests). All routes per docs/QUOXON_EVENTS_v1.md.
|
|
4
4
|
*/
|
|
5
|
+
import { otherOrgsWaiting } from '../core/crossOrgApprovals.js';
|
|
5
6
|
import { resolveEndpoint } from '../api/endpoints.js';
|
|
6
7
|
import { rememberEmail } from '../core/auth.js';
|
|
7
8
|
import { isSessionDead } from './sessionDead.js'; // not signin.js: that loads React + Ink (QUOXON-LATENCY)
|
|
@@ -9,7 +10,8 @@ import { buildStreamQuery } from '../commands/chat.js';
|
|
|
9
10
|
import { loadBundledAssistantsRoster } from '../core/agentAlias.js';
|
|
10
11
|
import { bundledIdentities } from './objects/fetch.js';
|
|
11
12
|
import { recordHeldSlot } from '../commands/slot.js';
|
|
12
|
-
import { askGripe, settleGripe } from '../commands/gripe.js';
|
|
13
|
+
import { askGripe, settleGripe, fileDeterministically } from '../commands/gripe.js';
|
|
14
|
+
import { gripeQueue, fileOrQueue, makeRetryFiler } from './gripeQueue.js';
|
|
13
15
|
import { sendFeedback } from './feedback.js';
|
|
14
16
|
import { fromObjective, fromExecution, fromApproval, sortRows } from './roster.js';
|
|
15
17
|
import { normChoice } from './model.js';
|
|
@@ -21,6 +23,7 @@ import { createKillerApi } from './slash/killerApi.js';
|
|
|
21
23
|
import { createUndoOps } from './slash/undoApi.js';
|
|
22
24
|
import { resolveDashboardBase, resolveCollectorBase, detectLanIp } from './hostUrls.js';
|
|
23
25
|
import { grantBody } from './consent.js';
|
|
26
|
+
import { runCallMe } from '../core/callMe.js';
|
|
24
27
|
import { loadJourney } from './onboarding/journey.js';
|
|
25
28
|
import { joinServiceUrl } from '../core/config.js';
|
|
26
29
|
import { runVmCommand } from '../core/vmTui.js';
|
|
@@ -92,6 +95,7 @@ async function createNoteFrom(client, text) {
|
|
|
92
95
|
} catch (e) {
|
|
93
96
|
if (e?.status === 402) return { ok: false, message: 'NotesQ is not licensed for this org. ' + NOTES_OFF };
|
|
94
97
|
if (e?.status === 403 || e?.status === 404 || e?.status === 503) return { ok: false, message: NOTES_OFF };
|
|
98
|
+
if (!e?.status && /timed out|timeout|ECONNRESET|socket hang up|network|fetch failed|ETIMEDOUT/i.test(e?.message || '')) return { ok: false, maybeSaved: true, message: 'The save may still have gone through (the server was slow). /notes shows your recent notes; I won\'t retry automatically so you don\'t get a duplicate.' }; // request was sent: the outcome is unknown, not a failure
|
|
95
99
|
return { ok: false, message: `Could not save the note: ${e?.message || 'request failed'}${e?.status ? ` (HTTP ${e.status})` : ''}.` };
|
|
96
100
|
}
|
|
97
101
|
}
|
|
@@ -102,9 +106,10 @@ export function isInteractiveHuman({ stdin = process.stdin, stdout = process.std
|
|
|
102
106
|
/** A one-line title within the tasks service's 200 character cap. */
|
|
103
107
|
const clipTitle = (g) => { const t = String(g ?? '').replace(/\s+/g, ' ').trim(); return t.length > 200 ? `${t.slice(0, 199)}…` : t; };
|
|
104
108
|
|
|
105
|
-
export function createApi({ client, config, auth, windowOrg = null, clientForOrg = null, reattachBackoff = REATTACH_BACKOFF_MS }) {
|
|
109
|
+
export function createApi({ client, config, auth, windowOrg = null, clientForOrg = null, reattachBackoff = REATTACH_BACKOFF_MS, gripeQueuePath = undefined, listSessions = null, awayRetryMs = 3000 }) {
|
|
106
110
|
let holderSessionFn = null;
|
|
107
111
|
const orgId = () => { try { return auth.getOrgId(); } catch { return null; } };
|
|
112
|
+
const gripeStore = gripeQueue(gripeQueuePath);
|
|
108
113
|
const rowsStore = createRowsStore({ client, config, orgId });
|
|
109
114
|
let credsSeen = null; // credentials-file mtime last seen (credentialsChanged)
|
|
110
115
|
const userId = () => { try { return config.getAuth?.()?.user_id || null; } catch { return null; } };
|
|
@@ -161,12 +166,13 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
161
166
|
err.code = 'reattach_failed';
|
|
162
167
|
throw err;
|
|
163
168
|
}
|
|
169
|
+
const HUMAN_TUI = { 'X-Quox-Human-Action': 'tui' }; // DND contract: only a human action in the TUI sends this, never a restart or internal path
|
|
164
170
|
/** PRESENCE auto-clear: the human's own first real action. -> { ok, cleared, welcome } (welcome once: { away_text, via }). Never throws. */
|
|
165
171
|
async function noteActivity(action, { quiet = false, interactive = isInteractiveHuman() } = {}) {
|
|
166
172
|
if (!interactive) return { ok: false, cleared: false, welcome: null, skipped: 'not interactive' };
|
|
167
173
|
try {
|
|
168
174
|
const ep = resolveEndpoint('presence.activity');
|
|
169
|
-
const d = await client.post(ep.path, { service: ep.service, retries: 0, timeout: 6000, body: { action, org_id: orgId(), ...(quiet ? { quiet: true } : {}) } });
|
|
175
|
+
const d = await client.post(ep.path, { service: ep.service, retries: 0, timeout: 6000, headers: HUMAN_TUI, body: { action, org_id: orgId(), ...(quiet ? { quiet: true } : {}) } });
|
|
170
176
|
return { ok: true, cleared: d?.cleared === true, welcome: d?.welcome || null };
|
|
171
177
|
} catch (e) { return { ok: false, cleared: false, welcome: null, status: e?.status || null }; }
|
|
172
178
|
}
|
|
@@ -453,6 +459,10 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
453
459
|
await client.post(ep.path, { service: ep.service, body: grantBody(cur, provider), retries: 0 });
|
|
454
460
|
return true;
|
|
455
461
|
},
|
|
462
|
+
/** Record a contact opt-in as THIS person (POST /api/v1/commanderq/contactability/policy/grant; human-only, needs a lawful basis). */
|
|
463
|
+
async grantContactability(body) {
|
|
464
|
+
return client.post('/api/v1/commanderq/contactability/policy/grant', { service: 'collector', body, retries: 0 });
|
|
465
|
+
},
|
|
456
466
|
/** The dashboard address to print (never localhost when a better one is known). */
|
|
457
467
|
async dashboardBase() {
|
|
458
468
|
const profileUrl = config.getActiveProfile?.()?.url || config.getBaseUrl();
|
|
@@ -550,16 +560,23 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
550
560
|
* file when the agent did not). The client asks the user first, so the agent is told to file without asking.
|
|
551
561
|
* -> { ok:true, response, url, superseded, notes } | { ok:false, message }.
|
|
552
562
|
*/
|
|
553
|
-
async gripe(text, { yes = true } = {}) {
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
563
|
+
async gripe(text, { yes = true, repo = null } = {}) {
|
|
564
|
+
return fileOrQueue({
|
|
565
|
+
text, repo, queue: gripeStore,
|
|
566
|
+
run: async (key) => {
|
|
567
|
+
const options = { yes, idempotencyKey: key, queueRetryable: true, ...(repo ? { repo } : {}) };
|
|
568
|
+
const data = await askGripe({ client, complaint: text, yes, orgId: orgId() });
|
|
569
|
+
const r = await settleGripe({ client, data, complaint: text, options, orgId: orgId() });
|
|
570
|
+
return { response: r.response, url: r.url, superseded: r.superseded, notes: r.notes };
|
|
571
|
+
},
|
|
572
|
+
});
|
|
562
573
|
},
|
|
574
|
+
/** GRIPE-QUEUE: file what is queued (at most once per 2 minutes). -> { ran, filed:[{text,url}], expired:[{text}], pending }. */
|
|
575
|
+
async gripeRetry({ force = false } = {}) {
|
|
576
|
+
return gripeStore.retry(makeRetryFiler({ fileDeterministically, client, orgId: orgId() }), { force });
|
|
577
|
+
},
|
|
578
|
+
/** GRIPE-QUEUE: queued plus recently filed gripes, oldest first. */
|
|
579
|
+
gripes() { return gripeStore.list(); },
|
|
563
580
|
/**
|
|
564
581
|
* /feedback: POST the previewed payload to the public quox.ai feedback API (not the org's server, not GitHub).
|
|
565
582
|
* Anonymous by design: no token, cookie, org or profile goes with it. -> { ok:true, id, url } | { ok:false, message }.
|
|
@@ -581,6 +598,8 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
581
598
|
async todoList() { return listBoard(client); },
|
|
582
599
|
/** /todo move: move one item (real id) to todo, doing or done. -> the item. */
|
|
583
600
|
async todoMove(id, status) { return moveActionItem(client, id, status); },
|
|
601
|
+
/** /callmeforapprovals: { action, after, quiet } -> { data, changed } (same core as `quox approvals call-me`). Throws on failure. */
|
|
602
|
+
async callMe(args) { return runCallMe(client, args); },
|
|
584
603
|
/** -> [{ plugin_id, version, is_enabled, ... }] every plugin installed for the org (GET plugins.list, as `quox plugin list`). Throws on failure. */
|
|
585
604
|
async pluginList() {
|
|
586
605
|
const id = orgId();
|
|
@@ -617,6 +636,35 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
617
636
|
* ("quox1 (Adam)"); `fast` answers once the line is in the slot, without waiting on the chat-app bridges.
|
|
618
637
|
* -> { ok:true, message } | { ok:false, message, status }.
|
|
619
638
|
*/
|
|
639
|
+
/**
|
|
640
|
+
* Does this slot name exist in one of the user's OWN orgs? Reads only, never sends. Same rules as slotSay: name@org
|
|
641
|
+
* targets one membership; two other orgs holding the name is { found:true, ambiguous:true } (the send path then asks).
|
|
642
|
+
* -> { found:boolean, ambiguous?:true }. Throws when the membership list itself cannot be read.
|
|
643
|
+
*/
|
|
644
|
+
async slotLocate(name) {
|
|
645
|
+
const slug = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
646
|
+
const clientFor = async (oid) => (oid === orgId() ? client : clientForOrg(oid));
|
|
647
|
+
const listed = async (oid, nm) => {
|
|
648
|
+
const ep = resolveEndpoint('v1.slots.list');
|
|
649
|
+
const rows = listOf(await (await clientFor(oid)).get(ep.path, { service: ep.service, retries: 0 }), 'slots') || [];
|
|
650
|
+
return rows.some(r => String(r?.name || '').toLowerCase() === String(nm).toLowerCase());
|
|
651
|
+
};
|
|
652
|
+
const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
|
|
653
|
+
const orgs = (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || [])).filter(o => o?.id).map(o => ({ id: o.id, name: o.name || o.id }));
|
|
654
|
+
const at = String(name).indexOf('@');
|
|
655
|
+
if (at > 0) {
|
|
656
|
+
const raw = String(name).slice(at + 1); const frag = slug(raw);
|
|
657
|
+
const hit = frag ? orgs.filter(o => o.id === raw || slug(o.name) === frag) : [];
|
|
658
|
+
const pick = hit.length ? hit : orgs.filter(o => frag && slug(o.name).startsWith(frag));
|
|
659
|
+
if (pick.length !== 1 || !clientForOrg) return { found: false };
|
|
660
|
+
return { found: await listed(pick[0].id, String(name).slice(0, at)) };
|
|
661
|
+
}
|
|
662
|
+
if (await listed(orgId(), name)) return { found: true };
|
|
663
|
+
if (!clientForOrg) return { found: false };
|
|
664
|
+
const hits = [];
|
|
665
|
+
for (const o of orgs) { if (o.id === orgId()) continue; try { if (await listed(o.id, name)) hits.push(o); } catch { /* an org that cannot be listed is not a candidate */ } }
|
|
666
|
+
return hits.length > 1 ? { found: true, ambiguous: true } : { found: hits.length === 1 };
|
|
667
|
+
},
|
|
620
668
|
/** SLOTS-REPLY2: the window registers how to name its checkout session; slotSay then sends it as holder_session. */
|
|
621
669
|
bindHolderSession(fn) { holderSessionFn = typeof fn === 'function' ? fn : null; },
|
|
622
670
|
async slotSay(name, text, { from = null, fast = false, roomSession = null, fromSlot = null, holderSession = holderSessionFn ? holderSessionFn() : null, anyOrg = true } = {}) {
|
|
@@ -630,33 +678,66 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
630
678
|
if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
|
|
631
679
|
return { ok: false, message: e?.status === 404 ? `no slot named ${name}` : (e?.message || 'failed'), status: e?.status || null };
|
|
632
680
|
};
|
|
681
|
+
// The user's own memberships (orgs.list is scoped to the caller); nothing outside this list is ever tried.
|
|
682
|
+
const memberOrgs = async () => {
|
|
683
|
+
try {
|
|
684
|
+
const oe = resolveEndpoint('orgs.list'); const d = await client.get(oe.path, { service: oe.service });
|
|
685
|
+
return (Array.isArray(d) ? d : (d?.organizations || d?.orgs || d?.data || [])).filter(o => o?.id).map(o => ({ id: o.id, name: o.name || o.id }));
|
|
686
|
+
} catch { return []; }
|
|
687
|
+
};
|
|
688
|
+
const slug = (x) => String(x || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
689
|
+
const clientFor = async (oid) => (oid === orgId() ? client : clientForOrg(oid));
|
|
690
|
+
const at = String(name).indexOf('@');
|
|
691
|
+
if (at > 0) { // name@org: exactly that membership, never the others
|
|
692
|
+
const base = String(name).slice(0, at); const frag = slug(String(name).slice(at + 1));
|
|
693
|
+
const orgs = anyOrg && clientForOrg ? await memberOrgs() : [];
|
|
694
|
+
const hit = frag ? orgs.filter(o => o.id === String(name).slice(at + 1) || slug(o.name) === frag) : [];
|
|
695
|
+
const pick = hit.length ? hit : orgs.filter(o => frag && slug(o.name).startsWith(frag));
|
|
696
|
+
if (pick.length !== 1) return { ok: false, status: 404, message: pick.length ? `"${name}" matches several of your orgs: ${pick.map(o => `${base}@${slug(o.name)}`).join(', ')}` : `no slot named ${name}` };
|
|
697
|
+
try { const r = await send(await clientFor(pick[0].id), pick[0].id); return { ...r, viaOrgId: pick[0].id, viaOrgName: pick[0].name }; } catch (e) { return { ...fail(e), viaOrgId: pick[0].id, viaOrgName: pick[0].name }; }
|
|
698
|
+
}
|
|
633
699
|
let first;
|
|
634
700
|
try { return await send(client, orgId()); } catch (e) { first = fail(e); }
|
|
635
701
|
// HITL-INBOX-BAR: a window in another org cannot see a slot that lives in the profile's home org. Same user, same
|
|
636
|
-
// sessions (not a cross-tenant leak):
|
|
702
|
+
// sessions (not a cross-tenant leak): look in the user's other orgs only, each on a token for THAT org.
|
|
637
703
|
if (!anyOrg || first.status !== 404 || !clientForOrg) return first;
|
|
638
704
|
const tried = new Set([orgId()]);
|
|
639
|
-
const
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
705
|
+
const orgs = await memberOrgs();
|
|
706
|
+
const nameOf = (oid) => orgs.find(o => o.id === oid)?.name || null;
|
|
707
|
+
// 1. Find it without sending: list slots per other org. Two orgs hold the name: ask, never pick.
|
|
708
|
+
const hits = [];
|
|
709
|
+
for (const o of orgs) {
|
|
710
|
+
if (tried.has(o.id)) continue;
|
|
711
|
+
try {
|
|
712
|
+
const ep = resolveEndpoint('v1.slots.list');
|
|
713
|
+
const rows = listOf(await (await clientFor(o.id)).get(ep.path, { service: ep.service, retries: 0 }), 'slots') || [];
|
|
714
|
+
if (rows.some(r => String(r?.name || '').toLowerCase() === String(name).toLowerCase())) hits.push(o);
|
|
715
|
+
} catch { /* an org that cannot be listed is not a candidate here; the probe below still covers it */ }
|
|
716
|
+
}
|
|
717
|
+
if (hits.length > 1) {
|
|
718
|
+
const choices = hits.map(o => ({ orgId: o.id, orgName: o.name, ref: `${name}@${slug(o.name)}` }));
|
|
719
|
+
return { ok: false, ambiguous: true, choices, status: null, message: `"${name}" exists in more than one of your orgs: ${choices.map(c => `${c.ref} (${c.orgName})`).join(', ')}. Say which, e.g. /say ${choices[0].ref} ...` };
|
|
720
|
+
}
|
|
721
|
+
const order = hits.map(o => o.id);
|
|
722
|
+
if (!order.length) { // nothing listed it: fall back to trying the home org, then each membership
|
|
723
|
+
try { const home = config.getAuth?.()?.org_id; if (home) order.push(home); } catch { /* no home org known */ }
|
|
724
|
+
for (const o of orgs) order.push(o.id);
|
|
725
|
+
}
|
|
645
726
|
for (const oid of order) {
|
|
646
727
|
if (tried.has(oid)) continue; tried.add(oid);
|
|
647
728
|
try {
|
|
648
|
-
const cl = await
|
|
729
|
+
const cl = await clientFor(oid);
|
|
649
730
|
const r = await send(cl, oid);
|
|
650
|
-
return { ...r, viaOrgId: oid };
|
|
651
|
-
} catch (e) { const f = fail(e); if (f.status !== 404) return { ...f, viaOrgId: oid }; } // the slot is there but refused: that is the real answer
|
|
731
|
+
return { ...r, viaOrgId: oid, viaOrgName: nameOf(oid) };
|
|
732
|
+
} catch (e) { const f = fail(e); if (f.status !== 404) return { ...f, viaOrgId: oid, viaOrgName: nameOf(oid) }; } // the slot is there but refused: that is the real answer
|
|
652
733
|
}
|
|
653
734
|
return first;
|
|
654
735
|
},
|
|
655
736
|
/** QUOXON-BROADCAST: fan a message out to the org's held slots. -> { ok:true, data } | { ok:false, message, status }. */
|
|
656
|
-
async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, from = null } = {}) {
|
|
737
|
+
async slotBroadcast({ text, slots = null, priority = 'normal', ack = false, active = false, dryRun = false, from = null } = {}) {
|
|
657
738
|
const ep = resolveEndpoint('v1.slots.broadcast');
|
|
658
739
|
try {
|
|
659
|
-
const data = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, priority, ack, org_id: orgId(), ...(slots ? { slots } : {}), ...(from ? { from } : {}) } });
|
|
740
|
+
const data = await client.post(ep.path, { service: ep.service, retries: 0, body: { text, priority, ack, ...(active ? { active: true } : {}), ...(dryRun ? { dry_run: true } : {}), org_id: orgId(), ...(slots ? { slots } : {}), ...(from ? { from } : {}) } });
|
|
660
741
|
return { ok: true, data };
|
|
661
742
|
} catch (e) {
|
|
662
743
|
if (slotsUnlicensed(e)) return { ok: false, unlicensed: true, status: 403, message: SLOTS_UNLICENSED };
|
|
@@ -859,8 +940,26 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
859
940
|
return l.map(e => fromExecution(e, now, { userId: userId() })).filter(Boolean);
|
|
860
941
|
})
|
|
861
942
|
]);
|
|
943
|
+
// Attached agents (org list, owner/admin) and this box's live `claude --resume` processes. Neither is a work row:
|
|
944
|
+
// they ride on the source as `items` and /roster prints them (rosterSessions.js).
|
|
945
|
+
const itemsSrc = (id, label, fn) => fn().then(
|
|
946
|
+
(items) => ({ id, label, state: 'connected', rows: [], items, count: items.length }),
|
|
947
|
+
(e) => ({ id, label, rows: [], items: [], count: 0, state: missing(e) ? 'unavailable' : 'error', note: missing(e) ? 'no list endpoint on this server' : (e?.message || 'failed') }));
|
|
948
|
+
const extra = await Promise.all([
|
|
949
|
+
itemsSrc('agents', 'attached agents', async () => {
|
|
950
|
+
const ep = resolveEndpoint('agent-attach.list');
|
|
951
|
+
const d = await client.get(ep.path, { service: ep.service, retries: 0 });
|
|
952
|
+
const l = listOf(d, 'attaches'); if (!l) throw new Error('unexpected response');
|
|
953
|
+
return l;
|
|
954
|
+
}),
|
|
955
|
+
itemsSrc('boxsessions', "this box's sessions", async () => {
|
|
956
|
+
if (listSessions) return await listSessions();
|
|
957
|
+
const [{ listLiveSessions }, { spawnSync }] = await Promise.all([import('../core/governedSession.js'), import('node:child_process')]);
|
|
958
|
+
return listLiveSessions({ spawnSync, pid: process.pid });
|
|
959
|
+
})
|
|
960
|
+
]);
|
|
862
961
|
const unlisted = [
|
|
863
|
-
{
|
|
962
|
+
...extra.map(({ rows, ...rest }) => rest),
|
|
864
963
|
{ id: 'devsessions', label: 'dev sessions', state: 'unavailable', count: 0, note: 'listed per host only (quox dev sessions <host>)' }
|
|
865
964
|
];
|
|
866
965
|
return { rows: sortRows(parts.flatMap(p => p.rows)), sources: [...parts.map(({ rows, ...rest }) => rest), ...unlisted] };
|
|
@@ -896,18 +995,31 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
896
995
|
return { situation, approvals };
|
|
897
996
|
},
|
|
898
997
|
/** PRESENCE: PUT /api/v1/presence (/afk, /sleep, /back, a typed turn). -> { ok:true, presence, notice } | { ok:false, message, status }. */
|
|
899
|
-
async setPresence(state, { note, slots } = {}) {
|
|
998
|
+
async setPresence(state, { note, slots, dnd = false, human = false } = {}) {
|
|
900
999
|
const ep = resolveEndpoint('presence.set');
|
|
901
1000
|
try {
|
|
902
|
-
const d = await client.put(ep.path, { service: ep.service, retries: 0, timeout: 8000, body: { state, org_id: orgId(), ...(note ? { note } : {}), ...(Array.isArray(slots) && slots.length ? { slots } : {}) } });
|
|
1001
|
+
const d = await client.put(ep.path, { service: ep.service, retries: 0, timeout: 8000, ...(human ? { headers: HUMAN_TUI } : {}), body: { state, org_id: orgId(), ...(dnd ? { dnd: true } : {}), ...(note ? { note } : {}), ...(Array.isArray(slots) && slots.length ? { slots } : {}) } });
|
|
903
1002
|
return { ok: true, presence: d?.presence || null, notice: d?.notice || null };
|
|
904
1003
|
} catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
|
|
905
1004
|
},
|
|
1005
|
+
/** AFK-SURVIVES-RESTART: read-only GET /api/v1/presence/me. -> { ok:true, presence } | { ok:false, status }. Never throws, never writes. */
|
|
1006
|
+
async getPresence() {
|
|
1007
|
+
try {
|
|
1008
|
+
const ep = resolveEndpoint('presence.get', { who: 'me' });
|
|
1009
|
+
const d = await client.get(ep.path, { service: ep.service, query: { org_id: orgId() }, retries: 0, timeout: 6000 });
|
|
1010
|
+
return { ok: true, presence: d?.presence || null };
|
|
1011
|
+
} catch (e) { return { ok: false, message: e?.message || 'failed', status: e?.status || null }; }
|
|
1012
|
+
},
|
|
906
1013
|
noteActivity,
|
|
907
1014
|
/** E5 /away: GET /api/v1/away?since=<iso> -> { since, until, items, activity, blind }. Throws on non-2xx. */
|
|
908
1015
|
async away(since) {
|
|
909
1016
|
const ep = resolveEndpoint('away.digest');
|
|
910
|
-
|
|
1017
|
+
const read = () => client.get(ep.path, { service: ep.service, query: { since }, retries: 0, timeout: 15000 });
|
|
1018
|
+
const first = await read();
|
|
1019
|
+
// AWAY-RETRY: the first calls after a fresh sign-in can time out; try those sources once more before saying "Can't see".
|
|
1020
|
+
if (!(first?.blind || []).some((b) => /timed? ?out|timeout/i.test(String(b?.reason || '')))) return first;
|
|
1021
|
+
await new Promise((r) => setTimeout(r, awayRetryMs));
|
|
1022
|
+
try { return (await read()) || first; } catch { return first; } // the retry also failing leaves the honest first answer
|
|
911
1023
|
},
|
|
912
1024
|
/** Facts for /observers, each part on its own: { lucid, learning:[{ id, label, state, text }] }. */
|
|
913
1025
|
async observers() {
|
|
@@ -1074,12 +1186,42 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
1074
1186
|
async hyperion() { const data = await fetchActivity(client); return { lines: heldLines(data), tone: data.nodes_error || data.pool_error ? 'warn' : undefined }; },
|
|
1075
1187
|
approvals: listApprovals,
|
|
1076
1188
|
/** HITL-INBOX-BAR: the owner's latest inbox items (any kind, active), newest first. Throws on failure, never reads as empty. */
|
|
1077
|
-
async inboxItems({ limit = 30 } = {}) {
|
|
1189
|
+
async inboxItems({ limit = 30, page = 1 } = {}) {
|
|
1078
1190
|
const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
|
|
1079
|
-
const data = await client.get(ep.path, { service: ep.service, query: { limit } });
|
|
1191
|
+
const data = await client.get(ep.path, { service: ep.service, query: page > 1 ? { limit, page } : { limit } });
|
|
1080
1192
|
const items = data?.items || data?.data || data || [];
|
|
1081
1193
|
return Array.isArray(items) ? items : [];
|
|
1082
1194
|
},
|
|
1195
|
+
/** TUI-MAIL: connected InboxQ mailboxes (GET /api/v1/inboxq/accounts, the `quox mail accounts` read). Throws on failure, never reads as none. */
|
|
1196
|
+
async mailAccounts() {
|
|
1197
|
+
const ep = resolveEndpoint('inboxq.accounts.list');
|
|
1198
|
+
const data = await client.get(ep.path, { service: ep.service, retries: 0 });
|
|
1199
|
+
return Array.isArray(data?.accounts) ? data.accounts : [];
|
|
1200
|
+
},
|
|
1201
|
+
/** TUI-MAIL: triaged messages (GET /api/v1/inboxq/messages, the `quox mail list` read). Throws on failure. */
|
|
1202
|
+
async mailMessages({ limit = 10, zone } = {}) {
|
|
1203
|
+
const ep = resolveEndpoint('inboxq.messages.list');
|
|
1204
|
+
const data = await client.get(ep.path, { service: ep.service, retries: 0, query: { limit, offset: 0, ...(zone ? { zone } : {}) } });
|
|
1205
|
+
return Array.isArray(data?.messages) ? data.messages : [];
|
|
1206
|
+
},
|
|
1207
|
+
/** TUI-MAIL: one message with its stored body (GET /api/v1/inboxq/messages/:id, read only: no body fetch from the mailbox). */
|
|
1208
|
+
async mailMessage(id) {
|
|
1209
|
+
const ep = resolveEndpoint('inboxq.messages.get', { id });
|
|
1210
|
+
const data = await client.get(ep.path, { service: ep.service, retries: 0 });
|
|
1211
|
+
return data?.message || null;
|
|
1212
|
+
},
|
|
1213
|
+
/** Every inbox item, paged (100 per page, capped at maxPages). Throws on failure, never returns a partial list as the whole. */
|
|
1214
|
+
async inboxAllItems({ pageSize = 100, maxPages = 20 } = {}) {
|
|
1215
|
+
const ep = resolveEndpoint('inbox.list', { orgId: orgId() });
|
|
1216
|
+
const all = [];
|
|
1217
|
+
for (let page = 1; page <= maxPages; page++) {
|
|
1218
|
+
const data = await client.get(ep.path, { service: ep.service, query: { limit: pageSize, page } });
|
|
1219
|
+
const items = data?.items || data?.data || (Array.isArray(data) ? data : []);
|
|
1220
|
+
all.push(...items);
|
|
1221
|
+
if (!(data?.pagination?.has_more) || !items.length) break;
|
|
1222
|
+
}
|
|
1223
|
+
return all;
|
|
1224
|
+
},
|
|
1083
1225
|
/** One inbox item in full (the server marks it read for this user as a side effect). */
|
|
1084
1226
|
async inboxItem(itemId) {
|
|
1085
1227
|
const ep = resolveEndpoint('inbox.get', { orgId: orgId(), itemId: encodeURIComponent(itemId) });
|
|
@@ -1097,6 +1239,14 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
1097
1239
|
const d = await client.get(ep.path, { service: ep.service });
|
|
1098
1240
|
return { pending: Number(d?.pending_approvals ?? 0), awaiting: Number(d?.awaiting_human ?? 0) };
|
|
1099
1241
|
},
|
|
1242
|
+
/** Approvals waiting in the user's OTHER orgs: counts only, per-org stats on that org's own token (core/crossOrgApprovals). */
|
|
1243
|
+
async otherOrgApprovals() { return otherOrgsWaiting({ client, currentOrgId: orgId() }); },
|
|
1244
|
+
/** APPROVALS-AUTO: another org's open approvals, read with the caller's OWN token (auth checks membership of the URL org, not the token's org claim). */
|
|
1245
|
+
async approvalsInOrg(org) {
|
|
1246
|
+
const ep = resolveEndpoint('inbox.approvals.open', { orgId: org });
|
|
1247
|
+
const data = await client.get(ep.path, { service: ep.service, retries: 0 });
|
|
1248
|
+
return Array.isArray(data?.approvals) ? data.approvals.map((a) => a.item).filter(Boolean) : [];
|
|
1249
|
+
},
|
|
1100
1250
|
/** GET /api/v1/approvals/:id/result -> { status, result, executed_at } ; null when unknown (404) */
|
|
1101
1251
|
async approvalResult(approvalId) {
|
|
1102
1252
|
const ep = resolveEndpoint('v1.approvals.result', { id: approvalId });
|
|
@@ -1104,14 +1254,14 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
1104
1254
|
catch (e) { if (e?.status === 404) return null; throw e; }
|
|
1105
1255
|
},
|
|
1106
1256
|
/** quox ask: answer a decision with one of its option keys (and an optional note). */
|
|
1107
|
-
async answerDecision(itemId, option, { note = null, stepUp = null, bulk = false } = {}) {
|
|
1108
|
-
const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
|
|
1257
|
+
async answerDecision(itemId, option, { note = null, stepUp = null, bulk = false, org = null } = {}) {
|
|
1258
|
+
const ep = resolveEndpoint('inbox.respond', { orgId: org || orgId(), itemId }); // org: APPROVALS-AUTO answers another org's card by its URL org (the server checks membership of THAT org)
|
|
1109
1259
|
const r = await client.post(ep.path, { service: ep.service, headers: stepUp || {}, body: { action: 'answer', option, ...(bulk ? { bulk: true } : {}), ...(note ? { comment: String(note).slice(0, 4000) } : {}) } });
|
|
1110
1260
|
if (stepUp && Object.keys(stepUp).length) noteActivity('answer', { quiet: true }); // PRESENCE: answering is the owner being back
|
|
1111
1261
|
return r;
|
|
1112
1262
|
},
|
|
1113
|
-
async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false } = {}) {
|
|
1114
|
-
const ep = resolveEndpoint('inbox.respond', { orgId: orgId(), itemId });
|
|
1263
|
+
async respond(itemId, action, { via = null, stepUp = null, reason = null, bulk = false, org = null } = {}) {
|
|
1264
|
+
const ep = resolveEndpoint('inbox.respond', { orgId: org || orgId(), itemId });
|
|
1115
1265
|
const tag = via ? `via quoxon ${via}` : 'via quoxon';
|
|
1116
1266
|
const why = reason ? String(reason).trim().slice(0, 4000) : '';
|
|
1117
1267
|
const r = await client.post(ep.path, { service: ep.service, headers: stepUp || {}, body: { action, ...(bulk ? { bulk: true } : {}), comment: why ? `${why} (${tag})` : tag, ...(why ? { reason: why } : {}) } });
|
|
@@ -1129,6 +1279,11 @@ export function createApi({ client, config, auth, windowOrg = null, clientForOrg
|
|
|
1129
1279
|
const ep = resolveEndpoint('stepup.status');
|
|
1130
1280
|
return client.get(ep.path, { service: ep.service });
|
|
1131
1281
|
},
|
|
1282
|
+
/** /verify-email: the SAME endpoint `quox admin users verify-email` calls. The server's checks are the only gate; the caller supplies a fresh one-shot step-up token. */
|
|
1283
|
+
async verifyEmail({ email, reason, userId = null, stepUp = {} }) {
|
|
1284
|
+
const ep = resolveEndpoint('admin.users.verify_email');
|
|
1285
|
+
return client.post(ep.path, { service: ep.service, headers: stepUp, body: { email, reason, ...(userId ? { user_id: userId } : {}) } });
|
|
1286
|
+
},
|
|
1132
1287
|
/** QUOXON-STEPUP part B: override ONE blocked action. Needs a live override-scoped step-up token. */
|
|
1133
1288
|
async override({ blockId, blockType, reason, agentId = null, action = null, stepUp = {} }) {
|
|
1134
1289
|
const ep = resolveEndpoint('safety.override');
|