opencode-consistent 0.1.0 → 0.4.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/README.md +29 -12
- package/freeze.js +110 -13
- package/index.js +117 -20
- package/package.json +6 -4
- package/wait.js +69 -0
package/README.md
CHANGED
|
@@ -1,16 +1,21 @@
|
|
|
1
1
|
# opencode-consistent
|
|
2
2
|
|
|
3
|
-
OpenCode rebuilds the system prompt on every turn.
|
|
3
|
+
OpenCode rebuilds the system prompt on every turn. In 1.18.27 the `<env>` block is cwd, worktree, git yes/no, platform, and `Today's date: ${new Date().toDateString()}`. Prefix caches hash from token 0, so when the calendar rolls the whole session misses.
|
|
4
4
|
|
|
5
|
-
|
|
5
|
+
AGENTS.md, MCP instructions, and the skill list are re-read from disk. If you did not edit them, a restart already sends the same bytes. This plugin does **not** freeze those, so a mid-session edit still reaches the model (and correctly misses the old prefix).
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
The other restart miss is MCP **tools**, which are not in the system string. They sit after the built-in tool schemas. If those servers are still connecting, vLLM hashes builtins only (1,792 tokens = 7×256 blocks) and prefills the rest.
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
This plugin:
|
|
10
|
+
|
|
11
|
+
1. **Pins `Today's date`** on the first real turn of a `sessionID` and rewrites just that field later, including after quit/reopen.
|
|
12
|
+
2. **Waits** in `chat.message` until the same MCP servers are `connected` (or have finished failing) so `SessionTools.resolve` includes the same tool block.
|
|
13
|
+
|
|
14
|
+
Upstream: [#32622](https://github.com/anomalyco/opencode/issues/32622) (date in the cached prefix, closed not-planned), [#29672](https://github.com/anomalyco/opencode/issues/29672), [PR #29949](https://github.com/anomalyco/opencode/pull/29949) (move env to the tail, not default as of 1.18.27).
|
|
10
15
|
|
|
11
16
|
## Install
|
|
12
17
|
|
|
13
|
-
Put it **last** in the plugin list
|
|
18
|
+
Put it **last** in the plugin list:
|
|
14
19
|
|
|
15
20
|
```json
|
|
16
21
|
{
|
|
@@ -20,21 +25,33 @@ Put it **last** in the plugin list so other plugins mutate first, then this free
|
|
|
20
25
|
}
|
|
21
26
|
```
|
|
22
27
|
|
|
23
|
-
|
|
28
|
+
Optional wait budget (milliseconds). Default `15000`. `0` polls once and does not block.
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"plugin": [
|
|
33
|
+
["opencode-consistent", { "mcpWaitMs": 20000 }]
|
|
34
|
+
]
|
|
35
|
+
}
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Same value via `OPENCODE_CONSISTENT_MCP_WAIT_MS`. Restart OpenCode. Plugins load at process start.
|
|
24
39
|
|
|
25
40
|
## What it freezes
|
|
26
41
|
|
|
27
|
-
|
|
42
|
+
`Today's date` per `sessionID`, on disk under `~/.local/state/opencode/consistent/`.
|
|
43
|
+
|
|
44
|
+
The list of MCP server names that were `connected` when that date was first pinned. Later turns wait until those names are `connected` again.
|
|
28
45
|
|
|
29
|
-
Title-generation
|
|
46
|
+
Title-generation has no date line. Those calls are ignored.
|
|
30
47
|
|
|
31
48
|
## What it does not freeze
|
|
32
49
|
|
|
33
|
-
|
|
50
|
+
AGENTS.md, cwd, skills, MCP instruction prose. Change them and the next turn is live — and a new prefix hash.
|
|
34
51
|
|
|
35
|
-
|
|
52
|
+
Tool JSON schemas. The plugin cannot inject a missing MCP tool. If a server is still down after `mcpWaitMs`, or it advertises different tools, the hash still misses after the built-in set.
|
|
36
53
|
|
|
37
|
-
`/compact` still rewrites history.
|
|
54
|
+
`/compact` still rewrites history.
|
|
38
55
|
|
|
39
56
|
## Debug
|
|
40
57
|
|
|
@@ -42,7 +59,7 @@ A process restart drops the snapshot. The first turn after a relaunch writes a n
|
|
|
42
59
|
OPENCODE_CONSISTENT_LOG=1 opencode
|
|
43
60
|
```
|
|
44
61
|
|
|
45
|
-
Writes `~/.local/state/opencode/opencode-consistent.log` with `snapshot` / `restore
|
|
62
|
+
Writes `~/.local/state/opencode/opencode-consistent.log` with `wait ok|timeout` and `snapshot` / `restore date=…`. Restore keeps the first-turn date; `sha=` of the full system string is allowed to move when AGENTS.md or skills change.
|
|
46
63
|
|
|
47
64
|
## License
|
|
48
65
|
|
package/freeze.js
CHANGED
|
@@ -1,38 +1,135 @@
|
|
|
1
|
+
import fs from "node:fs"
|
|
2
|
+
import os from "node:os"
|
|
3
|
+
import path from "node:path"
|
|
4
|
+
|
|
1
5
|
const MAX_SESSIONS = 32
|
|
6
|
+
/** JS Date#toDateString: "Thu Sep 03 2026" */
|
|
7
|
+
export const DATE_LINE = /Today's date:\s+[A-Za-z]{3} [A-Za-z]{3} +\d{1,2} \d{4}/
|
|
8
|
+
const DATE_VALUE = /Today's date:\s+([A-Za-z]{3} [A-Za-z]{3} +\d{1,2} \d{4})/
|
|
9
|
+
|
|
10
|
+
/** @type {Map<string, { date: string, mcp: { connected: string[] } | null, savedAt?: number }>} */
|
|
2
11
|
const snapshots = new Map()
|
|
3
12
|
|
|
13
|
+
export function extractDate(system) {
|
|
14
|
+
const text = Array.isArray(system) ? system.join("\n") : typeof system === "string" ? system : ""
|
|
15
|
+
const match = text.match(DATE_VALUE)
|
|
16
|
+
return match ? match[1] : null
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function applyDate(system, date) {
|
|
20
|
+
if (!date || !Array.isArray(system)) return false
|
|
21
|
+
let hit = false
|
|
22
|
+
for (let i = 0; i < system.length; i++) {
|
|
23
|
+
if (DATE_LINE.test(system[i])) {
|
|
24
|
+
system[i] = system[i].replace(DATE_LINE, `Today's date: ${date}`)
|
|
25
|
+
hit = true
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return hit
|
|
29
|
+
}
|
|
30
|
+
|
|
4
31
|
export function isEnvBearing(system) {
|
|
5
|
-
|
|
6
|
-
const text = system.join("\n")
|
|
7
|
-
return text.includes("<env>") || text.includes("Today's date:")
|
|
32
|
+
return extractDate(system) != null
|
|
8
33
|
}
|
|
9
34
|
|
|
10
|
-
function
|
|
35
|
+
export function stateDir() {
|
|
36
|
+
return process.env.OPENCODE_CONSISTENT_DIR || path.join(os.homedir(), ".local", "state", "opencode", "consistent")
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fileFor(sessionID) {
|
|
40
|
+
const safe = String(sessionID).replace(/[^A-Za-z0-9._-]/g, "_")
|
|
41
|
+
return path.join(stateDir(), `${safe}.json`)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function cloneRecord(rec) {
|
|
45
|
+
return {
|
|
46
|
+
date: rec.date,
|
|
47
|
+
mcp: rec.mcp && Array.isArray(rec.mcp.connected) ? { connected: rec.mcp.connected.slice() } : null,
|
|
48
|
+
savedAt: rec.savedAt,
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function loadRecord(sessionID) {
|
|
53
|
+
if (!sessionID) return null
|
|
54
|
+
try {
|
|
55
|
+
const raw = fs.readFileSync(fileFor(sessionID), "utf8")
|
|
56
|
+
const parsed = JSON.parse(raw)
|
|
57
|
+
let date = typeof parsed.date === "string" ? parsed.date : null
|
|
58
|
+
if (!date && Array.isArray(parsed.system)) date = extractDate(parsed.system)
|
|
59
|
+
if (!date) return null
|
|
60
|
+
const mcp =
|
|
61
|
+
parsed.mcp && Array.isArray(parsed.mcp.connected) ? { connected: parsed.mcp.connected.map(String) } : null
|
|
62
|
+
return { date, mcp, savedAt: parsed.savedAt }
|
|
63
|
+
} catch {
|
|
64
|
+
return null
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function saveRecord(sessionID, rec) {
|
|
69
|
+
if (!sessionID || !rec?.date) return
|
|
70
|
+
fs.mkdirSync(stateDir(), { recursive: true })
|
|
71
|
+
const body = { date: rec.date, savedAt: rec.savedAt ?? Date.now() }
|
|
72
|
+
if (rec.mcp && Array.isArray(rec.mcp.connected)) {
|
|
73
|
+
body.mcp = { connected: rec.mcp.connected.slice() }
|
|
74
|
+
}
|
|
75
|
+
fs.writeFileSync(fileFor(sessionID), JSON.stringify(body))
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function forgetDisk(sessionID) {
|
|
79
|
+
if (!sessionID) return
|
|
80
|
+
try {
|
|
81
|
+
fs.unlinkSync(fileFor(sessionID))
|
|
82
|
+
} catch {
|
|
83
|
+
/* missing is fine */
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function remember(sessionID, rec) {
|
|
11
88
|
if (snapshots.has(sessionID)) snapshots.delete(sessionID)
|
|
12
|
-
snapshots.set(sessionID,
|
|
89
|
+
snapshots.set(sessionID, cloneRecord(rec))
|
|
13
90
|
while (snapshots.size > MAX_SESSIONS) {
|
|
14
91
|
const oldest = snapshots.keys().next().value
|
|
15
92
|
snapshots.delete(oldest)
|
|
16
93
|
}
|
|
17
94
|
}
|
|
18
95
|
|
|
96
|
+
export function peekRecord(sessionID) {
|
|
97
|
+
if (!sessionID) return null
|
|
98
|
+
const live = snapshots.get(sessionID)
|
|
99
|
+
if (live) return cloneRecord(live)
|
|
100
|
+
const disk = loadRecord(sessionID)
|
|
101
|
+
return disk ? cloneRecord(disk) : null
|
|
102
|
+
}
|
|
103
|
+
|
|
19
104
|
/** @returns {"snapshot"|"restore"|"skip"} */
|
|
20
105
|
export function freezeSessionSystem(sessionID, system) {
|
|
21
|
-
if (!sessionID || !Array.isArray(system)
|
|
22
|
-
const
|
|
106
|
+
if (!sessionID || !Array.isArray(system)) return "skip"
|
|
107
|
+
const date = extractDate(system)
|
|
108
|
+
if (!date) return "skip"
|
|
109
|
+
const prev = snapshots.get(sessionID) || loadRecord(sessionID)
|
|
23
110
|
if (!prev) {
|
|
24
|
-
|
|
111
|
+
const rec = { date, mcp: null, savedAt: Date.now() }
|
|
112
|
+
remember(sessionID, rec)
|
|
113
|
+
saveRecord(sessionID, rec)
|
|
25
114
|
return "snapshot"
|
|
26
115
|
}
|
|
27
|
-
system.
|
|
28
|
-
|
|
29
|
-
snapshots.delete(sessionID)
|
|
30
|
-
snapshots.set(sessionID, prev)
|
|
116
|
+
applyDate(system, prev.date)
|
|
117
|
+
remember(sessionID, prev)
|
|
31
118
|
return "restore"
|
|
32
119
|
}
|
|
33
120
|
|
|
121
|
+
export function setMcpConnected(sessionID, connected) {
|
|
122
|
+
const rec = snapshots.get(sessionID) || loadRecord(sessionID)
|
|
123
|
+
if (!rec) return
|
|
124
|
+
rec.mcp = { connected: [...new Set((connected ?? []).map(String))].sort() }
|
|
125
|
+
remember(sessionID, rec)
|
|
126
|
+
saveRecord(sessionID, rec)
|
|
127
|
+
}
|
|
128
|
+
|
|
34
129
|
export function forgetSession(sessionID) {
|
|
35
|
-
if (sessionID)
|
|
130
|
+
if (!sessionID) return
|
|
131
|
+
snapshots.delete(sessionID)
|
|
132
|
+
forgetDisk(sessionID)
|
|
36
133
|
}
|
|
37
134
|
|
|
38
135
|
export function resetSnapshots() {
|
package/index.js
CHANGED
|
@@ -2,37 +2,134 @@ import { createHash } from "node:crypto"
|
|
|
2
2
|
import fs from "node:fs"
|
|
3
3
|
import os from "node:os"
|
|
4
4
|
import path from "node:path"
|
|
5
|
-
import { freezeSessionSystem, forgetSession } from "./freeze.js"
|
|
5
|
+
import { freezeSessionSystem, forgetSession, peekRecord, setMcpConnected } from "./freeze.js"
|
|
6
|
+
import { connectedNames, hasConnected, isSettled, waitMsFrom, waitUntil, wantedServers } from "./wait.js"
|
|
6
7
|
|
|
7
8
|
function digest(system) {
|
|
8
9
|
return createHash("sha256").update(system.join("\n")).digest("hex").slice(0, 12)
|
|
9
10
|
}
|
|
10
11
|
|
|
11
|
-
function
|
|
12
|
+
function logPath() {
|
|
13
|
+
return path.join(os.homedir(), ".local", "state", "opencode", "opencode-consistent.log")
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function logRaw(line) {
|
|
12
17
|
if (process.env.OPENCODE_CONSISTENT_LOG !== "1") return
|
|
13
18
|
try {
|
|
14
|
-
|
|
15
|
-
fs.
|
|
16
|
-
const file = path.join(dir, "opencode-consistent.log")
|
|
17
|
-
const line = `${new Date().toISOString()} ${kind} session=${sessionID.slice(0, 12)} sha=${digest(system)} parts=${system.length} bytes=${system.join("\n").length}\n`
|
|
18
|
-
fs.appendFileSync(file, line)
|
|
19
|
+
fs.mkdirSync(path.dirname(logPath()), { recursive: true })
|
|
20
|
+
fs.appendFileSync(logPath(), `${new Date().toISOString()} ${line}\n`)
|
|
19
21
|
} catch {
|
|
20
22
|
/* logging must never break a turn */
|
|
21
23
|
}
|
|
22
24
|
}
|
|
23
25
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
26
|
+
function logSystem(kind, sessionID, system) {
|
|
27
|
+
const rec = peekRecord(sessionID)
|
|
28
|
+
const date = rec?.date ?? "none"
|
|
29
|
+
logRaw(
|
|
30
|
+
`${kind} session=${sessionID.slice(0, 12)} date=${date.replaceAll(" ", "_")} sha=${digest(system)} parts=${system.length} bytes=${system.join("\n").length}`,
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readStatus(client) {
|
|
35
|
+
if (!client?.mcp?.status) return {}
|
|
36
|
+
try {
|
|
37
|
+
const result = await client.mcp.status()
|
|
38
|
+
const data = result?.data
|
|
39
|
+
if (data && typeof data === "object" && !Array.isArray(data)) return data
|
|
40
|
+
return {}
|
|
41
|
+
} catch {
|
|
42
|
+
return {}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async function readWanted(client) {
|
|
47
|
+
if (!client?.config?.get) return []
|
|
48
|
+
try {
|
|
49
|
+
const result = await client.config.get()
|
|
50
|
+
const cfg = result?.data ?? result
|
|
51
|
+
return wantedServers(cfg?.mcp)
|
|
52
|
+
} catch {
|
|
53
|
+
return []
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const OpencodeConsistentPlugin = async (ctx = {}, options = {}) => {
|
|
58
|
+
const client = ctx.client
|
|
59
|
+
let wanted = []
|
|
60
|
+
/** @type {Map<string, Record<string, { status: string }>>} */
|
|
61
|
+
const lastWait = new Map()
|
|
62
|
+
const timeoutMs = waitMsFrom(options.mcpWaitMs)
|
|
63
|
+
const intervalMs =
|
|
64
|
+
options.mcpWaitIntervalMs !== undefined && Number.isFinite(Number(options.mcpWaitIntervalMs))
|
|
65
|
+
? Math.max(0, Number(options.mcpWaitIntervalMs))
|
|
66
|
+
: undefined
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
config: async (cfg) => {
|
|
70
|
+
wanted = wantedServers(cfg?.mcp)
|
|
71
|
+
},
|
|
72
|
+
"chat.message": async (input) => {
|
|
73
|
+
const sessionID = input?.sessionID
|
|
74
|
+
if (!sessionID) return
|
|
75
|
+
try {
|
|
76
|
+
if (wanted.length === 0) wanted = await readWanted(client)
|
|
77
|
+
const rec = peekRecord(sessionID)
|
|
78
|
+
const required = rec?.mcp?.connected
|
|
79
|
+
let mode
|
|
80
|
+
let predicate
|
|
81
|
+
if (Array.isArray(required)) {
|
|
82
|
+
if (required.length === 0) {
|
|
83
|
+
logRaw(`wait skip session=${sessionID.slice(0, 12)} reason=no-mcp`)
|
|
84
|
+
lastWait.set(sessionID, {})
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
mode = "restore"
|
|
88
|
+
predicate = (status) => hasConnected(status, required)
|
|
89
|
+
} else {
|
|
90
|
+
if (wanted.length === 0) {
|
|
91
|
+
logRaw(`wait skip session=${sessionID.slice(0, 12)} reason=no-wanted`)
|
|
92
|
+
lastWait.set(sessionID, {})
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
mode = rec ? "upgrade" : "first"
|
|
96
|
+
predicate = (status) => isSettled(status, wanted)
|
|
97
|
+
}
|
|
98
|
+
const result = await waitUntil({
|
|
99
|
+
poll: () => readStatus(client),
|
|
100
|
+
ok: predicate,
|
|
101
|
+
timeoutMs,
|
|
102
|
+
intervalMs,
|
|
103
|
+
})
|
|
104
|
+
lastWait.set(sessionID, result.last ?? {})
|
|
105
|
+
const connected = connectedNames(result.last).join(",") || "-"
|
|
106
|
+
logRaw(
|
|
107
|
+
`wait ${result.ok ? "ok" : "timeout"} mode=${mode} session=${sessionID.slice(0, 12)} elapsed=${result.elapsed} connected=${connected}`,
|
|
108
|
+
)
|
|
109
|
+
} catch (err) {
|
|
110
|
+
logRaw(`wait error session=${sessionID.slice(0, 12)} ${err?.message ?? err}`)
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"experimental.chat.system.transform": async (input, output) => {
|
|
114
|
+
const sessionID = input?.sessionID
|
|
115
|
+
const system = output?.system
|
|
116
|
+
const kind = freezeSessionSystem(sessionID, system)
|
|
117
|
+
if (kind === "skip") return
|
|
118
|
+
const rec = peekRecord(sessionID)
|
|
119
|
+
const waited = lastWait.get(sessionID)
|
|
120
|
+
const shouldCapture = waited !== undefined && (kind === "snapshot" || rec?.mcp == null)
|
|
121
|
+
if (shouldCapture) setMcpConnected(sessionID, connectedNames(waited))
|
|
122
|
+
logSystem(kind, sessionID, system)
|
|
123
|
+
},
|
|
124
|
+
event: async ({ event }) => {
|
|
125
|
+
const type = event?.type
|
|
126
|
+
const id = event?.properties?.sessionID ?? event?.properties?.info?.id
|
|
127
|
+
if (typeof id === "string" && type === "session.deleted") {
|
|
128
|
+
lastWait.delete(id)
|
|
129
|
+
forgetSession(id)
|
|
130
|
+
}
|
|
131
|
+
},
|
|
132
|
+
}
|
|
133
|
+
}
|
|
37
134
|
|
|
38
135
|
export default OpencodeConsistentPlugin
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-consistent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Pin Today's date in OpenCode sessions and wait for the same MCP servers so prefix caches actually hit.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./index.js",
|
|
7
7
|
"exports": {
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
"files": [
|
|
11
11
|
"index.js",
|
|
12
12
|
"freeze.js",
|
|
13
|
+
"wait.js",
|
|
13
14
|
"README.md",
|
|
14
15
|
"LICENSE"
|
|
15
16
|
],
|
|
@@ -19,7 +20,8 @@
|
|
|
19
20
|
"prefix-cache",
|
|
20
21
|
"vllm",
|
|
21
22
|
"kv-cache",
|
|
22
|
-
"prompt-cache"
|
|
23
|
+
"prompt-cache",
|
|
24
|
+
"mcp"
|
|
23
25
|
],
|
|
24
26
|
"author": "Nguyen Phan <nguyen@nguyenphan.org>",
|
|
25
27
|
"license": "MIT",
|
|
@@ -43,6 +45,6 @@
|
|
|
43
45
|
}
|
|
44
46
|
},
|
|
45
47
|
"scripts": {
|
|
46
|
-
"test": "node freeze.test.js"
|
|
48
|
+
"test": "node freeze.test.js && node wait.test.js && node plugin.test.js"
|
|
47
49
|
}
|
|
48
50
|
}
|
package/wait.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
export const DEFAULT_WAIT_MS = 15_000
|
|
2
|
+
export const DEFAULT_INTERVAL_MS = 200
|
|
3
|
+
|
|
4
|
+
const TERMINAL = new Set(["connected", "failed", "needs_auth", "needs_client_registration"])
|
|
5
|
+
|
|
6
|
+
export function waitMsFrom(option) {
|
|
7
|
+
if (option !== undefined && option !== null && Number.isFinite(Number(option))) {
|
|
8
|
+
return Math.max(0, Number(option))
|
|
9
|
+
}
|
|
10
|
+
const env = process.env.OPENCODE_CONSISTENT_MCP_WAIT_MS
|
|
11
|
+
if (env !== undefined && env !== "" && Number.isFinite(Number(env))) {
|
|
12
|
+
return Math.max(0, Number(env))
|
|
13
|
+
}
|
|
14
|
+
return DEFAULT_WAIT_MS
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** MCP names that OpenCode will try to connect (enabled !== false). */
|
|
18
|
+
export function wantedServers(mcpConfig) {
|
|
19
|
+
if (!mcpConfig || typeof mcpConfig !== "object") return []
|
|
20
|
+
return Object.entries(mcpConfig)
|
|
21
|
+
.filter(([, value]) => value && typeof value === "object" && value.enabled !== false)
|
|
22
|
+
.map(([name]) => name)
|
|
23
|
+
.sort()
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function connectedNames(status) {
|
|
27
|
+
if (!status || typeof status !== "object") return []
|
|
28
|
+
return Object.entries(status)
|
|
29
|
+
.filter(([, value]) => value && value.status === "connected")
|
|
30
|
+
.map(([name]) => name)
|
|
31
|
+
.sort()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Connecting servers report as "disabled" on GET /mcp until they finish.
|
|
36
|
+
* Treat disabled/missing as in-flight for names that are configured and enabled.
|
|
37
|
+
*/
|
|
38
|
+
export function isSettled(status, wanted) {
|
|
39
|
+
if (!Array.isArray(wanted) || wanted.length === 0) return true
|
|
40
|
+
return wanted.every((name) => TERMINAL.has(status?.[name]?.status))
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function hasConnected(status, required) {
|
|
44
|
+
if (!Array.isArray(required) || required.length === 0) return true
|
|
45
|
+
return required.every((name) => status?.[name]?.status === "connected")
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Poll until `ok(last)` or timeout. First poll is immediate.
|
|
50
|
+
* @returns {{ ok: boolean, elapsed: number, last: any, timedOut: boolean }}
|
|
51
|
+
*/
|
|
52
|
+
export async function waitUntil({ poll, ok, timeoutMs, intervalMs, sleep, now }) {
|
|
53
|
+
const t0 = (now ?? Date.now)()
|
|
54
|
+
const timeout = timeoutMs ?? DEFAULT_WAIT_MS
|
|
55
|
+
const step = intervalMs ?? DEFAULT_INTERVAL_MS
|
|
56
|
+
const nap = sleep ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)))
|
|
57
|
+
const clock = now ?? Date.now
|
|
58
|
+
|
|
59
|
+
let last = await poll()
|
|
60
|
+
if (ok(last)) return { ok: true, elapsed: clock() - t0, last, timedOut: false }
|
|
61
|
+
if (timeout <= 0) return { ok: false, elapsed: clock() - t0, last, timedOut: true }
|
|
62
|
+
|
|
63
|
+
while (clock() - t0 < timeout) {
|
|
64
|
+
await nap(step)
|
|
65
|
+
last = await poll()
|
|
66
|
+
if (ok(last)) return { ok: true, elapsed: clock() - t0, last, timedOut: false }
|
|
67
|
+
}
|
|
68
|
+
return { ok: false, elapsed: clock() - t0, last, timedOut: true }
|
|
69
|
+
}
|