@drakon-systems/multi-clawd 1.8.11 → 1.9.2

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/README.md CHANGED
@@ -31,6 +31,7 @@ multi-clawd setup # guided wizard: accounts, isolated second login, pool, wa
31
31
  multi-clawd login claw2 # launch the right Claude sign-in for an account (or re-auth it)
32
32
  multi-clawd explain # your whole setup in plain English — accounts, chain, live health
33
33
  multi-clawd chain # audit your model routing — what actually serves each turn
34
+ multi-clawd direct # the direct anthropic/* route: status, or `direct sync` (v1.9, optional)
34
35
  multi-clawd doctor # health check (add --probe for a live end-to-end turn)
35
36
  ```
36
37
 
@@ -374,7 +375,7 @@ openclaw plugins install (Get-Location).Path
374
375
  **Or let your agent install it.** Running an OpenClaw assistant or Claude
375
376
  Code on the target machine already? Paste it this and go make coffee:
376
377
 
377
- > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.8.11/SETUP-AGENT.md
378
+ > Read https://raw.githubusercontent.com/Drakon-Systems-Ltd/multi-clawd/v1.9.2/SETUP-AGENT.md
378
379
  > and follow it to set up multi-clawd on this machine. I own a second
379
380
  > Claude account — ask me when you need me to log in.
380
381
 
@@ -594,9 +595,100 @@ Notes:
594
595
  which runs on every launch on every turn path. Details in
595
596
  [`DESIGN.md`](./DESIGN.md).
596
597
 
598
+ ## Direct route: the pool for `anthropic/*` too (v1.9)
599
+
600
+ OpenClaw reaches Claude two ways. The pool above drives **Claude Code CLI**
601
+ backends (`clawd/…`). OpenClaw's own **`anthropic/*`** models skip the CLI and
602
+ call the Anthropic API directly, with an auth profile. A `claude setup-token`
603
+ works for both, and both use up the same account limits.
604
+
605
+ From v1.9 the pool can steer both. Opt accounts in with `direct`:
606
+
607
+ ```jsonc
608
+ "accounts": [
609
+ // native login: give it its own setup-token for the API route
610
+ { "id": "claw1", "native": true,
611
+ "direct": { "tokenRef": { "source": "exec", "provider": "onepassword",
612
+ "id": "op://YourVault/claw1-setup-token/password" } } },
613
+ // already has a setup-token for the CLI: reuse it
614
+ { "id": "claw2", "configDir": "~/.claw2",
615
+ "oauthTokenRef": { "source": "exec", "provider": "onepassword",
616
+ "id": "op://YourVault/claw2-setup-token/password" },
617
+ "direct": true }
618
+ ],
619
+ "directRoute": { "agents": ["main"] } // optional; default ["main"]
620
+ ```
621
+
622
+ Then run `multi-clawd direct sync`. It does two things:
623
+
624
+ 1. **Stores one OpenClaw profile per account** (`anthropic:claw1`,
625
+ `anthropic:claw2`), using OpenClaw's own commands:
626
+ - A **secret reference** is stored as a reference (`openclaw secrets
627
+ apply`). OpenClaw resolves it at runtime, so the token is never copied.
628
+ - A **token file** is piped to `openclaw models auth paste-token` on stdin.
629
+ - Profiles that already exist are left alone; `--resync` rewrites them.
630
+ 2. **Sets the `anthropic` auth order** to match pool health.
631
+
632
+ The plugin then keeps that order up to date inside the gateway, re-checking
633
+ every minute:
634
+
635
+ - The healthiest account goes first; a nearly-maxed one moves to the back
636
+ **before** it errors.
637
+ - The rule is the same one the CLI pool uses, and it reads the same health
638
+ data, so both routes lean on the same account at the same time.
639
+ - OpenClaw's own rotation covers the rest mid-turn: a 429 cools that profile
640
+ down and the turn moves to the next one.
641
+
642
+ What to know:
643
+
644
+ - **Why a native or config-dir login needs its own token.** That login is a
645
+ rotating one-time OAuth grant, and a copy of it breaks on the next refresh.
646
+ multi-clawd never reads Claude's own credential files. Run `claude
647
+ setup-token` signed in as that account and store the token. `setup` walks
648
+ you through it.
649
+ - **Already stored a profile yourself?** Adopt it with
650
+ `"direct": { "profileId": "anthropic:<id>" }`. It gets ordered, never
651
+ rewritten.
652
+ - **Existing sessions stay on their account.** OpenClaw pins a profile to
653
+ each session. A new order applies to new sessions, and to a session whose
654
+ profile cools down. A pinned session moves when its account actually hits
655
+ its limit, and the next account in line is by then the healthiest.
656
+ - **Your own profiles are kept.** Anything in the `anthropic` order that
657
+ multi-clawd doesn't manage stays there, after the pool's accounts.
658
+ `"directRoute": { "manageOrder": false }` hands the order back to you.
659
+ - **Health comes from CLI turns only.** Traffic that only uses the API route
660
+ doesn't update it. With no recent CLI turns an account counts as healthy,
661
+ and OpenClaw's reactive rotation does the work.
662
+ - **Nothing changes without `direct`.** No timer, no OpenClaw calls, and the
663
+ same `explain`/`chain`/`doctor` output as before.
664
+
665
+ `multi-clawd direct` shows, per account: the profile, whether it's stored,
666
+ any cooldown, and the live order. `explain` shows the same, and `doctor
667
+ --probe` makes one tiny live call per profile.
668
+
669
+ ## History identity for fresh Claude sessions
670
+
671
+ OpenClaw only replays a conversation into a **fresh** Claude session when it
672
+ knows which account owns the history. It learns that from an auth profile in
673
+ its own store, and Claude logins aren't OpenClaw profiles, so it used to log
674
+ `cli session history refused across auth boundary: reason=auth-unknown` on
675
+ every turn and start fresh sessions blank.
676
+
677
+ multi-clawd now stores one identity-only profile per backend
678
+ (`claw1:multi-clawd-identity`, `clawd:multi-clawd-identity`, …) and points the
679
+ backend at it. The profile is a **name, not a credential**: no token, and
680
+ OpenClaw never uses it to log in — `openclaw models status` shows it as
681
+ `missing_credential`, which is correct. Which account runs is decided exactly
682
+ as before. Right after upgrading, each existing conversation resets its Claude
683
+ session once (`cli session reset … reason=auth-profile` in the log). OpenClaw
684
+ binds the owner only when a conversation is born, so conversations that
685
+ already have history keep logging the refusal line until you start a new one
686
+ (`/new`); every session started after the upgrade is covered from its first
687
+ turn. `"historyIdentity": false` turns the feature off.
688
+
597
689
  ## How it works
598
690
 
599
- Three moves, all through the official plugin SDK (details in
691
+ Four moves, all through the official plugin SDK (details in
600
692
  [`DESIGN.md`](./DESIGN.md)):
601
693
 
602
694
  1. **`registerCliBackend`** mirrors the bundled `claude-cli` backend — same
@@ -609,6 +701,9 @@ Three moves, all through the official plugin SDK (details in
609
701
  3. **`prepareExecution`** injects that account's own login
610
702
  (`CLAUDE_CONFIG_DIR` + `CLAUDE_CODE_OAUTH_TOKEN`) into the child process
611
703
  env, after the host's ambient Claude credentials are stripped.
704
+ 4. **`defaultAuthProfileId`** names an identity-only auth profile per backend,
705
+ so OpenClaw's CLI history boundary binds and fresh sessions get the
706
+ conversation back (see "History identity" above).
612
707
 
613
708
  ## Legacy issue: idle backend eviction on OpenClaw ≤ 2026.7.1
614
709
 
@@ -737,6 +832,9 @@ Early but real — built for and dogfooded in production.
737
832
  for each account shape, verified afterwards (signed-in email shown, token
738
833
  values never touched); ClawHub package published under
739
834
  `@drakon-systems` ✅
835
+ - **v1.9** — one pool, both transports: accounts can also serve OpenClaw's
836
+ direct `anthropic/*` route; the gateway keeps the `anthropic` auth order in
837
+ pool-health order; `multi-clawd direct` / `direct sync` ✅
740
838
  - **Next** — standalone localhost proxy (OpenAI-compatible) so Hermes and
741
839
  custom runtimes can share the pool; true per-session affinity; local
742
840
  five-hour-window signal (turn counting); per-account lock for the shim
package/SECURITY.md CHANGED
@@ -57,6 +57,7 @@ is listed here so you can check them yourself.
57
57
  | `dist/watchdog-schedule.js` | `node <bundled script>` | Runs the eviction watchdog on a timer. Path is the package's own script. |
58
58
  | `scripts/cli.mjs` | `node <bundled script>` | The CLI dispatching to its own subcommands. |
59
59
  | `scripts/setup.mjs` | `launchctl` / `systemctl` | Loads the watchdog timer during setup on macOS/Linux. |
60
+ | `dist/openclaw-runner.js` | the `openclaw` CLI (or `directRoute.openclawCommand`) | Direct route only (v1.9, accounts with `direct`): reads and writes the `anthropic` auth profiles and order through OpenClaw's own subcommands (`models auth list`/`order get`/`order set`/`paste-token`, `secrets apply`, `models status`). A setup-token only ever travels on the child's stdin. Never spawned without `direct` configured. |
60
61
 
61
62
  None of these pass a string to a shell — they are direct process spawns with
62
63
  argument arrays, so there is no quoting or injection surface. Paths are
@@ -47,3 +47,8 @@ export function validateAccountTokenSources(account) {
47
47
  `account "${account.id}" declares ${sources.join(" + ")} — token sources are mutually exclusive; precedence applied is ${sources.includes("native") ? "native" : "oauthTokenFile"} first. Remove the extras.`,
48
48
  ];
49
49
  }
50
+ export function accountConfigDir(account) {
51
+ if (!account.native && account.configDir)
52
+ return expandHomePath(account.configDir);
53
+ return resolve(homedir(), ".claude");
54
+ }
@@ -27,7 +27,7 @@ function parseRef(ref) {
27
27
  return { modelId: ref };
28
28
  return { provider: ref.slice(0, idx).trim().toLowerCase(), modelId: ref.slice(idx + 1) };
29
29
  }
30
- export function offPoolClaudeRef(ref, poolId) {
30
+ export function offPoolClaudeRef(ref, poolId, opts = {}) {
31
31
  if (typeof ref !== "string" || ref.length === 0)
32
32
  return null;
33
33
  const { provider, modelId } = parseRef(ref);
@@ -37,14 +37,16 @@ export function offPoolClaudeRef(ref, poolId) {
37
37
  return null;
38
38
  if (provider === poolId.trim().toLowerCase())
39
39
  return null;
40
+ if (provider === "anthropic" && opts.directPooled)
41
+ return null;
40
42
  if (provider === "anthropic" || provider === "claude-cli")
41
43
  return "strong";
42
44
  if (ACCOUNT_PIN_RE.test(provider))
43
45
  return "warn";
44
46
  return null;
45
47
  }
46
- function classifyBypass(ref, poolId) {
47
- const severity = offPoolClaudeRef(ref, poolId);
48
+ function classifyBypass(ref, poolId, opts) {
49
+ const severity = offPoolClaudeRef(ref, poolId, opts);
48
50
  if (!severity)
49
51
  return null;
50
52
  const { provider } = parseRef(ref);
@@ -170,12 +172,12 @@ export function collectChainRefs(config) {
170
172
  }
171
173
  return out;
172
174
  }
173
- export function auditEffectiveChain(config, poolId) {
175
+ export function auditEffectiveChain(config, poolId, opts = {}) {
174
176
  if (!poolId)
175
177
  return [];
176
178
  const findings = [];
177
179
  for (const { surface, ref, allowlist } of collectChainRefs(config)) {
178
- const reason = classifyBypass(ref, poolId);
180
+ const reason = classifyBypass(ref, poolId, opts);
179
181
  if (!reason)
180
182
  continue;
181
183
  findings.push({ surface, ref, severity: allowlist ? "note" : "warn", reason });
@@ -257,7 +259,7 @@ export function auditChainShadowing(config) {
257
259
  return findings;
258
260
  }
259
261
  const POOL_PROVIDER = "clawd";
260
- export function auditSessionOverrides(sessions, poolConfigured) {
262
+ export function auditSessionOverrides(sessions, poolConfigured, opts = {}) {
261
263
  if (!poolConfigured)
262
264
  return [];
263
265
  const findings = [];
@@ -282,7 +284,7 @@ export function auditSessionOverrides(sessions, poolConfigured) {
282
284
  continue;
283
285
  }
284
286
  const ref = `${provider}/${model}`;
285
- const severity = offPoolClaudeRef(ref, POOL_PROVIDER);
287
+ const severity = offPoolClaudeRef(ref, POOL_PROVIDER, opts);
286
288
  if (!severity)
287
289
  continue;
288
290
  const reason = severity === "strong"
@@ -0,0 +1,119 @@
1
+ import { classifyAccountHealth } from "./health.js";
2
+ import { DIRECT_PROVIDER, collectDirectMembers, } from "./direct-route.js";
3
+ import { parseUnusableProfiles, planAgentOrder, readDirectRouteSnapshot, safeCliError, } from "./direct-sync.js";
4
+ export function describeDirectSource(member) {
5
+ const s = member.source;
6
+ switch (s.kind) {
7
+ case "ref":
8
+ return `setup-token via ${s.ref.provider || "a secret provider"} secret reference${s.reused ? " (the same one its CLI login uses)" : ""}`;
9
+ case "file":
10
+ return `setup-token file ${s.path}${s.reused ? " (the same one its CLI login uses)" : ""}`;
11
+ case "existing":
12
+ return "adopted profile you stored yourself";
13
+ default:
14
+ return "no direct credential";
15
+ }
16
+ }
17
+ export async function gatherDirectStatus(params) {
18
+ const { members, problems } = collectDirectMembers(params.accounts, params.poolAccounts ?? []);
19
+ if (members.length === 0 && problems.length === 0)
20
+ return undefined;
21
+ const errors = [];
22
+ const verdicts = members.map((m) => ({
23
+ accountId: m.accountId,
24
+ profileId: m.profileId,
25
+ verdict: classifyAccountHealth(params.readHealth(m.accountId), params.healthOptions, params.nowMs).verdict,
26
+ }));
27
+ const read = members.length > 0 ? await readDirectRouteSnapshot(params.runner, params.agentId) : {};
28
+ if ("error" in read && read.error)
29
+ errors.push(read.error);
30
+ const snapshot = "snapshot" in read ? read.snapshot : undefined;
31
+ let unusable = [];
32
+ if (members.length > 0 && !params.skipCooldowns) {
33
+ const status = await params.runner(["models", "status", "--agent", params.agentId, "--json"], { timeoutMs: 120_000 });
34
+ const parsed = status.code === 0 ? parseUnusableProfiles(status.stdout) : undefined;
35
+ if (parsed)
36
+ unusable = parsed;
37
+ else
38
+ errors.push(`models status ${safeCliError(status)}`);
39
+ }
40
+ let pendingOrder;
41
+ if (snapshot) {
42
+ pendingOrder = planAgentOrder({
43
+ members: verdicts,
44
+ snapshot,
45
+ configOrder: params.configOrder,
46
+ sticky: params.sticky,
47
+ nowMs: params.nowMs,
48
+ minDwellMs: params.minDwellMs,
49
+ }).order;
50
+ }
51
+ const byProfile = new Map(unusable.map((u) => [u.profileId, u]));
52
+ const order = snapshot?.storedOrder ?? (params.configOrder?.length ? [...params.configOrder] : snapshot ? [] : undefined);
53
+ const orderSource = snapshot?.storedOrder
54
+ ? `stored for agent ${params.agentId}`
55
+ : params.configOrder?.length
56
+ ? "config auth.order"
57
+ : undefined;
58
+ return {
59
+ agentId: params.agentId,
60
+ verdicts,
61
+ pendingOrder,
62
+ unusable,
63
+ errors,
64
+ explain: {
65
+ members: members.map((m) => {
66
+ const u = byProfile.get(m.profileId);
67
+ return {
68
+ accountId: m.accountId,
69
+ profileId: m.profileId,
70
+ source: describeDirectSource(m),
71
+ stored: snapshot ? snapshot.stored.has(m.profileId) : undefined,
72
+ ...(m.source.kind === "existing" ? { adopted: true } : {}),
73
+ ...(u ? { cooldownUntil: u.until, cooldownReason: u.reason ?? u.kind } : {}),
74
+ };
75
+ }),
76
+ problems: problems.map((p) => ({ accountId: p.accountId, reason: p.reason })),
77
+ order,
78
+ orderSource,
79
+ },
80
+ };
81
+ }
82
+ export function parseProbeResults(stdout) {
83
+ const start = stdout.indexOf("{");
84
+ if (start < 0)
85
+ return undefined;
86
+ let doc;
87
+ try {
88
+ doc = JSON.parse(stdout.slice(start));
89
+ }
90
+ catch {
91
+ return undefined;
92
+ }
93
+ const rows = doc?.auth?.probes?.results;
94
+ if (!Array.isArray(rows))
95
+ return undefined;
96
+ return rows
97
+ .filter((r) => typeof r?.profileId === "string" && (r.provider === undefined || r.provider === DIRECT_PROVIDER))
98
+ .map((r) => ({
99
+ profileId: r.profileId,
100
+ status: typeof r.status === "string" ? r.status : "unknown",
101
+ ...(typeof r.error === "string" ? { error: r.error.replace(/sk-ant-[A-Za-z0-9_-]+/g, "sk-ant-…").slice(0, 200) } : {}),
102
+ }));
103
+ }
104
+ export function probeArgs(agentId, profileIds) {
105
+ return [
106
+ "models",
107
+ "status",
108
+ "--agent",
109
+ agentId,
110
+ "--json",
111
+ "--probe",
112
+ "--probe-provider",
113
+ DIRECT_PROVIDER,
114
+ "--probe-profile",
115
+ profileIds.join(","),
116
+ "--probe-max-tokens",
117
+ "16",
118
+ ];
119
+ }
@@ -0,0 +1,156 @@
1
+ import { isSecretRefShape } from "./token-resolution.js";
2
+ import { decideStickySelection } from "./sticky.js";
3
+ export const DIRECT_PROVIDER = "anthropic";
4
+ export const DIRECT_PROFILE_PREFIX = `${DIRECT_PROVIDER}:`;
5
+ export const DIRECT_SETUP_TOKEN_GUIDANCE = "a native or config-dir Claude login is a rotating single-use OAuth grant — copying it into " +
6
+ "OpenClaw would invalidate one of the two copies on the next refresh. Run `claude setup-token` " +
7
+ "signed in as THIS account, store the printed token in your secret manager (or a 0600 file), " +
8
+ "and set direct.tokenRef (or direct.tokenFile) on the account — or, if a profile for this " +
9
+ 'account is already stored in OpenClaw, name it with direct.profileId';
10
+ function asRecord(value) {
11
+ return typeof value === "object" && value !== null && !Array.isArray(value)
12
+ ? value
13
+ : undefined;
14
+ }
15
+ export function directEnabled(account) {
16
+ const d = account.direct;
17
+ if (d === undefined || d === false)
18
+ return false;
19
+ return true;
20
+ }
21
+ export function directCredentialSource(account) {
22
+ if (!directEnabled(account))
23
+ return { kind: "none" };
24
+ const explicit = asRecord(account.direct);
25
+ const explicitRef = explicit?.tokenRef;
26
+ const explicitFile = typeof explicit?.tokenFile === "string" ? explicit.tokenFile.trim() : "";
27
+ if (explicitRef !== undefined && explicitFile) {
28
+ return {
29
+ kind: "unsupported",
30
+ code: "direct_sources_conflict",
31
+ reason: "direct.tokenRef and direct.tokenFile are mutually exclusive — keep one",
32
+ };
33
+ }
34
+ if (explicitRef !== undefined) {
35
+ if (!isSecretRefShape(explicitRef)) {
36
+ return {
37
+ kind: "unsupported",
38
+ code: "direct_ref_malformed",
39
+ reason: 'direct.tokenRef must be { "source": "...", "provider": "...", "id": "..." } like every other secret reference',
40
+ };
41
+ }
42
+ return { kind: "ref", ref: explicitRef, reused: false };
43
+ }
44
+ if (explicitFile)
45
+ return { kind: "file", path: explicitFile, reused: false };
46
+ if (typeof explicit?.profileId === "string" && explicit.profileId.trim()) {
47
+ return { kind: "existing" };
48
+ }
49
+ if (!account.native) {
50
+ if (account.oauthTokenFile) {
51
+ return { kind: "file", path: account.oauthTokenFile, reused: true };
52
+ }
53
+ if (isSecretRefShape(account.oauthTokenRef)) {
54
+ return { kind: "ref", ref: account.oauthTokenRef, reused: true };
55
+ }
56
+ }
57
+ return {
58
+ kind: "unsupported",
59
+ code: "direct_setup_token_required",
60
+ reason: DIRECT_SETUP_TOKEN_GUIDANCE,
61
+ };
62
+ }
63
+ const PROFILE_SUFFIX_RE = /^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$/;
64
+ export function directProfileId(account) {
65
+ const explicit = asRecord(account.direct)?.profileId;
66
+ if (typeof explicit === "string" && explicit.trim()) {
67
+ const id = explicit.trim();
68
+ if (!id.startsWith(DIRECT_PROFILE_PREFIX) || !PROFILE_SUFFIX_RE.test(id.slice(DIRECT_PROFILE_PREFIX.length))) {
69
+ throw new Error(`account "${account.id}": direct.profileId must look like "${DIRECT_PROFILE_PREFIX}<name>" (got "${id}")`);
70
+ }
71
+ return id;
72
+ }
73
+ const suffix = account.id.trim();
74
+ if (!PROFILE_SUFFIX_RE.test(suffix)) {
75
+ throw new Error(`account "${account.id}" cannot become an auth profile id — set direct.profileId`);
76
+ }
77
+ return `${DIRECT_PROFILE_PREFIX}${suffix}`;
78
+ }
79
+ export function collectDirectMembers(accounts, poolOrder = []) {
80
+ const ordered = [];
81
+ const byId = new Map(accounts.filter((a) => a?.id).map((a) => [a.id.trim(), a]));
82
+ for (const id of poolOrder) {
83
+ const a = byId.get(id);
84
+ if (a && !ordered.includes(a))
85
+ ordered.push(a);
86
+ }
87
+ for (const a of byId.values())
88
+ if (!ordered.includes(a))
89
+ ordered.push(a);
90
+ const members = [];
91
+ const problems = [];
92
+ const seenProfiles = new Set();
93
+ for (const account of ordered) {
94
+ const source = directCredentialSource(account);
95
+ if (source.kind === "none")
96
+ continue;
97
+ if (source.kind === "unsupported") {
98
+ problems.push({ accountId: account.id, code: source.code, reason: source.reason });
99
+ continue;
100
+ }
101
+ let profileId;
102
+ try {
103
+ profileId = directProfileId(account);
104
+ }
105
+ catch (err) {
106
+ problems.push({ accountId: account.id, code: "direct_profile_invalid", reason: err.message });
107
+ continue;
108
+ }
109
+ if (seenProfiles.has(profileId)) {
110
+ problems.push({
111
+ accountId: account.id,
112
+ code: "direct_profile_duplicate",
113
+ reason: `profile ${profileId} is already used by another account`,
114
+ });
115
+ continue;
116
+ }
117
+ seenProfiles.add(profileId);
118
+ members.push({ accountId: account.id, profileId, source });
119
+ }
120
+ return { members, problems };
121
+ }
122
+ const VERDICT_RANK = {
123
+ ok: 0,
124
+ no_data: 0,
125
+ near_limit: 1,
126
+ exhausted: 2,
127
+ credential_failed: 3,
128
+ };
129
+ export function planDirectOrder(params) {
130
+ const { members } = params;
131
+ if (members.length === 0)
132
+ return undefined;
133
+ const decision = decideStickySelection({
134
+ verdicts: members.map((m) => ({ id: m.accountId, verdict: m.verdict })),
135
+ sticky: params.sticky,
136
+ nowMs: params.nowMs,
137
+ minDwellMs: params.minDwellMs,
138
+ });
139
+ const first = members.find((m) => m.accountId === decision.account) ?? members[0];
140
+ const rest = members
141
+ .filter((m) => m !== first)
142
+ .map((m, index) => ({ m, index }))
143
+ .sort((a, b) => VERDICT_RANK[a.m.verdict] - VERDICT_RANK[b.m.verdict] || a.index - b.index)
144
+ .map(({ m }) => m.profileId);
145
+ const managed = new Set(members.map((m) => m.profileId));
146
+ const unmanaged = (params.currentOrder ?? []).filter((id, i, all) => typeof id === "string" && !managed.has(id) && all.indexOf(id) === i);
147
+ const order = [first.profileId, ...rest, ...unmanaged];
148
+ const current = params.currentOrder ?? [];
149
+ const changed = order.length !== current.length || order.some((id, i) => id !== current[i]);
150
+ return { order, firstAccount: first.accountId, sticky: decision.sticky, changed };
151
+ }
152
+ export function directRoutePools(accounts, directRoute) {
153
+ if (directRoute?.manageOrder === false)
154
+ return false;
155
+ return collectDirectMembers(accounts).members.length >= 2;
156
+ }