@gentbajko/slopify 1.3.1 → 1.4.1
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 +12 -6
- package/dist/adapter-registry.js +11 -1
- package/dist/adapters/host-cli/index.js +192 -0
- package/dist/adapters/host-cli/transport.js +135 -0
- package/dist/adapters/image/codex.js +15 -6
- package/dist/adapters/llm/claude-code-models.js +1 -0
- package/dist/adapters/llm/claude-code.js +7 -0
- package/dist/adapters/llm/cli-login-error.js +16 -0
- package/dist/adapters/llm/codex.js +10 -0
- package/dist/edge/cli.js +36 -0
- package/dist/edge/docker.js +109 -0
- package/dist/edge/host-cli.js +58 -0
- package/dist/edge/http/actions.js +1 -1
- package/dist/edge/http/diagnostics.js +5 -1
- package/dist/edge/http/host-cli.js +247 -0
- package/dist/edge/http/providers.js +5 -1
- package/dist/host-cli/environment.js +25 -0
- package/dist/host-cli/install.js +61 -0
- package/dist/host-cli/paths.js +68 -0
- package/dist/host-cli/runtime.js +78 -0
- package/dist/host-cli/server.js +98 -0
- package/dist/host-cli/service.js +200 -0
- package/dist/host-cli/status.js +134 -0
- package/dist/kernel/ports/host-cli.js +95 -0
- package/dist/kernel/ports/model.js +1 -0
- package/dist/kernel/runner/attempt.js +10 -1
- package/dist/main.js +7 -1
- package/dist/slices/play-drafts/readiness.js +2 -3
- package/dist/slices/rebuild/service-readiness.js +2 -3
- package/dist/slices/settings/cli-paths.js +10 -0
- package/dist/slices/settings/cli-status.js +2 -0
- package/dist/slices/settings/readiness.js +15 -1
- package/dist/web/assets/{index-DsumGtv6.js → index-CbGJtCio.js} +21 -21
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/scripts/docker-run.sh +8 -45
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { codexImage } from "../adapters/image/codex.js";
|
|
5
|
+
import { claudeCodeLlm } from "../adapters/llm/claude-code.js";
|
|
6
|
+
import { nodeClaudeCodeModels } from "../adapters/llm/claude-code-models.js";
|
|
7
|
+
import { codexLlm } from "../adapters/llm/codex.js";
|
|
8
|
+
import { nodeCodexModels } from "../adapters/llm/codex-models.js";
|
|
9
|
+
import { geminiLlm } from "../adapters/llm/gemini.js";
|
|
10
|
+
import { nodeGeminiModels } from "../adapters/llm/gemini-models.js";
|
|
11
|
+
import { providerError } from "../kernel/ports/model.js";
|
|
12
|
+
import { createHostStatus } from "./status.js";
|
|
13
|
+
export function createHostRuntime(deps) {
|
|
14
|
+
const status = createHostStatus(deps);
|
|
15
|
+
async function command(id) {
|
|
16
|
+
const ready = await status(id);
|
|
17
|
+
if (ready.issue || !ready.installed)
|
|
18
|
+
throw providerError({
|
|
19
|
+
kind: ready.issueKind === "login" ? "missing_key" : "unavailable",
|
|
20
|
+
message: ready.issue ?? "The host CLI is unavailable.",
|
|
21
|
+
});
|
|
22
|
+
return deps.resolve(id === "codex-image" ? "codex" : id);
|
|
23
|
+
}
|
|
24
|
+
const factories = { "claude-code": claudeCodeLlm, codex: codexLlm, gemini: geminiLlm };
|
|
25
|
+
const models = async (id) => {
|
|
26
|
+
const binary = await deps.resolve(id);
|
|
27
|
+
return id === "claude-code"
|
|
28
|
+
? nodeClaudeCodeModels(binary)
|
|
29
|
+
: id === "codex"
|
|
30
|
+
? nodeCodexModels(deps.env, binary)
|
|
31
|
+
: nodeGeminiModels(binary);
|
|
32
|
+
};
|
|
33
|
+
return {
|
|
34
|
+
status,
|
|
35
|
+
llm: (id) => ({
|
|
36
|
+
id,
|
|
37
|
+
capabilities: { streams: true, reportsUsage: true, webSearch: true },
|
|
38
|
+
models: () => models(id),
|
|
39
|
+
complete: async function* (request) {
|
|
40
|
+
request.signal.throwIfAborted();
|
|
41
|
+
const binary = await command(id);
|
|
42
|
+
const directory = id === "claude-code" ? await mkdtemp(join(tmpdir(), "slopify-host-claude-")) : undefined;
|
|
43
|
+
try {
|
|
44
|
+
const run = (file, args, signal, options) => deps.run(file, args, signal, {
|
|
45
|
+
...(directory === undefined ? {} : { cwd: directory }),
|
|
46
|
+
...options,
|
|
47
|
+
env: options?.env ?? {
|
|
48
|
+
...deps.env,
|
|
49
|
+
...(directory === undefined ? {} : { PWD: directory }),
|
|
50
|
+
},
|
|
51
|
+
});
|
|
52
|
+
yield* factories[id]({ run, binary, readModels: () => models(id) }).complete({
|
|
53
|
+
...request,
|
|
54
|
+
...(id === "codex" && request.thinking !== undefined
|
|
55
|
+
? {
|
|
56
|
+
thinkingConfig: {
|
|
57
|
+
effort: request.thinking === "off" ? "none" : request.thinking,
|
|
58
|
+
},
|
|
59
|
+
}
|
|
60
|
+
: {}),
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
finally {
|
|
64
|
+
if (directory !== undefined)
|
|
65
|
+
await rm(directory, { recursive: true, force: true });
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
}),
|
|
69
|
+
image: {
|
|
70
|
+
id: "codex-image",
|
|
71
|
+
models: () => codexImage({ run: deps.run }).models(),
|
|
72
|
+
generate: async (request) => {
|
|
73
|
+
const binary = await command("codex-image");
|
|
74
|
+
return codexImage({ run: deps.run, binary }).generate(request);
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { chmod, lstat, unlink } from "node:fs/promises";
|
|
2
|
+
import { Server } from "node:http";
|
|
3
|
+
import { connect } from "node:net";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createAdaptorServer } from "@hono/node-server";
|
|
6
|
+
import { hostCliRoutes, hostGate } from "../edge/http/host-cli.js";
|
|
7
|
+
import { hasCode } from "./paths.js";
|
|
8
|
+
export async function startHostServer(options) {
|
|
9
|
+
const socket = join(options.directory, "cli.sock");
|
|
10
|
+
if (Buffer.byteLength(socket) > 100)
|
|
11
|
+
throw new Error("Use a shorter host helper socket path.");
|
|
12
|
+
const directory = await lstat(options.directory);
|
|
13
|
+
if (!directory.isDirectory() ||
|
|
14
|
+
directory.isSymbolicLink() ||
|
|
15
|
+
(process.getuid && directory.uid !== process.getuid()))
|
|
16
|
+
throw new Error("Unsafe host helper directory.");
|
|
17
|
+
try {
|
|
18
|
+
const existing = await lstat(socket);
|
|
19
|
+
if (!existing.isSocket() || (process.getuid && existing.uid !== process.getuid()))
|
|
20
|
+
throw new Error("Unsafe host helper socket.");
|
|
21
|
+
if (await listening(socket))
|
|
22
|
+
throw new Error("A host helper is already listening.");
|
|
23
|
+
await unlink(socket);
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
if (!hasCode(error, "ENOENT"))
|
|
27
|
+
throw error;
|
|
28
|
+
}
|
|
29
|
+
const gate = hostGate();
|
|
30
|
+
const app = hostCliRoutes({ ...options, gate });
|
|
31
|
+
const server = createAdaptorServer({ fetch: app.fetch });
|
|
32
|
+
if (!(server instanceof Server))
|
|
33
|
+
throw new Error("Host helper requires HTTP over a Unix socket.");
|
|
34
|
+
server.headersTimeout = 10_000;
|
|
35
|
+
server.requestTimeout = 35_000;
|
|
36
|
+
await new Promise((resolve, reject) => {
|
|
37
|
+
server.once("error", reject);
|
|
38
|
+
server.listen(socket, () => {
|
|
39
|
+
server.removeListener("error", reject);
|
|
40
|
+
resolve();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
await chmod(socket, 0o666);
|
|
44
|
+
const owned = await lstat(socket);
|
|
45
|
+
let stopping;
|
|
46
|
+
return {
|
|
47
|
+
pauseAdmissions: gate.pause,
|
|
48
|
+
resumeAdmissions: gate.resume,
|
|
49
|
+
stop: () => {
|
|
50
|
+
stopping ??= (async () => {
|
|
51
|
+
gate.stop();
|
|
52
|
+
await new Promise((resolve) => {
|
|
53
|
+
const timer = setTimeout(() => {
|
|
54
|
+
server.closeAllConnections();
|
|
55
|
+
resolve();
|
|
56
|
+
}, 5000);
|
|
57
|
+
timer.unref();
|
|
58
|
+
server.close(() => {
|
|
59
|
+
clearTimeout(timer);
|
|
60
|
+
resolve();
|
|
61
|
+
});
|
|
62
|
+
server.closeIdleConnections();
|
|
63
|
+
});
|
|
64
|
+
try {
|
|
65
|
+
const current = await lstat(socket);
|
|
66
|
+
if (current.ino === owned.ino && current.dev === owned.dev)
|
|
67
|
+
await unlink(socket);
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
if (!hasCode(error, "ENOENT"))
|
|
71
|
+
throw error;
|
|
72
|
+
}
|
|
73
|
+
})();
|
|
74
|
+
return stopping;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
function listening(path) {
|
|
79
|
+
return new Promise((resolve, reject) => {
|
|
80
|
+
const socket = connect(path);
|
|
81
|
+
socket.setTimeout(1000);
|
|
82
|
+
socket.once("connect", () => {
|
|
83
|
+
socket.destroy();
|
|
84
|
+
resolve(true);
|
|
85
|
+
});
|
|
86
|
+
socket.once("timeout", () => {
|
|
87
|
+
socket.destroy();
|
|
88
|
+
reject(new Error("Could not verify existing host socket."));
|
|
89
|
+
});
|
|
90
|
+
socket.once("error", (error) => {
|
|
91
|
+
socket.destroy();
|
|
92
|
+
if (hasCode(error, "ECONNREFUSED"))
|
|
93
|
+
resolve(false);
|
|
94
|
+
else
|
|
95
|
+
reject(new Error("Could not verify existing host socket."));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { lstat, mkdir, open, rename, unlink } from "node:fs/promises";
|
|
3
|
+
import { isAbsolute, join } from "node:path";
|
|
4
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
5
|
+
import { hostRequest, readHostBytes } from "../adapters/host-cli/transport.js";
|
|
6
|
+
import { bridgeLimits, hostHealthSchema } from "../kernel/ports/host-cli.js";
|
|
7
|
+
import { hostEnvironment } from "./environment.js";
|
|
8
|
+
import { hasCode } from "./paths.js";
|
|
9
|
+
const marker = "# Managed by Slopify host CLI bridge, protocol 1";
|
|
10
|
+
export const hostUnit = "slopify-cli-bridge.service";
|
|
11
|
+
function unitArg(value) {
|
|
12
|
+
if (!isAbsolute(value) || /[\p{Cc}]/u.test(value))
|
|
13
|
+
throw new Error("Invalid service path.");
|
|
14
|
+
return JSON.stringify(value.replaceAll("%", "%%"));
|
|
15
|
+
}
|
|
16
|
+
export function serviceUnit(node, entry, root) {
|
|
17
|
+
return [
|
|
18
|
+
marker,
|
|
19
|
+
"[Unit]",
|
|
20
|
+
"Description=Slopify host CLI bridge",
|
|
21
|
+
"",
|
|
22
|
+
"[Service]",
|
|
23
|
+
"Type=simple",
|
|
24
|
+
`ExecStart=:${unitArg(node)} ${unitArg(entry)} --state-dir ${unitArg(root)}`,
|
|
25
|
+
"Restart=on-failure",
|
|
26
|
+
"RestartSec=2",
|
|
27
|
+
"KillMode=control-group",
|
|
28
|
+
"TimeoutStopSec=5",
|
|
29
|
+
"UMask=0077",
|
|
30
|
+
"",
|
|
31
|
+
"[Install]",
|
|
32
|
+
"WantedBy=default.target",
|
|
33
|
+
"",
|
|
34
|
+
].join("\n");
|
|
35
|
+
}
|
|
36
|
+
export async function privateRead(path, uid) {
|
|
37
|
+
try {
|
|
38
|
+
const file = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
39
|
+
try {
|
|
40
|
+
const stat = await file.stat();
|
|
41
|
+
if (!stat.isFile() || stat.uid !== uid || stat.size > 64 * 1024 || (stat.mode & 0o022) !== 0)
|
|
42
|
+
throw new Error("Unsafe host helper configuration.");
|
|
43
|
+
const buffer = Buffer.alloc(64 * 1024 + 1);
|
|
44
|
+
const { bytesRead } = await file.read(buffer, 0, buffer.length, 0);
|
|
45
|
+
if (bytesRead > 64 * 1024)
|
|
46
|
+
throw new Error("Host configuration too large.");
|
|
47
|
+
return buffer.subarray(0, bytesRead).toString("utf8");
|
|
48
|
+
}
|
|
49
|
+
finally {
|
|
50
|
+
await file.close();
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (hasCode(error, "ENOENT"))
|
|
55
|
+
return undefined;
|
|
56
|
+
throw error;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
export async function privateWrite(path, text) {
|
|
60
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
61
|
+
const file = await open(temporary, "wx", 0o600);
|
|
62
|
+
try {
|
|
63
|
+
await file.writeFile(text);
|
|
64
|
+
await file.sync();
|
|
65
|
+
}
|
|
66
|
+
finally {
|
|
67
|
+
await file.close();
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
await rename(temporary, path);
|
|
71
|
+
}
|
|
72
|
+
finally {
|
|
73
|
+
await unlink(temporary).catch((error) => {
|
|
74
|
+
if (!hasCode(error, "ENOENT"))
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
export async function helperHealth(root, signal) {
|
|
80
|
+
try {
|
|
81
|
+
const response = await hostRequest({
|
|
82
|
+
directory: join(root, "share"),
|
|
83
|
+
path: "/v1/health",
|
|
84
|
+
method: "GET",
|
|
85
|
+
kind: "metadata",
|
|
86
|
+
signal: AbortSignal.any([signal, AbortSignal.timeout(1000)]),
|
|
87
|
+
});
|
|
88
|
+
if (response.statusCode !== 200) {
|
|
89
|
+
response.destroy();
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
return hostHealthSchema.parse(JSON.parse((await readHostBytes(response, bridgeLimits.status)).toString("utf8")));
|
|
93
|
+
}
|
|
94
|
+
catch {
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async function waitHealth(options, accepting) {
|
|
99
|
+
const deadline = Date.now() + 30_000;
|
|
100
|
+
while (Date.now() < deadline) {
|
|
101
|
+
options.signal.throwIfAborted();
|
|
102
|
+
const health = await helperHealth(options.root, options.signal);
|
|
103
|
+
if (health &&
|
|
104
|
+
health.accepting === accepting &&
|
|
105
|
+
(!accepting || health.version === options.version))
|
|
106
|
+
return health;
|
|
107
|
+
await delay(100, undefined, { signal: options.signal });
|
|
108
|
+
}
|
|
109
|
+
throw new Error("Host helper did not become ready within 30 seconds.");
|
|
110
|
+
}
|
|
111
|
+
export async function ensureHostService(options) {
|
|
112
|
+
const { root, env, runner, signal, uid } = options;
|
|
113
|
+
const environment = JSON.stringify(hostEnvironment(env));
|
|
114
|
+
const directory = join(env.XDG_CONFIG_HOME ?? join(env.HOME ?? "", ".config"), "systemd/user");
|
|
115
|
+
if (!isAbsolute(directory))
|
|
116
|
+
throw new Error("Host configuration directory must be absolute.");
|
|
117
|
+
const unitPath = join(directory, hostUnit);
|
|
118
|
+
const configPath = join(root, "host-environment.json");
|
|
119
|
+
const previous = await privateRead(unitPath, uid);
|
|
120
|
+
const previousConfig = await privateRead(configPath, uid);
|
|
121
|
+
if (previous !== undefined && !previous.startsWith(`${marker}\n`))
|
|
122
|
+
throw new Error("An existing host helper service is not owned by Slopify. It was not changed.");
|
|
123
|
+
const unit = serviceUnit(options.node, options.entry, root);
|
|
124
|
+
const run = async (file, args, required = true, commandSignal = signal) => {
|
|
125
|
+
const result = await runner.exec(file, args, commandSignal);
|
|
126
|
+
if (required && result.code !== 0)
|
|
127
|
+
throw new Error(`Host helper setup failed (${file}). The existing container was not changed.`);
|
|
128
|
+
return result;
|
|
129
|
+
};
|
|
130
|
+
const fragment = (await run("systemctl", ["--user", "show", hostUnit, "--property=FragmentPath", "--value"])).stdout.trim();
|
|
131
|
+
if (fragment && fragment !== unitPath)
|
|
132
|
+
throw new Error("An existing host helper service is not owned by this Slopify installation.");
|
|
133
|
+
const active = (await run("systemctl", ["--user", "is-active", "--quiet", hostUnit], false)).code === 0;
|
|
134
|
+
const wasEnabled = (await run("systemctl", ["--user", "is-enabled", "--quiet", hostUnit], false)).code === 0;
|
|
135
|
+
const current = await helperHealth(root, signal);
|
|
136
|
+
if (active && (!previous || !current))
|
|
137
|
+
throw new Error("The running host helper cannot be verified. Check its service before updating; no work was interrupted.");
|
|
138
|
+
const linger = (await run("loginctl", ["show-user", String(uid), "-p", "Linger", "--value"])).stdout.trim();
|
|
139
|
+
if (linger !== "yes")
|
|
140
|
+
await run("loginctl", ["enable-linger", String(uid)]);
|
|
141
|
+
if (current?.version === options.version &&
|
|
142
|
+
current.accepting &&
|
|
143
|
+
previous === unit &&
|
|
144
|
+
previousConfig === environment) {
|
|
145
|
+
await run("systemctl", ["--user", "enable", hostUnit]);
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
let paused = false;
|
|
149
|
+
let replaced = false;
|
|
150
|
+
try {
|
|
151
|
+
if (active) {
|
|
152
|
+
paused = true;
|
|
153
|
+
await run("systemctl", ["--user", "kill", "--kill-whom=main", "--signal=SIGHUP", hostUnit]);
|
|
154
|
+
if ((await waitHealth(options, false)).active !== 0)
|
|
155
|
+
throw new Error("The host helper is doing work. Wait for it to finish before updating.");
|
|
156
|
+
}
|
|
157
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
158
|
+
const directoryStat = await lstat(directory);
|
|
159
|
+
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink() || directoryStat.uid !== uid)
|
|
160
|
+
throw new Error("Unsafe user service directory.");
|
|
161
|
+
replaced = true;
|
|
162
|
+
await privateWrite(configPath, environment);
|
|
163
|
+
await privateWrite(unitPath, unit);
|
|
164
|
+
await run("systemctl", ["--user", "daemon-reload"]);
|
|
165
|
+
await run("systemctl", ["--user", "enable", hostUnit]);
|
|
166
|
+
await run("systemctl", ["--user", active ? "restart" : "start", hostUnit]);
|
|
167
|
+
await waitHealth(options, true);
|
|
168
|
+
paused = false;
|
|
169
|
+
}
|
|
170
|
+
catch (error) {
|
|
171
|
+
const rollbackSignal = AbortSignal.timeout(35_000);
|
|
172
|
+
const restore = (args) => run("systemctl", args, true, rollbackSignal);
|
|
173
|
+
try {
|
|
174
|
+
if (replaced) {
|
|
175
|
+
await restore(["--user", "stop", hostUnit]);
|
|
176
|
+
if (!wasEnabled)
|
|
177
|
+
await restore(["--user", "disable", hostUnit]);
|
|
178
|
+
if (previous === undefined)
|
|
179
|
+
await unlink(unitPath);
|
|
180
|
+
else
|
|
181
|
+
await privateWrite(unitPath, previous);
|
|
182
|
+
if (previousConfig === undefined)
|
|
183
|
+
await unlink(configPath);
|
|
184
|
+
else
|
|
185
|
+
await privateWrite(configPath, previousConfig);
|
|
186
|
+
await restore(["--user", "daemon-reload"]);
|
|
187
|
+
if (active) {
|
|
188
|
+
await restore(["--user", "start", hostUnit]);
|
|
189
|
+
await waitHealth({ ...options, signal: rollbackSignal, version: current?.version ?? options.version }, true);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
else if (paused)
|
|
193
|
+
await restore(["--user", "kill", "--kill-whom=main", "--signal=SIGUSR2", hostUnit]);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
throw new Error("Host helper setup failed and rollback needs attention. Check slopify-cli-bridge.service; the Docker container was not changed.");
|
|
197
|
+
}
|
|
198
|
+
throw error;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
|
+
import { access, stat } from "node:fs/promises";
|
|
4
|
+
import { delimiter, isAbsolute, join } from "node:path";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { cliLoginError } from "../adapters/llm/cli-login-error.js";
|
|
7
|
+
import { cliCommand } from "../kernel/cli-command.js";
|
|
8
|
+
import { cliProbeTimeoutMs, readinessFromProbe, } from "../slices/settings/cli-status.js";
|
|
9
|
+
export function hostCommandName(id) {
|
|
10
|
+
return id === "claude-code" ? "claude" : id === "codex-image" ? "codex" : id;
|
|
11
|
+
}
|
|
12
|
+
export async function resolveHostCommand(id, env) {
|
|
13
|
+
for (const directory of (env.PATH ?? "").split(delimiter)) {
|
|
14
|
+
if (!isAbsolute(directory))
|
|
15
|
+
continue;
|
|
16
|
+
const path = join(directory, hostCommandName(id));
|
|
17
|
+
try {
|
|
18
|
+
await access(path, constants.X_OK);
|
|
19
|
+
if ((await stat(path)).isFile())
|
|
20
|
+
return path;
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
if (!isCode(error, "ENOENT") && !isCode(error, "ENOTDIR") && !isCode(error, "EACCES"))
|
|
24
|
+
throw error;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
throw Object.assign(new Error("The CLI was not found on the host PATH."), { code: "ENOENT" });
|
|
28
|
+
}
|
|
29
|
+
const authSchema = z.object({ loggedIn: z.boolean() });
|
|
30
|
+
export function parseHostLogin(id, stdout, stderr) {
|
|
31
|
+
if (id === "gemini")
|
|
32
|
+
return "unknown";
|
|
33
|
+
if (id === "codex") {
|
|
34
|
+
const text = `${stdout}\n${stderr}`.trim();
|
|
35
|
+
if (/^Not logged in\.?$/i.test(text))
|
|
36
|
+
return "signed-out";
|
|
37
|
+
if (/^Logged in using (?:ChatGPT|an API key|API key)\b/i.test(text))
|
|
38
|
+
return "signed-in";
|
|
39
|
+
return "unknown";
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const result = authSchema.safeParse(JSON.parse(stdout));
|
|
43
|
+
return result.success ? (result.data.loggedIn ? "signed-in" : "signed-out") : "unknown";
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
return "unknown";
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function readHostLogin(binary, id, signal) {
|
|
50
|
+
if (id === "gemini")
|
|
51
|
+
return "unknown";
|
|
52
|
+
const command = cliCommand(binary);
|
|
53
|
+
return new Promise((resolve) => {
|
|
54
|
+
execFile(command.file, [...command.args, ...(id === "claude-code" ? ["auth", "status"] : ["login", "status"])], { timeout: cliProbeTimeoutMs, maxBuffer: 64 * 1024, signal, windowsHide: true }, (error, stdout, stderr) => {
|
|
55
|
+
if (error && (error.killed || typeof error.code !== "number"))
|
|
56
|
+
return resolve("unknown");
|
|
57
|
+
resolve(parseHostLogin(id, stdout, stderr));
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
export function createHostStatus(deps) {
|
|
62
|
+
const cache = new Map();
|
|
63
|
+
async function read(id) {
|
|
64
|
+
let command;
|
|
65
|
+
try {
|
|
66
|
+
command = await deps.resolve(id);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
return {
|
|
70
|
+
id,
|
|
71
|
+
command: hostCommandName(id),
|
|
72
|
+
installed: false,
|
|
73
|
+
login: "unknown",
|
|
74
|
+
issueKind: isCode(error, "ENOENT") ? "missing" : "bridge",
|
|
75
|
+
issue: "The CLI could not be found on the host. Check its installation, then rerun the Docker launcher.",
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
const readiness = readinessFromProbe(await deps.probe(command, ["--version"], cliProbeTimeoutMs), {
|
|
79
|
+
id,
|
|
80
|
+
auth: "cli",
|
|
81
|
+
family: "llm",
|
|
82
|
+
displayName: id,
|
|
83
|
+
binary: command,
|
|
84
|
+
versionArgs: ["--version"],
|
|
85
|
+
});
|
|
86
|
+
if (readiness.kind !== "cli")
|
|
87
|
+
throw new Error("Expected CLI readiness.");
|
|
88
|
+
const base = {
|
|
89
|
+
id,
|
|
90
|
+
command,
|
|
91
|
+
installed: readiness.installed,
|
|
92
|
+
...(readiness.version === undefined ? {} : { version: readiness.version }),
|
|
93
|
+
};
|
|
94
|
+
if (!readiness.installed)
|
|
95
|
+
return {
|
|
96
|
+
...base,
|
|
97
|
+
login: "unknown",
|
|
98
|
+
issueKind: "missing",
|
|
99
|
+
issue: "The host CLI did not answer. Check its installation and rerun the Docker launcher.",
|
|
100
|
+
};
|
|
101
|
+
if (readiness.issue)
|
|
102
|
+
return { ...base, login: "unknown", issueKind: "version", issue: readiness.issue };
|
|
103
|
+
const login = await (deps.login ?? readHostLogin)(command, id, AbortSignal.timeout(cliProbeTimeoutMs));
|
|
104
|
+
return {
|
|
105
|
+
...base,
|
|
106
|
+
login,
|
|
107
|
+
...(login === "signed-out"
|
|
108
|
+
? {
|
|
109
|
+
issueKind: "login",
|
|
110
|
+
issue: cliLoginError(id, "Not logged in")?.message ?? "Sign in on the host.",
|
|
111
|
+
}
|
|
112
|
+
: {}),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
return async (id) => {
|
|
116
|
+
const source = id === "codex-image" ? "codex" : id;
|
|
117
|
+
let entry = cache.get(source);
|
|
118
|
+
if (!entry || entry.until <= deps.now()) {
|
|
119
|
+
const value = read(source);
|
|
120
|
+
entry = { until: Number.POSITIVE_INFINITY, value };
|
|
121
|
+
cache.set(source, entry);
|
|
122
|
+
const current = entry;
|
|
123
|
+
void value.then(() => {
|
|
124
|
+
current.until = deps.now() + 5000;
|
|
125
|
+
}, () => {
|
|
126
|
+
cache.delete(source);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
return { ...(await entry.value), id };
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
function isCode(error, code) {
|
|
133
|
+
return typeof error === "object" && error !== null && "code" in error && error.code === code;
|
|
134
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { messageRoles, thinkingModes } from "./llm.js";
|
|
3
|
+
import { providerErrorKinds } from "./model.js";
|
|
4
|
+
export const hostLlmIds = ["claude-code", "codex", "gemini"];
|
|
5
|
+
export const hostCliIds = [...hostLlmIds, "codex-image"];
|
|
6
|
+
export const hostCliProtocol = 1;
|
|
7
|
+
export const bridgeLimits = {
|
|
8
|
+
request: 16 * 1024 * 1024,
|
|
9
|
+
status: 64 * 1024,
|
|
10
|
+
models: 1024 * 1024,
|
|
11
|
+
frame: 4 * 1024 * 1024,
|
|
12
|
+
stream: 64 * 1024 * 1024,
|
|
13
|
+
image: 32 * 1024 * 1024,
|
|
14
|
+
text: 2 * 1024 * 1024,
|
|
15
|
+
generation: 5,
|
|
16
|
+
metadata: 8,
|
|
17
|
+
};
|
|
18
|
+
const short = z
|
|
19
|
+
.string()
|
|
20
|
+
.min(1)
|
|
21
|
+
.max(256)
|
|
22
|
+
.refine((v) => !/[\p{Cc}]/u.test(v));
|
|
23
|
+
const message = z
|
|
24
|
+
.object({ role: z.enum(messageRoles), content: z.string().max(bridgeLimits.text) })
|
|
25
|
+
.strict();
|
|
26
|
+
export const hostLlmSchema = z
|
|
27
|
+
.object({
|
|
28
|
+
model: short,
|
|
29
|
+
messages: z.array(message).min(1).max(128),
|
|
30
|
+
thinking: z.enum(thinkingModes).optional(),
|
|
31
|
+
webSearch: z.boolean().optional(),
|
|
32
|
+
})
|
|
33
|
+
.strict();
|
|
34
|
+
export const hostImageSchema = z
|
|
35
|
+
.object({
|
|
36
|
+
model: z.literal("codex-imagegen"),
|
|
37
|
+
prompt: z.string().min(1).max(bridgeLimits.text),
|
|
38
|
+
aspect: z.enum(["16:9", "9:16"]),
|
|
39
|
+
})
|
|
40
|
+
.strict();
|
|
41
|
+
export const hostStatusSchema = z
|
|
42
|
+
.object({
|
|
43
|
+
id: z.enum(hostCliIds),
|
|
44
|
+
command: z.string().max(4096),
|
|
45
|
+
installed: z.boolean(),
|
|
46
|
+
version: short.optional(),
|
|
47
|
+
login: z.enum(["signed-in", "signed-out", "unknown"]),
|
|
48
|
+
issueKind: z.enum(["missing", "version", "login", "bridge"]).optional(),
|
|
49
|
+
issue: z.string().max(4096).optional(),
|
|
50
|
+
})
|
|
51
|
+
.strict();
|
|
52
|
+
export const hostModelsSchema = z
|
|
53
|
+
.object({
|
|
54
|
+
models: z
|
|
55
|
+
.array(z
|
|
56
|
+
.object({
|
|
57
|
+
id: short,
|
|
58
|
+
name: short,
|
|
59
|
+
group: short.optional(),
|
|
60
|
+
thinkingModes: z.array(z.enum(thinkingModes)).max(5).optional(),
|
|
61
|
+
})
|
|
62
|
+
.strict())
|
|
63
|
+
.max(1000),
|
|
64
|
+
})
|
|
65
|
+
.strict();
|
|
66
|
+
export const hostHealthSchema = z
|
|
67
|
+
.object({
|
|
68
|
+
protocol: z.literal(hostCliProtocol),
|
|
69
|
+
version: short,
|
|
70
|
+
active: z.number().int().min(0).max(bridgeLimits.generation),
|
|
71
|
+
accepting: z.boolean(),
|
|
72
|
+
})
|
|
73
|
+
.strict();
|
|
74
|
+
export const hostFaultSchema = z
|
|
75
|
+
.object({
|
|
76
|
+
type: z.literal("error"),
|
|
77
|
+
kind: z.enum(providerErrorKinds),
|
|
78
|
+
message: z.string().max(4096),
|
|
79
|
+
})
|
|
80
|
+
.strict();
|
|
81
|
+
export const hostFrameSchema = z.discriminatedUnion("type", [
|
|
82
|
+
z.object({ type: z.literal("delta"), text: z.string().max(bridgeLimits.frame) }).strict(),
|
|
83
|
+
z.object({ type: z.literal("activity") }).strict(),
|
|
84
|
+
z
|
|
85
|
+
.object({
|
|
86
|
+
type: z.literal("done"),
|
|
87
|
+
usage: z
|
|
88
|
+
.object({ inputTokens: z.number().nonnegative(), outputTokens: z.number().nonnegative() })
|
|
89
|
+
.strict()
|
|
90
|
+
.nullable(),
|
|
91
|
+
finishReason: z.string().max(256).nullable(),
|
|
92
|
+
})
|
|
93
|
+
.strict(),
|
|
94
|
+
hostFaultSchema,
|
|
95
|
+
]);
|
|
@@ -15,7 +15,12 @@ export const timeoutMs = {
|
|
|
15
15
|
// spends the user's money again. The third is a key removed mid-run, which leaves nothing
|
|
16
16
|
// to call with. A key the provider rejected is different: that is an `auth` failure,
|
|
17
17
|
// retried like any other.
|
|
18
|
-
const terminalKinds = [
|
|
18
|
+
const terminalKinds = [
|
|
19
|
+
"refusal",
|
|
20
|
+
"unsupported",
|
|
21
|
+
"missing_key",
|
|
22
|
+
"unavailable",
|
|
23
|
+
];
|
|
19
24
|
export async function attempt(ctx, call, opts) {
|
|
20
25
|
ctx.signal.throwIfAborted();
|
|
21
26
|
const limit = timeoutMs[opts.kind];
|
|
@@ -86,6 +91,10 @@ export async function attempt(ctx, call, opts) {
|
|
|
86
91
|
}
|
|
87
92
|
// The wrapper is the only place a provider failure is named.
|
|
88
93
|
function classify(error, expired, opts, limit) {
|
|
94
|
+
// Losing a submitted host request cannot prove that generation never happened.
|
|
95
|
+
if (isProviderError(error) && error.fault.kind === "unavailable") {
|
|
96
|
+
return providerError({ kind: "unavailable", message: redact(error.message) });
|
|
97
|
+
}
|
|
89
98
|
if (expired) {
|
|
90
99
|
const seconds = Math.round(limit / 1000);
|
|
91
100
|
return providerError({
|
package/dist/main.js
CHANGED
|
@@ -6,6 +6,7 @@ import ffmpegStatic from "ffmpeg-static";
|
|
|
6
6
|
import { buildRegistry } from "./adapter-registry.js";
|
|
7
7
|
import { alignSubtitles } from "./adapters/alignment/index.js";
|
|
8
8
|
import { prepareFfmpeg } from "./adapters/ffmpeg.js";
|
|
9
|
+
import { createHostCliClient } from "./adapters/host-cli/index.js";
|
|
9
10
|
import { nodeRunCli } from "./adapters/llm/run-cli.js";
|
|
10
11
|
import { curateRegistry } from "./catalog/registry.js";
|
|
11
12
|
import { createCatalogueStore } from "./catalog/store.js";
|
|
@@ -154,12 +155,16 @@ export async function boot(config) {
|
|
|
154
155
|
post: httpPostEvents(collectorEndpoint(process.env), collectorTimeoutMs),
|
|
155
156
|
}, flushDelayMs);
|
|
156
157
|
const catalogue = createCatalogueStore({ dataDir: paths.dataDir, fetch: globalThis.fetch });
|
|
158
|
+
const hostCli = process.env.SLOPIFY_CONTAINER === "1" || process.env.SLOPIFY_HOST_CLI_DIR
|
|
159
|
+
? createHostCliClient({ directory: process.env.SLOPIFY_HOST_CLI_DIR })
|
|
160
|
+
: undefined;
|
|
157
161
|
const registry = curateRegistry(buildRegistry({
|
|
158
162
|
db,
|
|
159
163
|
fetch: globalThis.fetch,
|
|
160
164
|
spawn: nodeRunCli,
|
|
161
165
|
clock,
|
|
162
166
|
probe: nodeCliProbe,
|
|
167
|
+
hostCli,
|
|
163
168
|
}), catalogue);
|
|
164
169
|
const audioPreviews = createAudioPreviewStore();
|
|
165
170
|
const runner = wireRunner({
|
|
@@ -265,7 +270,7 @@ export async function boot(config) {
|
|
|
265
270
|
runner,
|
|
266
271
|
catalogue,
|
|
267
272
|
measureAudio: (path, signal) => probeDurationMs(ffmpeg, path, signal ?? AbortSignal.timeout(30_000), log),
|
|
268
|
-
providers: () => providerStatuses({ db: updateDb, probe: nodeCliProbe }),
|
|
273
|
+
providers: () => providerStatuses({ db: updateDb, probe: nodeCliProbe, hostCliStatus: hostCli?.status }),
|
|
269
274
|
modelsFor: modelSources(registry).modelsFor,
|
|
270
275
|
emit: (projectId, event) => hub.emit(projectId, event),
|
|
271
276
|
};
|
|
@@ -324,6 +329,7 @@ export async function boot(config) {
|
|
|
324
329
|
webDist: fileURLToPath(new URL("../dist/web", import.meta.url)),
|
|
325
330
|
flushSoon: flusher.soon,
|
|
326
331
|
probe: nodeCliProbe,
|
|
332
|
+
hostCliStatus: hostCli?.status,
|
|
327
333
|
});
|
|
328
334
|
const server = await listen(app, config, log);
|
|
329
335
|
const queueTimer = setInterval(() => {
|