@gotcos/glasses-server 6.28.0 → 6.30.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 +52 -0
- package/package.json +1 -1
- package/server/index.ts +45 -0
- package/server/lib/fork-thread.ts +957 -0
- package/server/lib/occupied-threads.ts +191 -0
- package/server/lib/thread-attach-capability.ts +200 -0
- package/server/routes/agent-session-bindings.ts +475 -1
- package/server/routes/agent-sessions.ts +62 -1
- package/server/routes/health.ts +24 -0
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
// Which threads currently have a live desktop process, in ONE pass.
|
|
2
|
+
//
|
|
3
|
+
// WHY THIS EXISTS SEPARATELY FROM `threadOccupancy`.
|
|
4
|
+
//
|
|
5
|
+
// `claudeOwners(threadId, ...)` re-reads the whole session registry and shells
|
|
6
|
+
// out to `ps` per entry, for ONE thread. That is correct and cheap when a user is
|
|
7
|
+
// deciding whether to write into a single thread. Calling it once per row to
|
|
8
|
+
// decorate a 53-row session list would mean tens of directory scans and hundreds
|
|
9
|
+
// of process spawns on every list request.
|
|
10
|
+
//
|
|
11
|
+
// So this does the opposite shape: scan once, return the set of occupied ids, and
|
|
12
|
+
// let the caller join in memory.
|
|
13
|
+
//
|
|
14
|
+
// THE LOAD-BEARING RULE, AND THE REASON THIS IS SAFE:
|
|
15
|
+
//
|
|
16
|
+
// WHAT THIS PRODUCES IS A DISPLAY HINT. IT MUST NEVER GATE A WRITE.
|
|
17
|
+
//
|
|
18
|
+
// The attach and turn paths keep calling `threadOccupancy` at the moment of the
|
|
19
|
+
// write, unchanged. That is deliberate: a list is rendered seconds or minutes
|
|
20
|
+
// before the user acts, and a desktop session opened in that gap is exactly the
|
|
21
|
+
// race the per-write probe exists to catch. This hint answers "should the UI show
|
|
22
|
+
// this session as busy", never "is it safe to write".
|
|
23
|
+
//
|
|
24
|
+
// Being a hint is also why its failure mode is the opposite of the gate's. The
|
|
25
|
+
// gate fails CLOSED — doubt means refuse. A hint that failed closed would paint
|
|
26
|
+
// every session as running the moment a probe hiccuped, so doubt here means "I do
|
|
27
|
+
// not know", rendered as not-running, and the real gate still refuses at the
|
|
28
|
+
// write. Getting a hint wrong costs a misleading badge; getting the gate wrong
|
|
29
|
+
// costs someone's conversation.
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
claudeOwners,
|
|
33
|
+
codexLockPath,
|
|
34
|
+
codexOwners,
|
|
35
|
+
type OccupancyDirs,
|
|
36
|
+
type OccupancyProbes,
|
|
37
|
+
type ThreadOwner,
|
|
38
|
+
} from './thread-occupancy.js'
|
|
39
|
+
import { isValidNativeThreadId } from './native-thread-id.js'
|
|
40
|
+
|
|
41
|
+
export interface OccupiedThread {
|
|
42
|
+
threadId: string
|
|
43
|
+
/**
|
|
44
|
+
* Every process working in this thread, COS's own children included.
|
|
45
|
+
*
|
|
46
|
+
* This is the DISPLAY question — "is an agent working in here right now" — and
|
|
47
|
+
* a turn COS queued is just as much a running agent as a desktop window is.
|
|
48
|
+
*/
|
|
49
|
+
owners: number
|
|
50
|
+
/**
|
|
51
|
+
* Owners that are NOT COS's own children.
|
|
52
|
+
*
|
|
53
|
+
* The separate count matters because it answers a different question: whether a
|
|
54
|
+
* Continue would be refused. Self-owned work does not block a write; a desktop
|
|
55
|
+
* window does.
|
|
56
|
+
*/
|
|
57
|
+
foreignOwners: number
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface OccupiedScan {
|
|
61
|
+
/** threadId -> occupancy, for every thread with at least one owner. */
|
|
62
|
+
occupied: Map<string, OccupiedThread>
|
|
63
|
+
/**
|
|
64
|
+
* True when the scan could not see clearly (unreadable registry, missing
|
|
65
|
+
* detector). The caller should render "unknown", never "everything is running".
|
|
66
|
+
*/
|
|
67
|
+
degraded: boolean
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const EMPTY: OccupiedScan = { occupied: new Map(), degraded: true }
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Probes that answer each identical question once per scan.
|
|
74
|
+
*
|
|
75
|
+
* MEASURED, not assumed. `claudeOwners` re-reads the whole registry directory and
|
|
76
|
+
* re-runs `ps` for every entry, for ONE thread — so scanning 45 real sessions on
|
|
77
|
+
* this machine cost 771ms of repeated identical I/O. With this wrapper the same
|
|
78
|
+
* scan is one directory read, one read per entry, and one `ps` per pid.
|
|
79
|
+
*
|
|
80
|
+
* Correctness comes from reusing `claudeOwners` / `codexOwners` UNCHANGED, so the
|
|
81
|
+
* self-ownership rule and the PID-reuse guard stay in exactly one place. This only
|
|
82
|
+
* removes duplicate work; it makes no decisions.
|
|
83
|
+
*
|
|
84
|
+
* Scoped to a single scan on purpose. A longer-lived cache would answer "is a
|
|
85
|
+
* desktop process holding this thread" from stale data, and liveness has a
|
|
86
|
+
* lifetime of about now.
|
|
87
|
+
*/
|
|
88
|
+
function memoize(probes: OccupancyProbes): OccupancyProbes {
|
|
89
|
+
const dirs = new Map<string, string[]>()
|
|
90
|
+
const files = new Map<string, string | null>()
|
|
91
|
+
const starts = new Map<number, number | null>()
|
|
92
|
+
const alive = new Map<number, boolean>()
|
|
93
|
+
const exists = new Map<string, boolean>()
|
|
94
|
+
const dirsExist = new Map<string, boolean>()
|
|
95
|
+
let ledger: ReturnType<OccupancyProbes['cosSpawnedPids']> | undefined
|
|
96
|
+
|
|
97
|
+
const once = <K, V>(cache: Map<K, V>, key: K, compute: () => V): V => {
|
|
98
|
+
if (cache.has(key)) return cache.get(key)!
|
|
99
|
+
const value = compute()
|
|
100
|
+
cache.set(key, value)
|
|
101
|
+
return value
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
...probes,
|
|
106
|
+
readDir: (path: string) => once(dirs, path, () => probes.readDir(path)),
|
|
107
|
+
readFile: (path: string) => once(files, path, () => probes.readFile(path)),
|
|
108
|
+
processStartMs: (pid: number) => once(starts, pid, () => probes.processStartMs(pid)),
|
|
109
|
+
isAlive: (pid: number) => once(alive, pid, () => probes.isAlive(pid)),
|
|
110
|
+
fileExists: (path: string) => once(exists, path, () => probes.fileExists(path)),
|
|
111
|
+
dirExists: (path: string) => once(dirsExist, path, () => probes.dirExists(path)),
|
|
112
|
+
cosSpawnedPids: () => (ledger ??= probes.cosSpawnedPids()),
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Scan one provider's registry once and return the threads a desktop process
|
|
118
|
+
* holds.
|
|
119
|
+
*
|
|
120
|
+
* Reuses `claudeOwners` / `codexOwners` rather than reimplementing the parsing,
|
|
121
|
+
* so the self-ownership rule (COS's own spawned children must not read as foreign
|
|
122
|
+
* owners) and the PID-reuse guard stay in exactly one place. Reuse alone would
|
|
123
|
+
* repeat the whole registry read per thread — `memoize` is what removes that, so
|
|
124
|
+
* the saving is real I/O rather than a claim in a comment.
|
|
125
|
+
*/
|
|
126
|
+
export function occupiedThreads(
|
|
127
|
+
provider: string,
|
|
128
|
+
threadIds: readonly string[],
|
|
129
|
+
probes: OccupancyProbes,
|
|
130
|
+
dirs: OccupancyDirs,
|
|
131
|
+
): OccupiedScan {
|
|
132
|
+
if (provider !== 'claude' && provider !== 'codex') return { occupied: new Map(), degraded: false }
|
|
133
|
+
|
|
134
|
+
// Deduplicated and validated up front. An invalid id cannot match a record, and
|
|
135
|
+
// it also reaches a filesystem path in `codexLockPath`.
|
|
136
|
+
const ids = [...new Set(threadIds)].filter(isValidNativeThreadId)
|
|
137
|
+
if (ids.length === 0) return { occupied: new Map(), degraded: false }
|
|
138
|
+
|
|
139
|
+
// Every identical read answered once for the whole scan. See `memoize`.
|
|
140
|
+
const probe = memoize(probes)
|
|
141
|
+
const occupied = new Map<string, OccupiedThread>()
|
|
142
|
+
let degraded = false
|
|
143
|
+
|
|
144
|
+
for (const threadId of ids) {
|
|
145
|
+
// MEASURED: `codexOwners` reaches `lsof` even when no writer lock exists, and
|
|
146
|
+
// lsof costs ~45ms a call — 761ms across 17 codex threads on this machine,
|
|
147
|
+
// against zero locks on disk. A missing lock means no writer, which is the
|
|
148
|
+
// same conclusion lsof reaches the expensive way, so skip it.
|
|
149
|
+
//
|
|
150
|
+
// Only an explicit `false` skips. `fileExists` collapses absent with
|
|
151
|
+
// unreadable, and an unreadable lock must still go through the real check
|
|
152
|
+
// rather than being read as free.
|
|
153
|
+
if (provider === 'codex') {
|
|
154
|
+
let lockPresent = true
|
|
155
|
+
try {
|
|
156
|
+
lockPresent = probe.fileExists(codexLockPath(threadId, dirs.codexLocksDir))
|
|
157
|
+
} catch {
|
|
158
|
+
lockPresent = true
|
|
159
|
+
}
|
|
160
|
+
if (!lockPresent) continue
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
let owners: readonly ThreadOwner[]
|
|
164
|
+
let doubt: string | null
|
|
165
|
+
try {
|
|
166
|
+
const result = provider === 'claude'
|
|
167
|
+
? claudeOwners(threadId, probe, dirs.claudeSessionsDir)
|
|
168
|
+
: codexOwners(threadId, probe, dirs.codexLocksDir)
|
|
169
|
+
owners = result.owners
|
|
170
|
+
doubt = result.doubt
|
|
171
|
+
} catch {
|
|
172
|
+
// One thread's probe throwing does not invalidate the others.
|
|
173
|
+
degraded = true
|
|
174
|
+
continue
|
|
175
|
+
}
|
|
176
|
+
if (doubt !== null) degraded = true
|
|
177
|
+
// ANY owner means an agent is working here. Counting only foreign ones would
|
|
178
|
+
// hide COS's own queued turn from the very screen the user opens to watch it.
|
|
179
|
+
const foreign = owners.filter(o => !o.selfOwned).length
|
|
180
|
+
if (owners.length > 0) {
|
|
181
|
+
occupied.set(threadId, { threadId, owners: owners.length, foreignOwners: foreign })
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
return { occupied, degraded }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** The safe answer when the caller cannot scan at all. */
|
|
189
|
+
export function noOccupancyKnown(): OccupiedScan {
|
|
190
|
+
return { occupied: new Map(EMPTY.occupied), degraded: true }
|
|
191
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
// Does this server offer Continue at all, and for what?
|
|
2
|
+
//
|
|
3
|
+
// The Continue/Fork affordance is a UI decision that has to be made BEFORE any
|
|
4
|
+
// thread is named, so COS Control and the phone cannot discover it the way they
|
|
5
|
+
// discover everything else about a thread — by asking
|
|
6
|
+
// /api/agent-sessions/:provider/:threadId/attachability. When the feature is off
|
|
7
|
+
// the two write routes are not registered at all, so a client that reaches for
|
|
8
|
+
// them gets a bare 404 with no reason and no copy, which is indistinguishable
|
|
9
|
+
// from a typo, a proxy, or a dead server. This module is what a client reads
|
|
10
|
+
// instead, once, up front.
|
|
11
|
+
//
|
|
12
|
+
// ---------------------------------------------------------------- the rule
|
|
13
|
+
//
|
|
14
|
+
// ABSENT MEANS DISABLED. NEVER ENABLED.
|
|
15
|
+
//
|
|
16
|
+
// These fields did not exist before this build. Every client older than it will
|
|
17
|
+
// read `undefined` from all three, and `undefined` MUST resolve to off — the same
|
|
18
|
+
// fail-closed posture as the rest of Continue Original Agent Thread, for the same
|
|
19
|
+
// reason: a client that guesses "the field is missing, so probably fine" offers a
|
|
20
|
+
// Continue button that writes into a real human's conversation, and the cost of
|
|
21
|
+
// guessing the other way is one Fork.
|
|
22
|
+
//
|
|
23
|
+
// That rule cannot be enforced by the wire format, because absence is exactly what
|
|
24
|
+
// an old client sees and an old client cannot be changed. So it is enforced by
|
|
25
|
+
// `readThreadAttachCapability`, which is the ONLY sanctioned way to interpret this
|
|
26
|
+
// surface, and by the test that feeds it a genuine older-shaped /api/health body
|
|
27
|
+
// and requires all three answers to come back off.
|
|
28
|
+
//
|
|
29
|
+
// `supported` exists solely to make the two OFFs distinguishable. Without it,
|
|
30
|
+
// "this server has never heard of Continue" and "this server can Continue but the
|
|
31
|
+
// user has not switched it on" are the same empty answer, and a client cannot tell
|
|
32
|
+
// a user to flip a setting that may not exist. It is a compile-time constant: this
|
|
33
|
+
// build has the code. Whether the routes are reachable is `enabled`.
|
|
34
|
+
//
|
|
35
|
+
// ------------------------------------------------------------ anti-drift
|
|
36
|
+
//
|
|
37
|
+
// `enabled` reads `threadAttachEnabled()` — the SAME function
|
|
38
|
+
// routes/agent-session-bindings.ts calls to decide whether to register the two
|
|
39
|
+
// POST routes. Not a second copy of `process.env.COS_THREAD_ATTACH_ENABLED === '1'`.
|
|
40
|
+
// A copy is how this surface comes to advertise a write path that 404s, and the
|
|
41
|
+
// repo already carries that lesson one import above the call site in
|
|
42
|
+
// routes/health.ts: MEDIA_CHUNKED_UPLOAD_ENABLED is imported from the route that
|
|
43
|
+
// owns the endpoints "so it cannot drift from whether they are actually
|
|
44
|
+
// registered."
|
|
45
|
+
//
|
|
46
|
+
// `providers` is sourced from BINDABLE_PROVIDERS for the same reason. Cursor must
|
|
47
|
+
// not appear (plan 2.5 makes it Fork-only), and the way to guarantee that is to
|
|
48
|
+
// read the list that `isBindableProvider` — the check the attach route itself
|
|
49
|
+
// applies — is built from, rather than restating two names here and hoping.
|
|
50
|
+
//
|
|
51
|
+
// ------------------------------------------------- why providers empties out
|
|
52
|
+
//
|
|
53
|
+
// When the gate is off, `providers` is `[]` rather than the list this build could
|
|
54
|
+
// drive if it were on. A client that reads only `threadAttachProviders` and never
|
|
55
|
+
// looks at `threadAttachEnabled` is then still correct. Every field independently
|
|
56
|
+
// resolves to "no" when the answer is no; there is no combination of fields a
|
|
57
|
+
// careless reader can pick that yields a Continue button pointing at an
|
|
58
|
+
// unregistered route.
|
|
59
|
+
//
|
|
60
|
+
// The cost is that a disabled server cannot tell an operator WHICH providers it
|
|
61
|
+
// would support. That is a settings-copy problem, and `supported: true` is enough
|
|
62
|
+
// to say "this build can do it, turn it on."
|
|
63
|
+
|
|
64
|
+
import {
|
|
65
|
+
BINDABLE_PROVIDERS,
|
|
66
|
+
isBindableProvider,
|
|
67
|
+
type BindableProvider,
|
|
68
|
+
} from './agent-session-binding-store.js'
|
|
69
|
+
import { threadAttachEnabled } from '../routes/agent-session-bindings.js'
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* This build has the feature compiled in.
|
|
73
|
+
*
|
|
74
|
+
* A constant, not a probe. There is no configuration under which the code is
|
|
75
|
+
* absent from a build that contains this file; the reachable/unreachable question
|
|
76
|
+
* is `enabled`. It is published so that `undefined` (an older server) and `false`
|
|
77
|
+
* (this build, switched off) are different answers on the wire.
|
|
78
|
+
*/
|
|
79
|
+
export const THREAD_ATTACH_SUPPORTED = true
|
|
80
|
+
|
|
81
|
+
export interface ThreadAttachCapability {
|
|
82
|
+
/** The build knows what Continue is. False only when read off an older payload. */
|
|
83
|
+
supported: boolean
|
|
84
|
+
/** The write routes are registered and a Continue may be attempted. */
|
|
85
|
+
enabled: boolean
|
|
86
|
+
/**
|
|
87
|
+
* Providers that can be CONTINUED, not merely browsed or forked.
|
|
88
|
+
*
|
|
89
|
+
* Empty whenever `enabled` is false. Cursor is never a member: it is Fork-only.
|
|
90
|
+
*/
|
|
91
|
+
providers: BindableProvider[]
|
|
92
|
+
/**
|
|
93
|
+
* Fork is available. NOT gated by `enabled`.
|
|
94
|
+
*
|
|
95
|
+
* Fork creates a new thread and leaves the source byte-identical, so the flag
|
|
96
|
+
* that protects an existing conversation does not apply. Gating it produced an
|
|
97
|
+
* incoherent default: every refusal recommends Fork while the route 404s.
|
|
98
|
+
*/
|
|
99
|
+
forkSupported: boolean
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* The capability as this server should publish it.
|
|
104
|
+
*
|
|
105
|
+
* @param gate Tests only. Production takes the default, which is the same
|
|
106
|
+
* function that decides whether the write routes exist. Passing anything else
|
|
107
|
+
* from production code re-opens the drift this module was written to close.
|
|
108
|
+
*/
|
|
109
|
+
export function threadAttachCapability(
|
|
110
|
+
gate: () => boolean = threadAttachEnabled,
|
|
111
|
+
): ThreadAttachCapability {
|
|
112
|
+
let enabled = false
|
|
113
|
+
try {
|
|
114
|
+
// Anything other than an exact `true` is off. A gate that answers "maybe" is
|
|
115
|
+
// answering no, and a gate that throws has not answered at all.
|
|
116
|
+
enabled = gate() === true
|
|
117
|
+
} catch (error) {
|
|
118
|
+
console.error(
|
|
119
|
+
`[thread-attach-capability] gate threw: ${error instanceof Error ? error.message : error}`,
|
|
120
|
+
)
|
|
121
|
+
enabled = false
|
|
122
|
+
}
|
|
123
|
+
return {
|
|
124
|
+
supported: THREAD_ATTACH_SUPPORTED,
|
|
125
|
+
enabled,
|
|
126
|
+
providers: enabled ? [...BINDABLE_PROVIDERS] : [],
|
|
127
|
+
// NOT gated by `enabled`: fork creates a new thread and leaves the source
|
|
128
|
+
// byte-identical, so the flag protecting an existing conversation does not
|
|
129
|
+
// apply. Gating it made every refusal recommend a route that 404s.
|
|
130
|
+
forkSupported: THREAD_ATTACH_SUPPORTED,
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The three keys as they appear on /api/health and /api/models. */
|
|
135
|
+
export interface ThreadAttachHealthFields {
|
|
136
|
+
threadAttachSupported: boolean
|
|
137
|
+
threadAttachEnabled: boolean
|
|
138
|
+
/** Fork is available whenever the build supports it, gate or no gate. */
|
|
139
|
+
threadForkSupported: boolean
|
|
140
|
+
threadAttachProviders: BindableProvider[]
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Flatten the capability onto the health payload's key names. */
|
|
144
|
+
export function threadAttachHealthFields(
|
|
145
|
+
capability: ThreadAttachCapability,
|
|
146
|
+
): ThreadAttachHealthFields {
|
|
147
|
+
return {
|
|
148
|
+
threadAttachSupported: capability.supported,
|
|
149
|
+
threadAttachEnabled: capability.enabled,
|
|
150
|
+
threadAttachProviders: capability.providers,
|
|
151
|
+
threadForkSupported: capability.forkSupported,
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Interpret a health payload from ANY server, including one that predates these
|
|
157
|
+
* fields entirely.
|
|
158
|
+
*
|
|
159
|
+
* The whole point of this function is the defaults, so read them as the contract:
|
|
160
|
+
*
|
|
161
|
+
* - a payload that is not an object at all -> everything off
|
|
162
|
+
* - `threadAttachSupported` anything but exactly `true` -> not supported, and
|
|
163
|
+
* therefore not enabled, whatever the other two fields say
|
|
164
|
+
* - `threadAttachEnabled` anything but exactly `true` -> off, so `'true'`, `1`
|
|
165
|
+
* and `'yes'` from a hand-rolled or proxied payload all fail closed
|
|
166
|
+
* - `providers` dropped entirely unless enabled, and filtered to names THIS
|
|
167
|
+
* build can actually drive, so a newer server naming a fourth provider does
|
|
168
|
+
* not make an older client offer a Continue it cannot perform
|
|
169
|
+
*
|
|
170
|
+
* A payload that contradicts itself — enabled without supported, or providers
|
|
171
|
+
* while disabled — is a defect somewhere, and a defect resolves to refuse.
|
|
172
|
+
*/
|
|
173
|
+
export function readThreadAttachCapability(payload: unknown): ThreadAttachCapability {
|
|
174
|
+
const row =
|
|
175
|
+
payload && typeof payload === 'object' && !Array.isArray(payload)
|
|
176
|
+
? (payload as Record<string, unknown>)
|
|
177
|
+
: null
|
|
178
|
+
|
|
179
|
+
const supported = row?.threadAttachSupported === true
|
|
180
|
+
const enabled = supported && row?.threadAttachEnabled === true
|
|
181
|
+
|
|
182
|
+
const providers: BindableProvider[] = []
|
|
183
|
+
const raw = row?.threadAttachProviders
|
|
184
|
+
if (enabled && Array.isArray(raw)) {
|
|
185
|
+
for (const entry of raw) {
|
|
186
|
+
// `isBindableProvider` is the attach route's own check. Anything it rejects
|
|
187
|
+
// is a provider this build has no code path for, so offering it would
|
|
188
|
+
// produce a Continue that cannot be honoured.
|
|
189
|
+
if (!isBindableProvider(entry)) continue
|
|
190
|
+
if (providers.includes(entry)) continue
|
|
191
|
+
providers.push(entry)
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read off a payload from another server. An older build has no fork route, and
|
|
196
|
+
// its payload carries no such field, so absent must read as NOT supported — the
|
|
197
|
+
// same fail-closed rule the rest of this module runs on.
|
|
198
|
+
const forkSupported = row?.threadForkSupported === true
|
|
199
|
+
return { supported, enabled, providers, forkSupported }
|
|
200
|
+
}
|