@getforgeai/cli 0.1.0-beta.5 → 0.1.0-beta.7
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/dist/cli.js +10 -1
- package/dist/commands/config-set.d.ts +6 -0
- package/dist/commands/config-set.js +83 -0
- package/dist/commands/models-list.d.ts +7 -0
- package/dist/commands/models-list.js +57 -0
- package/dist/config/config-manager.d.ts +13 -0
- package/dist/config/config-manager.js +13 -0
- package/dist/lib/connection-manager.js +48 -0
- package/dist/lib/daemon-entry.js +1 -0
- package/dist/lib/model-catalog.d.ts +34 -0
- package/dist/lib/model-catalog.js +210 -0
- package/dist/lib/rest-client.d.ts +6 -0
- package/dist/orchestrator/agent-profiles.d.ts +24 -6
- package/dist/orchestrator/agent-profiles.js +83 -12
- package/dist/orchestrator/agent-spawner.d.ts +4 -0
- package/dist/orchestrator/agent-spawner.js +11 -0
- package/dist/orchestrator/connectors/connector.d.ts +8 -0
- package/dist/orchestrator/connectors/docker-connector.js +39 -10
- package/dist/orchestrator/resume-handler.d.ts +5 -0
- package/dist/orchestrator/resume-handler.js +8 -0
- package/dist/vm/agent-auth-manager.d.ts +8 -0
- package/dist/vm/agent-auth-manager.js +8 -0
- package/dist/vm/session-manager.d.ts +9 -0
- package/dist/vm/session-manager.js +5 -3
- package/package.json +2 -2
package/dist/cli.js
CHANGED
|
@@ -13,7 +13,7 @@ import { orgUnlinkAction } from "./commands/org-unlink.js";
|
|
|
13
13
|
import { journalListAction } from "./commands/journal-list.js";
|
|
14
14
|
import { journalClearAction } from "./commands/journal-clear.js";
|
|
15
15
|
import { journalStatusAction } from "./commands/journal-status.js";
|
|
16
|
-
import { configSetAgentTypeAction, configSetAgentSlotsAction, configSetOpenerAction, configGetAction, } from "./commands/config-set.js";
|
|
16
|
+
import { configSetAgentTypeAction, configSetAgentSlotsAction, configSetModelsAction, configSetOpenerAction, configGetAction, } from "./commands/config-set.js";
|
|
17
17
|
import { mcpServeAction } from "./commands/mcp-serve.js";
|
|
18
18
|
import { releaseOpenAction } from "./commands/release-open.js";
|
|
19
19
|
import { sessionRespondAction } from "./commands/session-respond.js";
|
|
@@ -21,6 +21,7 @@ import { taskOpenAction } from "./commands/task-open.js";
|
|
|
21
21
|
import { workflowOpenAction } from "./commands/workflow-open.js";
|
|
22
22
|
import { vmInitAction, vmStatusAction, vmCleanupAction, vmStopAllAction, setupClaudeTokenAction, } from "./commands/vm.js";
|
|
23
23
|
import { setupCodexAuthAction, setupKiloAuthAction, setupOpenCodeAuthAction, } from "./commands/auth-setup-agent.js";
|
|
24
|
+
import { modelsListAction } from "./commands/models-list.js";
|
|
24
25
|
const program = new Command();
|
|
25
26
|
program
|
|
26
27
|
.name("forge")
|
|
@@ -119,6 +120,10 @@ journalCmd
|
|
|
119
120
|
.command("status")
|
|
120
121
|
.description("Show sync status: last sync time, pending events, cursor")
|
|
121
122
|
.action(() => journalStatusAction());
|
|
123
|
+
program
|
|
124
|
+
.command("models [engine]")
|
|
125
|
+
.description("Show the models declared on this device and the models available for an engine (defaults to the configured agent type)")
|
|
126
|
+
.action((engine) => modelsListAction(engine));
|
|
122
127
|
const configCmd = program
|
|
123
128
|
.command("config")
|
|
124
129
|
.description("Manage CLI configuration");
|
|
@@ -129,6 +134,10 @@ configSetCmd
|
|
|
129
134
|
.command("agent-type <value>")
|
|
130
135
|
.description("Set agent type (CLAUDE_CODE, CODEX, OPENCODE or KILO)")
|
|
131
136
|
.action((value) => configSetAgentTypeAction(value));
|
|
137
|
+
configSetCmd
|
|
138
|
+
.command("models <engineOrModels> [models]")
|
|
139
|
+
.description("Declare the usable models, comma-separated (first = default); 'none' clears. Engine optional — defaults to the configured agent type")
|
|
140
|
+
.action((engineOrModels, models) => configSetModelsAction(engineOrModels, models));
|
|
132
141
|
configSetCmd
|
|
133
142
|
.command("agent-slots <value>")
|
|
134
143
|
.description("Set agent slots (1-20)")
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
export declare function configSetAgentTypeAction(value: string): Promise<void>;
|
|
2
|
+
/**
|
|
3
|
+
* forge config set models <engine> <m1,m2,...>
|
|
4
|
+
* Declares the models usable on this device for an engine (first = default).
|
|
5
|
+
* "none" clears the engine's list. Refused when the org locked the list.
|
|
6
|
+
*/
|
|
7
|
+
export declare function configSetModelsAction(engineOrModels: string, maybeModels?: string): Promise<void>;
|
|
2
8
|
export declare function configSetAgentSlotsAction(value: string): Promise<void>;
|
|
3
9
|
export declare function configSetOpenerAction(value: string): void;
|
|
4
10
|
export declare function configGetAction(): void;
|
|
@@ -24,6 +24,78 @@ export async function configSetAgentTypeAction(value) {
|
|
|
24
24
|
console.log(chalk.dim(`Make sure credentials are configured: ${hint}`));
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* forge config set models <engine> <m1,m2,...>
|
|
29
|
+
* Declares the models usable on this device for an engine (first = default).
|
|
30
|
+
* "none" clears the engine's list. Refused when the org locked the list.
|
|
31
|
+
*/
|
|
32
|
+
export async function configSetModelsAction(engineOrModels, maybeModels) {
|
|
33
|
+
const config = getConfig();
|
|
34
|
+
// Engine is optional: `forge config set models sonnet,haiku` applies to the
|
|
35
|
+
// configured agent type; an explicit engine targets another engine's list.
|
|
36
|
+
let upper;
|
|
37
|
+
let value;
|
|
38
|
+
if (maybeModels === undefined) {
|
|
39
|
+
if (isAgentType(engineOrModels.toUpperCase())) {
|
|
40
|
+
console.error(chalk.red(`Missing model list. Usage: forge config set models ${engineOrModels.toUpperCase()} <m1,m2,...>`));
|
|
41
|
+
process.exitCode = 1;
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
upper = (config.agentType ?? "CLAUDE_CODE").toUpperCase();
|
|
45
|
+
value = engineOrModels;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
upper = engineOrModels.toUpperCase();
|
|
49
|
+
value = maybeModels;
|
|
50
|
+
if (!isAgentType(upper)) {
|
|
51
|
+
console.error(chalk.red(`Invalid engine "${engineOrModels}". Allowed: ${AGENT_TYPE.join(", ")}`));
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (!isAgentType(upper)) {
|
|
57
|
+
console.error(chalk.red(`Configured agent type "${upper}" is not a valid engine.`));
|
|
58
|
+
process.exitCode = 1;
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
const lockedByOrgs = config.modelsLockedByOrgs ?? [];
|
|
62
|
+
if (lockedByOrgs.length > 0) {
|
|
63
|
+
console.error(chalk.red(`The model list is locked by organization(s): ${lockedByOrgs.join(", ")} — edit it from their cockpit (Settings → CLI Devices).`));
|
|
64
|
+
process.exitCode = 1;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const models = value.trim().toLowerCase() === "none"
|
|
68
|
+
? []
|
|
69
|
+
: value
|
|
70
|
+
.split(",")
|
|
71
|
+
.map((m) => m.trim())
|
|
72
|
+
.filter(Boolean);
|
|
73
|
+
// Mirror the server-side AvailableModelsSchema bounds so an invalid list
|
|
74
|
+
// fails HERE instead of turning every register POST/PATCH into a 400.
|
|
75
|
+
if (models.length > 20) {
|
|
76
|
+
console.error(chalk.red("Too many models — 20 maximum per engine."));
|
|
77
|
+
process.exitCode = 1;
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const tooLong = models.find((m) => m.length > 120);
|
|
81
|
+
if (tooLong) {
|
|
82
|
+
console.error(chalk.red(`Model id too long (120 chars max): "${tooLong.slice(0, 40)}..."`));
|
|
83
|
+
process.exitCode = 1;
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const availableModels = { ...(config.availableModels ?? {}) };
|
|
87
|
+
if (models.length === 0) {
|
|
88
|
+
delete availableModels[upper];
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
availableModels[upper] = models;
|
|
92
|
+
}
|
|
93
|
+
setConfig({ availableModels });
|
|
94
|
+
const applied = await tryReRegister();
|
|
95
|
+
console.log(chalk.green(models.length === 0
|
|
96
|
+
? `Model list cleared for ${upper}. ${applied ? "(Applied immediately)" : "(Will apply on next connect)"}`
|
|
97
|
+
: `Models for ${upper}: ${models.join(", ")} (default: ${models[0]}). ${applied ? "(Applied immediately)" : "(Will apply on next connect)"}`));
|
|
98
|
+
}
|
|
27
99
|
export async function configSetAgentSlotsAction(value) {
|
|
28
100
|
const num = Number.parseInt(value, 10);
|
|
29
101
|
if (Number.isNaN(num) || num < 1 || num > 20) {
|
|
@@ -55,6 +127,17 @@ export function configGetAction() {
|
|
|
55
127
|
if (config.agentType === "KILO") {
|
|
56
128
|
console.log(` Kilo model: ${config.kiloModel ?? "(Kilo default)"}`);
|
|
57
129
|
}
|
|
130
|
+
const declared = config.availableModels ?? {};
|
|
131
|
+
const declaredEntries = Object.entries(declared).filter(([, models]) => models.length > 0);
|
|
132
|
+
if (declaredEntries.length > 0) {
|
|
133
|
+
const lockNote = (config.modelsLockedByOrgs ?? []).length > 0
|
|
134
|
+
? ` (locked by: ${(config.modelsLockedByOrgs ?? []).join(", ")})`
|
|
135
|
+
: "";
|
|
136
|
+
console.log(` Declared models:${lockNote}`);
|
|
137
|
+
for (const [engine, models] of declaredEntries) {
|
|
138
|
+
console.log(` ${engine}: ${models.join(", ")}`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
58
141
|
console.log(` Agent slots: ${config.agentSlots ?? 5}`);
|
|
59
142
|
console.log(` Opener: ${config.opener ?? "terminal"}`);
|
|
60
143
|
console.log(` Server URL: ${config.serverUrl}`);
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge models [engine] — show the models declared on this device and the
|
|
3
|
+
* models available for the engine (live catalog for OpenCode/Kilo, curated
|
|
4
|
+
* suggestions for Claude Code/Codex).
|
|
5
|
+
*/
|
|
6
|
+
export declare function modelsListAction(engineArg?: string): Promise<void>;
|
|
7
|
+
//# sourceMappingURL=models-list.d.ts.map
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* forge models [engine] — show the models declared on this device and the
|
|
3
|
+
* models available for the engine (live catalog for OpenCode/Kilo, curated
|
|
4
|
+
* suggestions for Claude Code/Codex).
|
|
5
|
+
*/
|
|
6
|
+
import chalk from "chalk";
|
|
7
|
+
import { AGENT_TYPE, KNOWN_MODEL_SUGGESTIONS, LIVE_CATALOG_ENGINES, isAgentType, } from "@getforgeai/protocol";
|
|
8
|
+
import { getConfig } from "../config/config-manager.js";
|
|
9
|
+
import { fetchEngineModelCatalog } from "../lib/model-catalog.js";
|
|
10
|
+
import { resolveAgentType } from "../orchestrator/agent-profiles.js";
|
|
11
|
+
export async function modelsListAction(engineArg) {
|
|
12
|
+
const config = getConfig();
|
|
13
|
+
const engine = engineArg
|
|
14
|
+
? engineArg.toUpperCase()
|
|
15
|
+
: resolveAgentType(config.agentType);
|
|
16
|
+
if (!isAgentType(engine)) {
|
|
17
|
+
console.error(chalk.red(`Invalid engine "${engineArg}". Allowed: ${AGENT_TYPE.join(", ")}`));
|
|
18
|
+
process.exitCode = 1;
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
const declared = config.availableModels?.[engine] ?? [];
|
|
22
|
+
const lockedBy = config.modelsLockedByOrgs ?? [];
|
|
23
|
+
const locked = lockedBy.length > 0;
|
|
24
|
+
const lockNote = locked
|
|
25
|
+
? chalk.yellow(` (locked by: ${lockedBy.join(", ")})`)
|
|
26
|
+
: "";
|
|
27
|
+
console.log(chalk.bold(`Models — ${engine}`));
|
|
28
|
+
if (declared.length > 0) {
|
|
29
|
+
console.log(` Declared on this device${lockNote}:`);
|
|
30
|
+
declared.forEach((m, i) => {
|
|
31
|
+
console.log(` ${m}${i === 0 ? chalk.dim(" (default)") : ""}`);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
console.log(chalk.dim(" No models declared on this device — any demanded model makes it ineligible only if a list exists; declare one to advertise what your subscription can run.") + lockNote);
|
|
36
|
+
}
|
|
37
|
+
const live = LIVE_CATALOG_ENGINES.includes(engine);
|
|
38
|
+
console.log(live
|
|
39
|
+
? chalk.dim(` Listing available models via the ${engine} engine...`)
|
|
40
|
+
: chalk.dim(" Known models (curated — no headless listing for this engine):"));
|
|
41
|
+
const catalog = await fetchEngineModelCatalog(engine);
|
|
42
|
+
if (catalog.error) {
|
|
43
|
+
console.error(chalk.yellow(` ${catalog.error}`));
|
|
44
|
+
}
|
|
45
|
+
if (catalog.models.length > 0) {
|
|
46
|
+
const suggestions = KNOWN_MODEL_SUGGESTIONS[engine];
|
|
47
|
+
for (const model of catalog.models) {
|
|
48
|
+
const note = suggestions?.find((s) => s.id === model)?.note;
|
|
49
|
+
const mark = declared.includes(model) ? chalk.green("✓ ") : " ";
|
|
50
|
+
console.log(` ${mark}${model}${note ? chalk.dim(` — ${note}`) : ""}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
console.log(chalk.dim(locked
|
|
54
|
+
? "\nModel lists on this device are locked by your organization — manage them from the cockpit."
|
|
55
|
+
: `\nDeclare with: forge config set models ${engine} <model1,model2,...> (first = default)`));
|
|
56
|
+
}
|
|
57
|
+
//# sourceMappingURL=models-list.js.map
|
|
@@ -15,6 +15,19 @@ export type CliConfig = {
|
|
|
15
15
|
opencodeModel?: string;
|
|
16
16
|
/** Kilo model, e.g. "kilo/anthropic/claude-sonnet-4.5" (used when agentType is KILO). */
|
|
17
17
|
kiloModel?: string;
|
|
18
|
+
/**
|
|
19
|
+
* Models the device owner declares usable, per engine. Advertised to the
|
|
20
|
+
* cockpit at registration; first entry = this device's default model for
|
|
21
|
+
* the engine. Server-owned (read-only here) when modelsLockedByServer.
|
|
22
|
+
*/
|
|
23
|
+
availableModels?: Record<string, string[]>;
|
|
24
|
+
/**
|
|
25
|
+
* Slugs of the organizations that locked this device's model list from the
|
|
26
|
+
* cockpit. Enforcement is server-side per org (locked orgs ignore CLI-sent
|
|
27
|
+
* lists); locally this only gates `forge config set models` with a clear
|
|
28
|
+
* message. Per-org because one machine can serve several orgs.
|
|
29
|
+
*/
|
|
30
|
+
modelsLockedByOrgs?: string[];
|
|
18
31
|
agentSlots?: number;
|
|
19
32
|
opener?: Opener;
|
|
20
33
|
lastSyncCursor?: string;
|
|
@@ -53,6 +53,19 @@ export function clearSessionConfig() {
|
|
|
53
53
|
if (config.agentSlots !== undefined) {
|
|
54
54
|
cleaned.agentSlots = config.agentSlots;
|
|
55
55
|
}
|
|
56
|
+
// Machine-level agent model settings survive logout too
|
|
57
|
+
if (config.opencodeModel !== undefined) {
|
|
58
|
+
cleaned.opencodeModel = config.opencodeModel;
|
|
59
|
+
}
|
|
60
|
+
if (config.kiloModel !== undefined) {
|
|
61
|
+
cleaned.kiloModel = config.kiloModel;
|
|
62
|
+
}
|
|
63
|
+
if (config.availableModels !== undefined) {
|
|
64
|
+
cleaned.availableModels = config.availableModels;
|
|
65
|
+
}
|
|
66
|
+
if (config.modelsLockedByOrgs !== undefined) {
|
|
67
|
+
cleaned.modelsLockedByOrgs = config.modelsLockedByOrgs;
|
|
68
|
+
}
|
|
56
69
|
if (config.vmCpus !== undefined) {
|
|
57
70
|
cleaned.vmCpus = config.vmCpus;
|
|
58
71
|
}
|
|
@@ -219,6 +219,16 @@ export function establishConnections(ctx, append = false) {
|
|
|
219
219
|
registerWorkflowStepDispatchHandler(client.socket, org.orgId, spawnerCtx);
|
|
220
220
|
// Register workflow agent dismiss handler (Story 6b-10)
|
|
221
221
|
registerWorkflowAgentDismissHandler(client.socket, spawnerCtx);
|
|
222
|
+
// Cockpit-initiated model catalog listing ("Fetch from device"): run the
|
|
223
|
+
// engine's own listing (OpenCode/Kilo) or answer curated suggestions.
|
|
224
|
+
client.socket.on("models:catalog:request", (payload, callback) => {
|
|
225
|
+
void (async () => {
|
|
226
|
+
const { answerCatalogRequest } = await import("./model-catalog.js");
|
|
227
|
+
callback(await answerCatalogRequest(payload));
|
|
228
|
+
})().catch(() => {
|
|
229
|
+
callback({ models: [], error: "Catalog fetch failed" });
|
|
230
|
+
});
|
|
231
|
+
});
|
|
222
232
|
// Listen for server-pushed config updates (e.g., admin changed agentSlots
|
|
223
233
|
// or the agent engine in the Cloud UI)
|
|
224
234
|
client.socket.on("cli:config:updated", (payload) => {
|
|
@@ -235,6 +245,23 @@ export function establishConnections(ctx, append = false) {
|
|
|
235
245
|
console.log(chalk.green(`${prefix(org.orgSlug)}Agent type updated by server: ${previousType} → ${payload.agentType}`));
|
|
236
246
|
}
|
|
237
247
|
}
|
|
248
|
+
// Server-pushed model governance (cockpit list edits + lock). The lock
|
|
249
|
+
// is tracked per org; the local availableModels stays the owner's
|
|
250
|
+
// declaration (a locked org enforces its own list server-side).
|
|
251
|
+
if (payload.modelsLocked !== undefined) {
|
|
252
|
+
const lockedByOrgs = new Set(getConfig().modelsLockedByOrgs ?? []);
|
|
253
|
+
if (payload.modelsLocked) {
|
|
254
|
+
lockedByOrgs.add(org.orgSlug);
|
|
255
|
+
}
|
|
256
|
+
else {
|
|
257
|
+
lockedByOrgs.delete(org.orgSlug);
|
|
258
|
+
}
|
|
259
|
+
setConfig({ modelsLockedByOrgs: Array.from(lockedByOrgs) });
|
|
260
|
+
}
|
|
261
|
+
if (payload.availableModels !== undefined ||
|
|
262
|
+
payload.modelsLocked !== undefined) {
|
|
263
|
+
console.log(chalk.green(`${prefix(org.orgSlug)}Model governance updated by this organization${payload.modelsLocked ? " (list locked — its cockpit owns it there)" : ""}`));
|
|
264
|
+
}
|
|
238
265
|
if (payload.agentSlots != null && payload.agentSlots !== ctx.agentSlots) {
|
|
239
266
|
const previous = ctx.agentSlots;
|
|
240
267
|
ctx.agentSlots = payload.agentSlots;
|
|
@@ -281,8 +308,29 @@ export function establishConnections(ctx, append = false) {
|
|
|
281
308
|
name: ctx.cliName,
|
|
282
309
|
agentType: ctx.agentType,
|
|
283
310
|
agentSlots: ctx.agentSlots,
|
|
311
|
+
availableModels: getConfig().availableModels,
|
|
284
312
|
});
|
|
285
313
|
console.log(chalk.green(`${prefix(org.orgSlug)}CLI registered as "${ctx.cliName}"`));
|
|
314
|
+
// Lock state is tracked PER ORG (one machine can serve several orgs;
|
|
315
|
+
// org B must not clear org A's lock, and org A's restricted list must
|
|
316
|
+
// not overwrite the owner's local declaration — enforcement is
|
|
317
|
+
// server-side per org: a locked org ignores CLI-sent lists anyway).
|
|
318
|
+
{
|
|
319
|
+
const lockedByOrgs = new Set(getConfig().modelsLockedByOrgs ?? []);
|
|
320
|
+
const wasLocked = lockedByOrgs.has(org.orgSlug);
|
|
321
|
+
if (device.modelsLocked) {
|
|
322
|
+
lockedByOrgs.add(org.orgSlug);
|
|
323
|
+
}
|
|
324
|
+
else {
|
|
325
|
+
lockedByOrgs.delete(org.orgSlug);
|
|
326
|
+
}
|
|
327
|
+
if (wasLocked !== Boolean(device.modelsLocked)) {
|
|
328
|
+
setConfig({ modelsLockedByOrgs: Array.from(lockedByOrgs) });
|
|
329
|
+
if (device.modelsLocked) {
|
|
330
|
+
console.log(chalk.dim(`${prefix(org.orgSlug)}Model list is locked by this organization — its cockpit owns the list there`));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
286
334
|
// The server is source of truth for agentType after first registration
|
|
287
335
|
// (a cockpit edit made while this CLI was offline must survive the
|
|
288
336
|
// reconnect). Adopt the returned value.
|
package/dist/lib/daemon-entry.js
CHANGED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model catalog discovery per engine.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode and Kilo can list their real catalogs (`opencode models`,
|
|
5
|
+
* `kilo models`) — we run that inside the agent Docker image with the stored
|
|
6
|
+
* credentials, in a throwaway container. Claude Code and Codex have no
|
|
7
|
+
* headless listing; they answer from the curated KNOWN_MODEL_SUGGESTIONS.
|
|
8
|
+
*
|
|
9
|
+
* Used by `forge models` and by the cockpit's "Fetch from device" button
|
|
10
|
+
* (models:catalog:request socket event, answered via answerCatalogRequest).
|
|
11
|
+
*/
|
|
12
|
+
import type { AgentType } from "@getforgeai/protocol";
|
|
13
|
+
export type ModelCatalogResult = {
|
|
14
|
+
models: string[];
|
|
15
|
+
/** "curated" = static suggestions; "live" = listed by the engine itself. */
|
|
16
|
+
source: "curated" | "live";
|
|
17
|
+
error?: string;
|
|
18
|
+
};
|
|
19
|
+
/** Strip ANSI escapes and keep plausible model-id lines. Exported for tests. */
|
|
20
|
+
export declare function parseCatalogOutput(raw: string): string[];
|
|
21
|
+
/**
|
|
22
|
+
* Fetch the model catalog for an engine: live from the engine when it
|
|
23
|
+
* supports listing, curated suggestions otherwise.
|
|
24
|
+
*/
|
|
25
|
+
export declare function fetchEngineModelCatalog(engine: AgentType): Promise<ModelCatalogResult>;
|
|
26
|
+
/**
|
|
27
|
+
* Answer a cockpit models:catalog:request payload. Never throws — every
|
|
28
|
+
* failure maps to a resolved value so the socket ack callback always fires.
|
|
29
|
+
*/
|
|
30
|
+
export declare function answerCatalogRequest(payload: unknown): Promise<{
|
|
31
|
+
models: string[];
|
|
32
|
+
error?: string;
|
|
33
|
+
}>;
|
|
34
|
+
//# sourceMappingURL=model-catalog.d.ts.map
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Model catalog discovery per engine.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode and Kilo can list their real catalogs (`opencode models`,
|
|
5
|
+
* `kilo models`) — we run that inside the agent Docker image with the stored
|
|
6
|
+
* credentials, in a throwaway container. Claude Code and Codex have no
|
|
7
|
+
* headless listing; they answer from the curated KNOWN_MODEL_SUGGESTIONS.
|
|
8
|
+
*
|
|
9
|
+
* Used by `forge models` and by the cockpit's "Fetch from device" button
|
|
10
|
+
* (models:catalog:request socket event, answered via answerCatalogRequest).
|
|
11
|
+
*/
|
|
12
|
+
import { spawn } from "node:child_process";
|
|
13
|
+
import { randomBytes } from "node:crypto";
|
|
14
|
+
import { KNOWN_MODEL_SUGGESTIONS, LIVE_CATALOG_ENGINES, isAgentType, } from "@getforgeai/protocol";
|
|
15
|
+
import { getAgentProfile } from "../orchestrator/agent-profiles.js";
|
|
16
|
+
import { ensureImage, FORGEAI_IMAGE } from "../vm/image-manager.js";
|
|
17
|
+
import { AGENT_API_KEY_DEFAULT_ENV, AGENT_AUTH_JSON_PATHS, loadAgentAuth, } from "../vm/agent-auth-manager.js";
|
|
18
|
+
const CATALOG_TIMEOUT_MS = 45_000;
|
|
19
|
+
/** Strip ANSI escapes and keep plausible model-id lines. Exported for tests. */
|
|
20
|
+
export function parseCatalogOutput(raw) {
|
|
21
|
+
const seen = new Set();
|
|
22
|
+
const models = [];
|
|
23
|
+
for (const line of raw.split("\n")) {
|
|
24
|
+
// eslint-disable-next-line no-control-regex
|
|
25
|
+
const clean = line.replaceAll(/\x1b\[[0-9;]*m/g, "").trim();
|
|
26
|
+
// Model ids: single token containing a provider separator, no spaces
|
|
27
|
+
if (!clean || clean.includes(" ") || !clean.includes("/"))
|
|
28
|
+
continue;
|
|
29
|
+
if (!/^[\w.:@/-]+$/.test(clean))
|
|
30
|
+
continue;
|
|
31
|
+
if (clean.length > 120 || seen.has(clean))
|
|
32
|
+
continue;
|
|
33
|
+
seen.add(clean);
|
|
34
|
+
models.push(clean);
|
|
35
|
+
}
|
|
36
|
+
return models;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Best-effort teardown of the throwaway container. Killing the docker CLI
|
|
40
|
+
* client only detaches it — the container (holding credentials in env) keeps
|
|
41
|
+
* running and `--rm` never fires, so it must be removed by name.
|
|
42
|
+
*/
|
|
43
|
+
function forceRemoveContainer(name) {
|
|
44
|
+
const rm = spawn("docker", ["rm", "-f", name], { stdio: "ignore" });
|
|
45
|
+
rm.on("error", () => { });
|
|
46
|
+
rm.unref();
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Run the engine's own catalog command in a throwaway agent container.
|
|
50
|
+
* Credentials travel as name-only `-e` env vars (values stay out of argv,
|
|
51
|
+
* same pattern as docker-executor's secretEnv).
|
|
52
|
+
*/
|
|
53
|
+
async function runLiveCatalog(engine) {
|
|
54
|
+
const auth = await loadAgentAuth(engine);
|
|
55
|
+
if (!auth) {
|
|
56
|
+
return {
|
|
57
|
+
models: [],
|
|
58
|
+
source: "live",
|
|
59
|
+
error: `No ${engine} credentials stored — run forge auth setup-${engine.toLowerCase()} first`,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
const profile = getAgentProfile(engine);
|
|
63
|
+
const binary = profile.binary;
|
|
64
|
+
const containerEnv = { ...profile.spawnEnv };
|
|
65
|
+
let setup = "";
|
|
66
|
+
if (auth.kind === "api_key") {
|
|
67
|
+
const envVar = auth.envVar ?? AGENT_API_KEY_DEFAULT_ENV[engine];
|
|
68
|
+
containerEnv[envVar] = auth.value;
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
const target = AGENT_AUTH_JSON_PATHS[engine];
|
|
72
|
+
const dir = target.slice(0, target.lastIndexOf("/"));
|
|
73
|
+
containerEnv.FORGE_AGENT_SECRET = auth.value;
|
|
74
|
+
setup = `mkdir -p ${dir} && printenv FORGE_AGENT_SECRET > ${target} && chmod 600 ${target} && `;
|
|
75
|
+
}
|
|
76
|
+
try {
|
|
77
|
+
await ensureImage();
|
|
78
|
+
}
|
|
79
|
+
catch (err) {
|
|
80
|
+
return {
|
|
81
|
+
models: [],
|
|
82
|
+
source: "live",
|
|
83
|
+
error: `Agent image unavailable: ${err instanceof Error ? err.message : String(err)}`,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
const containerName = `forge-catalog-${randomBytes(6).toString("hex")}`;
|
|
87
|
+
const script = `${setup}${binary} models`;
|
|
88
|
+
const args = [
|
|
89
|
+
"run",
|
|
90
|
+
"--rm",
|
|
91
|
+
"--name",
|
|
92
|
+
containerName,
|
|
93
|
+
"--label",
|
|
94
|
+
"forgeai.managed=true",
|
|
95
|
+
"--platform",
|
|
96
|
+
"linux/arm64",
|
|
97
|
+
...Object.keys(containerEnv).flatMap((key) => ["-e", key]),
|
|
98
|
+
FORGEAI_IMAGE,
|
|
99
|
+
"bash",
|
|
100
|
+
"-c",
|
|
101
|
+
script,
|
|
102
|
+
];
|
|
103
|
+
return new Promise((resolve) => {
|
|
104
|
+
const child = spawn("docker", args, {
|
|
105
|
+
env: { ...process.env, ...containerEnv },
|
|
106
|
+
});
|
|
107
|
+
let stdout = "";
|
|
108
|
+
let stderr = "";
|
|
109
|
+
let timedOut = false;
|
|
110
|
+
const timer = setTimeout(() => {
|
|
111
|
+
timedOut = true;
|
|
112
|
+
child.kill("SIGKILL");
|
|
113
|
+
forceRemoveContainer(containerName);
|
|
114
|
+
resolve({
|
|
115
|
+
models: [],
|
|
116
|
+
source: "live",
|
|
117
|
+
error: `Catalog listing timed out (${CATALOG_TIMEOUT_MS / 1000}s)`,
|
|
118
|
+
});
|
|
119
|
+
}, CATALOG_TIMEOUT_MS);
|
|
120
|
+
child.stdout.on("data", (d) => {
|
|
121
|
+
stdout += d.toString();
|
|
122
|
+
});
|
|
123
|
+
child.stderr.on("data", (d) => {
|
|
124
|
+
stderr += d.toString();
|
|
125
|
+
});
|
|
126
|
+
child.on("error", (err) => {
|
|
127
|
+
clearTimeout(timer);
|
|
128
|
+
resolve({
|
|
129
|
+
models: [],
|
|
130
|
+
source: "live",
|
|
131
|
+
error: err.code === "ENOENT"
|
|
132
|
+
? "Docker is not installed or not running on this device"
|
|
133
|
+
: err.message,
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
child.on("exit", (code) => {
|
|
137
|
+
clearTimeout(timer);
|
|
138
|
+
if (timedOut) {
|
|
139
|
+
forceRemoveContainer(containerName);
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
const models = parseCatalogOutput(stdout);
|
|
143
|
+
if (models.length > 0) {
|
|
144
|
+
resolve({ models, source: "live" });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const cause = stderr.trim().slice(0, 200);
|
|
148
|
+
let error;
|
|
149
|
+
if (code === 0) {
|
|
150
|
+
error = "The engine returned no parsable model ids";
|
|
151
|
+
}
|
|
152
|
+
else if (code === 125) {
|
|
153
|
+
error = cause ? `Docker error: ${cause}` : "Docker error (exit 125)";
|
|
154
|
+
}
|
|
155
|
+
else if (code === 127) {
|
|
156
|
+
error = `${binary} not found in the agent image — pull the latest getforgeai/agent image`;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
error = `${binary} models exited with code ${code}${cause ? `: ${cause}` : ""}`;
|
|
160
|
+
}
|
|
161
|
+
resolve({ models: [], source: "live", error });
|
|
162
|
+
});
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* One catalog container per engine at a time: concurrent requests (cockpit
|
|
167
|
+
* spam, `forge models` racing a socket request) share the in-flight run.
|
|
168
|
+
*/
|
|
169
|
+
const inFlight = new Map();
|
|
170
|
+
/**
|
|
171
|
+
* Fetch the model catalog for an engine: live from the engine when it
|
|
172
|
+
* supports listing, curated suggestions otherwise.
|
|
173
|
+
*/
|
|
174
|
+
export async function fetchEngineModelCatalog(engine) {
|
|
175
|
+
if (LIVE_CATALOG_ENGINES.includes(engine)) {
|
|
176
|
+
const running = inFlight.get(engine);
|
|
177
|
+
if (running)
|
|
178
|
+
return running;
|
|
179
|
+
const run = runLiveCatalog(engine).finally(() => {
|
|
180
|
+
inFlight.delete(engine);
|
|
181
|
+
});
|
|
182
|
+
inFlight.set(engine, run);
|
|
183
|
+
return run;
|
|
184
|
+
}
|
|
185
|
+
const suggestions = KNOWN_MODEL_SUGGESTIONS[engine] ?? [];
|
|
186
|
+
return { models: suggestions.map((s) => s.id), source: "curated" };
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Answer a cockpit models:catalog:request payload. Never throws — every
|
|
190
|
+
* failure maps to a resolved value so the socket ack callback always fires.
|
|
191
|
+
*/
|
|
192
|
+
export async function answerCatalogRequest(payload) {
|
|
193
|
+
try {
|
|
194
|
+
const engine = payload && typeof payload === "object"
|
|
195
|
+
? payload.engine
|
|
196
|
+
: undefined;
|
|
197
|
+
if (typeof engine !== "string" || !isAgentType(engine)) {
|
|
198
|
+
return { models: [], error: "Unknown engine" };
|
|
199
|
+
}
|
|
200
|
+
const result = await fetchEngineModelCatalog(engine);
|
|
201
|
+
return { models: result.models, error: result.error };
|
|
202
|
+
}
|
|
203
|
+
catch (err) {
|
|
204
|
+
return {
|
|
205
|
+
models: [],
|
|
206
|
+
error: err instanceof Error ? err.message : "Catalog fetch failed",
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
//# sourceMappingURL=model-catalog.js.map
|
|
@@ -9,6 +9,8 @@ type RegisterCliRequest = {
|
|
|
9
9
|
name?: string;
|
|
10
10
|
agentType?: string;
|
|
11
11
|
agentSlots?: number;
|
|
12
|
+
/** Declared usable models per engine (ignored server-side when locked). */
|
|
13
|
+
availableModels?: Record<string, string[]>;
|
|
12
14
|
};
|
|
13
15
|
type RegisterCliResponse = {
|
|
14
16
|
id: string;
|
|
@@ -17,6 +19,8 @@ type RegisterCliResponse = {
|
|
|
17
19
|
memberId: string;
|
|
18
20
|
agentType: string;
|
|
19
21
|
agentSlots: number;
|
|
22
|
+
availableModels?: Record<string, string[]> | null;
|
|
23
|
+
modelsLocked?: boolean;
|
|
20
24
|
createdAt: string;
|
|
21
25
|
};
|
|
22
26
|
type CliDeviceItem = {
|
|
@@ -54,6 +58,8 @@ type UpdateCliConfigRequest = {
|
|
|
54
58
|
name?: string;
|
|
55
59
|
agentType?: string;
|
|
56
60
|
agentSlots?: number;
|
|
61
|
+
/** Declared usable models per engine (ignored server-side when locked). */
|
|
62
|
+
availableModels?: Record<string, string[]>;
|
|
57
63
|
};
|
|
58
64
|
type TaskDetail = {
|
|
59
65
|
id: string;
|
|
@@ -23,13 +23,31 @@ export type McpBridgeConfig = {
|
|
|
23
23
|
};
|
|
24
24
|
export type AgentConfigOptions = {
|
|
25
25
|
/**
|
|
26
|
-
* Model
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
* "
|
|
26
|
+
* Model id, engine-specific:
|
|
27
|
+
* Claude Code: alias (sonnet/opus/haiku/fable) or full id.
|
|
28
|
+
* Codex: bare slug (e.g. "gpt-5.5").
|
|
29
|
+
* OpenCode: "provider/model" (e.g. "anthropic/claude-sonnet-4-5").
|
|
30
|
+
* Kilo: "kilo/<vendor>/<model>" (e.g. "kilo/anthropic/claude-sonnet-4.5").
|
|
30
31
|
*/
|
|
31
32
|
model?: string;
|
|
33
|
+
/**
|
|
34
|
+
* Generic reasoning effort (EFFORT_LEVELS: low|medium|high|max), mapped per
|
|
35
|
+
* engine: Claude --effort as-is; Codex model_reasoning_effort (max→xhigh);
|
|
36
|
+
* OpenCode/Kilo --variant clamped per model vendor (mapEffortToVariant).
|
|
37
|
+
*/
|
|
38
|
+
effort?: string;
|
|
32
39
|
};
|
|
40
|
+
/**
|
|
41
|
+
* Map the generic effort scale onto an OpenCode/Kilo model variant for the
|
|
42
|
+
* given vendor. Variants are vendor catalogs: OpenAI none…xhigh, Anthropic
|
|
43
|
+
* high/max, Google low/high. Unknown vendor → undefined (omit --variant
|
|
44
|
+
* rather than risk a server-side unknown-variant error).
|
|
45
|
+
*/
|
|
46
|
+
export declare function mapEffortToVariant(vendor: string | undefined, effort: string | undefined): string | undefined;
|
|
47
|
+
/** Vendor of an OpenCode model id ("vendor/model") for variant mapping. */
|
|
48
|
+
export declare function openCodeModelVendor(model: string | undefined): string | undefined;
|
|
49
|
+
/** Vendor of a Kilo model id ("kilo/vendor/model") for variant mapping. */
|
|
50
|
+
export declare function kiloModelVendor(model: string | undefined): string | undefined;
|
|
33
51
|
export type AgentAuthErrorPatterns = {
|
|
34
52
|
/** Substrings matched against stderr only (avoids stdout false positives). */
|
|
35
53
|
stderr: string[];
|
|
@@ -49,9 +67,9 @@ export type AgentProfile = {
|
|
|
49
67
|
/** Build the agent config file content (JSON or TOML depending on engine). */
|
|
50
68
|
buildConfig(mcp: McpBridgeConfig, opts?: AgentConfigOptions): string;
|
|
51
69
|
/** argv for a fresh non-interactive run. */
|
|
52
|
-
buildArgs(prompt: string): string[];
|
|
70
|
+
buildArgs(prompt: string, opts?: AgentConfigOptions): string[];
|
|
53
71
|
/** argv to continue the latest on-disk session in the same home dir. */
|
|
54
|
-
buildContinueArgs(prompt: string): string[];
|
|
72
|
+
buildContinueArgs(prompt: string, opts?: AgentConfigOptions): string[];
|
|
55
73
|
/** Non-secret env vars added to every agent spawn. */
|
|
56
74
|
spawnEnv: Record<string, string>;
|
|
57
75
|
/**
|
|
@@ -18,6 +18,37 @@ import { DEFAULT_AGENT_TYPE, isAgentType } from "@getforgeai/protocol";
|
|
|
18
18
|
import { CodexJsonlExtractor } from "./codex-jsonl-extractor.js";
|
|
19
19
|
import { OpenCodeJsonlExtractor } from "./opencode-jsonl-extractor.js";
|
|
20
20
|
import { StreamJsonExtractor } from "./stream-json-extractor.js";
|
|
21
|
+
/**
|
|
22
|
+
* Map the generic effort scale onto an OpenCode/Kilo model variant for the
|
|
23
|
+
* given vendor. Variants are vendor catalogs: OpenAI none…xhigh, Anthropic
|
|
24
|
+
* high/max, Google low/high. Unknown vendor → undefined (omit --variant
|
|
25
|
+
* rather than risk a server-side unknown-variant error).
|
|
26
|
+
*/
|
|
27
|
+
export function mapEffortToVariant(vendor, effort) {
|
|
28
|
+
if (!effort)
|
|
29
|
+
return undefined;
|
|
30
|
+
switch (vendor) {
|
|
31
|
+
case "openai":
|
|
32
|
+
return { low: "low", medium: "medium", high: "high", max: "xhigh" }[effort];
|
|
33
|
+
case "anthropic":
|
|
34
|
+
return { low: "high", medium: "high", high: "high", max: "max" }[effort];
|
|
35
|
+
case "google":
|
|
36
|
+
return { low: "low", medium: "low", high: "high", max: "high" }[effort];
|
|
37
|
+
default:
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/** Vendor of an OpenCode model id ("vendor/model") for variant mapping. */
|
|
42
|
+
export function openCodeModelVendor(model) {
|
|
43
|
+
return model?.split("/")[0] || undefined;
|
|
44
|
+
}
|
|
45
|
+
/** Vendor of a Kilo model id ("kilo/vendor/model") for variant mapping. */
|
|
46
|
+
export function kiloModelVendor(model) {
|
|
47
|
+
if (!model)
|
|
48
|
+
return undefined;
|
|
49
|
+
const parts = model.split("/");
|
|
50
|
+
return parts[0] === "kilo" ? parts[1] || undefined : parts[0] || undefined;
|
|
51
|
+
}
|
|
21
52
|
/** Narrow an arbitrary config/dispatch string to a supported AgentType. */
|
|
22
53
|
export function resolveAgentType(value) {
|
|
23
54
|
if (value && isAgentType(value))
|
|
@@ -60,11 +91,14 @@ const claudeProfile = {
|
|
|
60
91
|
},
|
|
61
92
|
}, null, 2);
|
|
62
93
|
},
|
|
63
|
-
buildArgs(prompt) {
|
|
94
|
+
buildArgs(prompt, opts) {
|
|
64
95
|
return [
|
|
65
96
|
"--dangerously-skip-permissions",
|
|
66
97
|
"--mcp-config",
|
|
67
98
|
CLAUDE_MCP_CONFIG_PATH,
|
|
99
|
+
// Model alias/id and generic effort map 1:1 onto Claude Code flags
|
|
100
|
+
...(opts?.model ? ["--model", opts.model] : []),
|
|
101
|
+
...(opts?.effort ? ["--effort", opts.effort] : []),
|
|
68
102
|
"-p",
|
|
69
103
|
prompt,
|
|
70
104
|
"--output-format",
|
|
@@ -73,8 +107,8 @@ const claudeProfile = {
|
|
|
73
107
|
"--include-partial-messages",
|
|
74
108
|
];
|
|
75
109
|
},
|
|
76
|
-
buildContinueArgs(prompt) {
|
|
77
|
-
return ["--continue", ...this.buildArgs(prompt)];
|
|
110
|
+
buildContinueArgs(prompt, opts) {
|
|
111
|
+
return ["--continue", ...this.buildArgs(prompt, opts)];
|
|
78
112
|
},
|
|
79
113
|
spawnEnv: {},
|
|
80
114
|
nativeSkillDiscovery: true,
|
|
@@ -110,12 +144,17 @@ const codexProfile = {
|
|
|
110
144
|
type: "CODEX",
|
|
111
145
|
binary: "codex",
|
|
112
146
|
configTargetPath: "/home/agent/.codex/config.toml",
|
|
113
|
-
buildConfig(mcp) {
|
|
147
|
+
buildConfig(mcp, opts) {
|
|
148
|
+
// Codex reads model/effort from config.toml (regenerated per container,
|
|
149
|
+
// excluded from snapshots). Generic "max" maps to Codex's "xhigh".
|
|
150
|
+
const effort = opts?.effort === "max" ? "xhigh" : (opts?.effort ?? undefined);
|
|
114
151
|
return [
|
|
115
152
|
"# Generated by ForgeAI — do not edit",
|
|
116
153
|
"# The Docker container is the security boundary; Codex's own sandbox is disabled.",
|
|
117
154
|
'approval_policy = "never"',
|
|
118
155
|
'sandbox_mode = "danger-full-access"',
|
|
156
|
+
...(opts?.model ? [`model = ${tomlString(opts.model)}`] : []),
|
|
157
|
+
...(effort ? [`model_reasoning_effort = ${tomlString(effort)}`] : []),
|
|
119
158
|
"",
|
|
120
159
|
"[mcp_servers.forgeai]",
|
|
121
160
|
'command = "node"',
|
|
@@ -204,11 +243,26 @@ const openCodeProfile = {
|
|
|
204
243
|
},
|
|
205
244
|
}, null, 2);
|
|
206
245
|
},
|
|
207
|
-
buildArgs(prompt) {
|
|
208
|
-
|
|
246
|
+
buildArgs(prompt, opts) {
|
|
247
|
+
const variant = mapEffortToVariant(openCodeModelVendor(opts?.model), opts?.effort);
|
|
248
|
+
return [
|
|
249
|
+
"run",
|
|
250
|
+
...(variant ? ["--variant", variant] : []),
|
|
251
|
+
"--format",
|
|
252
|
+
"json",
|
|
253
|
+
prompt,
|
|
254
|
+
];
|
|
209
255
|
},
|
|
210
|
-
buildContinueArgs(prompt) {
|
|
211
|
-
|
|
256
|
+
buildContinueArgs(prompt, opts) {
|
|
257
|
+
const variant = mapEffortToVariant(openCodeModelVendor(opts?.model), opts?.effort);
|
|
258
|
+
return [
|
|
259
|
+
"run",
|
|
260
|
+
"--continue",
|
|
261
|
+
...(variant ? ["--variant", variant] : []),
|
|
262
|
+
"--format",
|
|
263
|
+
"json",
|
|
264
|
+
prompt,
|
|
265
|
+
];
|
|
212
266
|
},
|
|
213
267
|
spawnEnv: { OPENCODE_PERMISSION: OPENCODE_PERMISSION_JSON },
|
|
214
268
|
// OpenCode reads .claude/skills natively (OPENCODE_DISABLE_CLAUDE_CODE_SKILLS opts out)
|
|
@@ -268,14 +322,31 @@ const kiloProfile = {
|
|
|
268
322
|
},
|
|
269
323
|
}, null, 2);
|
|
270
324
|
},
|
|
271
|
-
buildArgs(prompt) {
|
|
325
|
+
buildArgs(prompt, opts) {
|
|
272
326
|
// --auto: auto-approve permissions (headless runs REJECT asks otherwise).
|
|
273
327
|
// Spawn must close stdin: with a non-TTY open stdin, `kilo run` blocks
|
|
274
328
|
// reading it to EOF before sending the prompt (Bun.stdin.text()).
|
|
275
|
-
|
|
329
|
+
const variant = mapEffortToVariant(kiloModelVendor(opts?.model), opts?.effort);
|
|
330
|
+
return [
|
|
331
|
+
"run",
|
|
332
|
+
"--auto",
|
|
333
|
+
...(variant ? ["--variant", variant] : []),
|
|
334
|
+
"--format",
|
|
335
|
+
"json",
|
|
336
|
+
prompt,
|
|
337
|
+
];
|
|
276
338
|
},
|
|
277
|
-
buildContinueArgs(prompt) {
|
|
278
|
-
|
|
339
|
+
buildContinueArgs(prompt, opts) {
|
|
340
|
+
const variant = mapEffortToVariant(kiloModelVendor(opts?.model), opts?.effort);
|
|
341
|
+
return [
|
|
342
|
+
"run",
|
|
343
|
+
"--continue",
|
|
344
|
+
"--auto",
|
|
345
|
+
...(variant ? ["--variant", variant] : []),
|
|
346
|
+
"--format",
|
|
347
|
+
"json",
|
|
348
|
+
prompt,
|
|
349
|
+
];
|
|
279
350
|
},
|
|
280
351
|
spawnEnv: {
|
|
281
352
|
KILO_PERMISSION: KILO_PERMISSION_JSON,
|
|
@@ -30,6 +30,10 @@ export declare const TaskDispatchSchema: z.ZodObject<{
|
|
|
30
30
|
projectSlug: z.ZodOptional<z.ZodString>;
|
|
31
31
|
repoUrl: z.ZodOptional<z.ZodString>;
|
|
32
32
|
metadata: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodUnknown>>;
|
|
33
|
+
modelConfig: z.ZodOptional<z.ZodObject<{
|
|
34
|
+
model: z.ZodOptional<z.ZodString>;
|
|
35
|
+
effort: z.ZodOptional<z.ZodString>;
|
|
36
|
+
}, z.core.$strip>>;
|
|
33
37
|
}, z.core.$strip>;
|
|
34
38
|
export type DispatchParams = z.infer<typeof TaskDispatchSchema> & {
|
|
35
39
|
orgId: string;
|
|
@@ -68,6 +68,13 @@ export const TaskDispatchSchema = z.object({
|
|
|
68
68
|
projectSlug: z.string().optional(),
|
|
69
69
|
repoUrl: z.string().optional(),
|
|
70
70
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
71
|
+
/** Cloud-resolved model/effort for this dispatch (ResolvedModelConfig). */
|
|
72
|
+
modelConfig: z
|
|
73
|
+
.object({
|
|
74
|
+
model: z.string().optional(),
|
|
75
|
+
effort: z.string().optional(),
|
|
76
|
+
})
|
|
77
|
+
.optional(),
|
|
71
78
|
});
|
|
72
79
|
let registry = null;
|
|
73
80
|
function getRegistry(agentSlots) {
|
|
@@ -641,6 +648,7 @@ export async function spawnAgent(params, ctx) {
|
|
|
641
648
|
skillName: skill?.name ?? skillName,
|
|
642
649
|
prompt: params.taskCommand,
|
|
643
650
|
agentType: ctx.agentType,
|
|
651
|
+
modelConfig: params.modelConfig,
|
|
644
652
|
repoUrl: params.repoUrl,
|
|
645
653
|
taskBranch,
|
|
646
654
|
workflowBaseBranch: params.workflowBaseBranch,
|
|
@@ -1010,6 +1018,7 @@ async function spawnWorkflowStepAgent(params, ctx) {
|
|
|
1010
1018
|
skillName: skill?.name ?? skillName,
|
|
1011
1019
|
prompt,
|
|
1012
1020
|
agentType: ctx.agentType,
|
|
1021
|
+
modelConfig: params.modelConfig,
|
|
1013
1022
|
repoUrl: params.repoUrl,
|
|
1014
1023
|
taskBranch: params.baseBranch,
|
|
1015
1024
|
workflowBaseBranch: params.sourceBranch,
|
|
@@ -1278,6 +1287,7 @@ async function resumeWorkflowStepAgent(agentId, userMessage, lastAssistantConten
|
|
|
1278
1287
|
skillName: DEFAULT_SKILL_NAME,
|
|
1279
1288
|
prompt: userMessage,
|
|
1280
1289
|
agentType: ctx.agentType,
|
|
1290
|
+
modelConfig: originalParams.modelConfig,
|
|
1281
1291
|
repoUrl: originalParams.repoUrl,
|
|
1282
1292
|
taskBranch: originalParams.baseBranch,
|
|
1283
1293
|
workflowBaseBranch: originalParams.sourceBranch,
|
|
@@ -1446,6 +1456,7 @@ export function registerWorkflowStepDispatchHandler(socket, orgId, ctx) {
|
|
|
1446
1456
|
sourceBranch: data.sourceBranch,
|
|
1447
1457
|
conversationHistory: data.conversationHistory,
|
|
1448
1458
|
hasSnapshot: data.hasSnapshot,
|
|
1459
|
+
modelConfig: data.modelConfig,
|
|
1449
1460
|
}, ctx).catch((err) => {
|
|
1450
1461
|
try {
|
|
1451
1462
|
vmLog.error("workflow-dispatch", `Failed to spawn agent for step ${data.stepId}: ${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -27,6 +27,14 @@ export type SpawnParams = {
|
|
|
27
27
|
prompt?: string;
|
|
28
28
|
/** Agent engine to run (AGENT_TYPE) — defaults to CLAUDE_CODE. */
|
|
29
29
|
agentType?: string;
|
|
30
|
+
/**
|
|
31
|
+
* Cloud-resolved model/effort for this dispatch (column -> project -> org).
|
|
32
|
+
* Absent → the device's local default model applies.
|
|
33
|
+
*/
|
|
34
|
+
modelConfig?: {
|
|
35
|
+
model?: string;
|
|
36
|
+
effort?: string;
|
|
37
|
+
};
|
|
30
38
|
};
|
|
31
39
|
/**
|
|
32
40
|
* Extended spawn params for VM connector — includes repo/branch/skill context
|
|
@@ -39,10 +39,26 @@ async function resolveMcpRelayHost() {
|
|
|
39
39
|
* and authenticates to the bridge with the per-session secret.
|
|
40
40
|
* Format and install path depend on the agent engine (see agent-profiles.ts).
|
|
41
41
|
*/
|
|
42
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Effective model/effort for a spawn: the cloud-resolved dispatch config wins,
|
|
44
|
+
* else the device's local default (first declared model for the engine, with
|
|
45
|
+
* the legacy per-engine model settings as fallback).
|
|
46
|
+
*/
|
|
47
|
+
function resolveEffectiveModelConfig(profile, dispatch) {
|
|
43
48
|
const cliConfig = getConfig();
|
|
44
|
-
const
|
|
45
|
-
|
|
49
|
+
const declared = cliConfig.availableModels?.[profile.type];
|
|
50
|
+
const legacyDefault = profile.type === "KILO"
|
|
51
|
+
? cliConfig.kiloModel
|
|
52
|
+
: profile.type === "OPENCODE"
|
|
53
|
+
? cliConfig.opencodeModel
|
|
54
|
+
: undefined;
|
|
55
|
+
return {
|
|
56
|
+
model: dispatch?.model ?? declared?.[0] ?? legacyDefault,
|
|
57
|
+
effort: dispatch?.effort,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function buildAgentConfig(profile, sessionIndex, mcpSecret, mcpHost, modelOpts) {
|
|
61
|
+
return profile.buildConfig({ host: mcpHost, port: 20000 + sessionIndex, token: mcpSecret }, modelOpts);
|
|
46
62
|
}
|
|
47
63
|
/**
|
|
48
64
|
* Bash script installing the staged config + context files into the container.
|
|
@@ -143,9 +159,13 @@ export const dockerConnector = {
|
|
|
143
159
|
});
|
|
144
160
|
}
|
|
145
161
|
}
|
|
162
|
+
// Effective model/effort: dispatch-resolved wins, else device default.
|
|
163
|
+
// Pinned on the session so same-container resumes stay consistent.
|
|
164
|
+
const modelOpts = resolveEffectiveModelConfig(profile, params.modelConfig);
|
|
165
|
+
session.modelConfig = modelOpts;
|
|
146
166
|
sm.prepareWorkspace(sessionId, {
|
|
147
167
|
contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
|
|
148
|
-
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
|
|
168
|
+
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost(), modelOpts),
|
|
149
169
|
skillFiles,
|
|
150
170
|
});
|
|
151
171
|
// 5. Copy workspace files from volume mount into place inside container
|
|
@@ -175,7 +195,7 @@ export const dockerConnector = {
|
|
|
175
195
|
const prompt = params.prompt
|
|
176
196
|
? appendSkillPointer(params.prompt, profile, params.skill?.name)
|
|
177
197
|
: buildDefaultPrompt(params.skillName);
|
|
178
|
-
const agentArgs = profile.buildArgs(prompt);
|
|
198
|
+
const agentArgs = profile.buildArgs(prompt, modelOpts);
|
|
179
199
|
const env = {};
|
|
180
200
|
vmLog.magenta("docker-connector", `Spawning ${profile.type} agent in container ${sessionId}`, sessionId);
|
|
181
201
|
const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, env, profile.type);
|
|
@@ -232,12 +252,17 @@ export const dockerConnector = {
|
|
|
232
252
|
vmLog.cyan("docker-connector", `Resuming ${profile.type} in existing container ${session.id}`, session.id);
|
|
233
253
|
// Restart MCP bridge (stopped on previous agent exit)
|
|
234
254
|
await startSessionMcpBridge(session, params);
|
|
255
|
+
// Session continuity: reuse the model/effort the session was
|
|
256
|
+
// provisioned with (config file was generated for it) — a mid-session
|
|
257
|
+
// demand change applies from the next fresh container.
|
|
258
|
+
const modelOpts = session.modelConfig ??
|
|
259
|
+
resolveEffectiveModelConfig(profile, params.modelConfig);
|
|
235
260
|
// Continue the on-disk session for workflow resume in the existing
|
|
236
261
|
// container (full context)
|
|
237
262
|
if (params.workflowContext) {
|
|
238
263
|
const continuePrompt = params.prompt ??
|
|
239
264
|
"Continue your work from where you left off. Do not repeat completed work.";
|
|
240
|
-
const agentArgs = profile.buildContinueArgs(continuePrompt);
|
|
265
|
+
const agentArgs = profile.buildContinueArgs(continuePrompt, modelOpts);
|
|
241
266
|
const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
|
|
242
267
|
// Keep container alive for next idle window cycle
|
|
243
268
|
proxy.once("exit", () => {
|
|
@@ -253,7 +278,7 @@ export const dockerConnector = {
|
|
|
253
278
|
// Board task resume: continue with user's answer (container alive from ask-human)
|
|
254
279
|
const resumePrompt = ctx?.response ?? "Continue your work.";
|
|
255
280
|
await startSessionMcpBridge(session, params);
|
|
256
|
-
const agentArgs = profile.buildContinueArgs(resumePrompt);
|
|
281
|
+
const agentArgs = profile.buildContinueArgs(resumePrompt, modelOpts);
|
|
257
282
|
const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
|
|
258
283
|
return {
|
|
259
284
|
pid: proxy.pid,
|
|
@@ -299,9 +324,13 @@ export const dockerConnector = {
|
|
|
299
324
|
});
|
|
300
325
|
}
|
|
301
326
|
}
|
|
327
|
+
// Fresh container: the current demand applies (dispatch-resolved or
|
|
328
|
+
// device default), pinned for subsequent same-container resumes.
|
|
329
|
+
const modelOpts = resolveEffectiveModelConfig(profile, params.modelConfig);
|
|
330
|
+
session.modelConfig = modelOpts;
|
|
302
331
|
sm.prepareWorkspace(sessionId, {
|
|
303
332
|
contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
|
|
304
|
-
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
|
|
333
|
+
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost(), modelOpts),
|
|
305
334
|
skillFiles,
|
|
306
335
|
});
|
|
307
336
|
await sm.exec(params.taskId, "bash", [
|
|
@@ -331,14 +360,14 @@ export const dockerConnector = {
|
|
|
331
360
|
vmLog.cyan("docker-connector", `Spawning ${profile.type} continue (snapshot restored) in ${sessionId}`, sessionId);
|
|
332
361
|
const continuePrompt = params.prompt ??
|
|
333
362
|
"Continue your work from where you left off. Do not repeat completed work.";
|
|
334
|
-
proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildContinueArgs(continuePrompt), {}, profile.type);
|
|
363
|
+
proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildContinueArgs(continuePrompt, modelOpts), {}, profile.type);
|
|
335
364
|
}
|
|
336
365
|
else {
|
|
337
366
|
vmLog.warn("docker-connector", `Snapshot restore failed — falling back to prompt in ${sessionId}`, sessionId);
|
|
338
367
|
const prompt = params.prompt
|
|
339
368
|
? appendSkillPointer(params.prompt, profile, params.skill?.name)
|
|
340
369
|
: buildDefaultPrompt(params.skillName);
|
|
341
|
-
proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildArgs(prompt), {}, profile.type);
|
|
370
|
+
proxy = await sm.spawnAgent(params.taskId, profile.binary, profile.buildArgs(prompt, modelOpts), {}, profile.type);
|
|
342
371
|
}
|
|
343
372
|
// Workflow: keep container alive for idle window
|
|
344
373
|
proxy.once("exit", () => {
|
|
@@ -16,6 +16,11 @@ export type ResumePayload = {
|
|
|
16
16
|
projectSlug?: string;
|
|
17
17
|
taskBranch?: string;
|
|
18
18
|
repoUrl?: string;
|
|
19
|
+
/** Cloud-resolved model/effort for this resume dispatch. */
|
|
20
|
+
modelConfig?: {
|
|
21
|
+
model?: string;
|
|
22
|
+
effort?: string;
|
|
23
|
+
};
|
|
19
24
|
};
|
|
20
25
|
export type ResumeContext = {
|
|
21
26
|
restClient: ReturnType<typeof createRestClient>;
|
|
@@ -28,6 +28,13 @@ function enqueueWhenNoCapacity(payload, ctx) {
|
|
|
28
28
|
orgId: payload.orgId,
|
|
29
29
|
taskSlug: payload.taskSlug,
|
|
30
30
|
projectSlug: payload.projectSlug,
|
|
31
|
+
skillSlug: payload.skillSlug,
|
|
32
|
+
taskCommand: payload.taskCommand,
|
|
33
|
+
repoUrl: payload.repoUrl,
|
|
34
|
+
taskBranch: payload.taskBranch,
|
|
35
|
+
// Keep the cloud-resolved model/effort — a queued resume must not
|
|
36
|
+
// respawn with the device's local default instead of the demand.
|
|
37
|
+
modelConfig: payload.modelConfig,
|
|
31
38
|
});
|
|
32
39
|
}
|
|
33
40
|
ctx.socketClient.emitAgentStatus({
|
|
@@ -106,6 +113,7 @@ export async function handleResume(payload, ctx) {
|
|
|
106
113
|
skillName: skill?.name ?? resumeSkillName,
|
|
107
114
|
prompt: validatedPayload.taskCommand,
|
|
108
115
|
agentType: ctx.agentType,
|
|
116
|
+
modelConfig: validatedPayload.modelConfig,
|
|
109
117
|
repoUrl: payload.repoUrl,
|
|
110
118
|
taskBranch,
|
|
111
119
|
skill: skill ? { name: skill.name, files: skill.files } : undefined,
|
|
@@ -24,6 +24,14 @@ export type AgentAuthPayload = {
|
|
|
24
24
|
};
|
|
25
25
|
/** Container path where each engine expects its auth.json. */
|
|
26
26
|
export declare const AGENT_AUTH_JSON_PATHS: Record<AuthAgent, string>;
|
|
27
|
+
/**
|
|
28
|
+
* Default provider env var when an api_key payload carries no explicit envVar.
|
|
29
|
+
* (Codex api keys go through `codex login --with-api-key`, never an env var.)
|
|
30
|
+
*/
|
|
31
|
+
export declare const AGENT_API_KEY_DEFAULT_ENV: {
|
|
32
|
+
readonly OPENCODE: "ANTHROPIC_API_KEY";
|
|
33
|
+
readonly KILO: "KILO_API_KEY";
|
|
34
|
+
};
|
|
27
35
|
/** Host path each engine's own CLI stores its auth.json at (for import). */
|
|
28
36
|
export declare function getHostAuthJsonPath(agent: AuthAgent): string;
|
|
29
37
|
export declare function saveAgentAuth(agent: AuthAgent, payload: AgentAuthPayload): Promise<void>;
|
|
@@ -43,6 +43,14 @@ export const AGENT_AUTH_JSON_PATHS = {
|
|
|
43
43
|
OPENCODE: "/home/agent/.local/share/opencode/auth.json",
|
|
44
44
|
KILO: "/home/agent/.local/share/kilo/auth.json",
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Default provider env var when an api_key payload carries no explicit envVar.
|
|
48
|
+
* (Codex api keys go through `codex login --with-api-key`, never an env var.)
|
|
49
|
+
*/
|
|
50
|
+
export const AGENT_API_KEY_DEFAULT_ENV = {
|
|
51
|
+
OPENCODE: "ANTHROPIC_API_KEY",
|
|
52
|
+
KILO: "KILO_API_KEY",
|
|
53
|
+
};
|
|
46
54
|
/** Host path each engine's own CLI stores its auth.json at (for import). */
|
|
47
55
|
export function getHostAuthJsonPath(agent) {
|
|
48
56
|
switch (agent) {
|
|
@@ -39,6 +39,15 @@ export type SessionState = {
|
|
|
39
39
|
* in the meantime.
|
|
40
40
|
*/
|
|
41
41
|
agentType?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Model/effort the container was provisioned with (its config file was
|
|
44
|
+
* generated for them). Same-container resumes reuse it for continuity; a
|
|
45
|
+
* demand change applies from the next fresh container.
|
|
46
|
+
*/
|
|
47
|
+
modelConfig?: {
|
|
48
|
+
model?: string;
|
|
49
|
+
effort?: string;
|
|
50
|
+
};
|
|
42
51
|
/**
|
|
43
52
|
* Git credential for this session's repo, resolved from the host credential
|
|
44
53
|
* helper at clone time. Kept in memory only (never written to disk or
|
|
@@ -24,7 +24,7 @@ import { gitCredentialConfigArgs, gitCredentialSecretEnv, } from "./git-credenti
|
|
|
24
24
|
import { ensureImage, FORGEAI_IMAGE } from "./image-manager.js";
|
|
25
25
|
import { vmLog } from "./log.js";
|
|
26
26
|
import { ensureClaudeToken } from "./claude-token-manager.js";
|
|
27
|
-
import { AGENT_AUTH_JSON_PATHS, loadAgentAuth, } from "./agent-auth-manager.js";
|
|
27
|
+
import { AGENT_API_KEY_DEFAULT_ENV, AGENT_AUTH_JSON_PATHS, loadAgentAuth, } from "./agent-auth-manager.js";
|
|
28
28
|
import { getAgentProfile, resolveAgentType, } from "../orchestrator/agent-profiles.js";
|
|
29
29
|
// ---------------------------------------------------------------------------
|
|
30
30
|
// Paths
|
|
@@ -361,14 +361,16 @@ export class SessionManager {
|
|
|
361
361
|
if (agentType === "OPENCODE") {
|
|
362
362
|
const auth = await this.getAgentAuth("OPENCODE");
|
|
363
363
|
if (auth.kind === "api_key") {
|
|
364
|
-
return {
|
|
364
|
+
return {
|
|
365
|
+
[auth.envVar ?? AGENT_API_KEY_DEFAULT_ENV.OPENCODE]: auth.value,
|
|
366
|
+
};
|
|
365
367
|
}
|
|
366
368
|
}
|
|
367
369
|
if (agentType === "KILO") {
|
|
368
370
|
const auth = await this.getAgentAuth("KILO");
|
|
369
371
|
if (auth.kind === "api_key") {
|
|
370
372
|
// Kilo gateway key (app.kilo.ai profile) — read at request time
|
|
371
|
-
return { [auth.envVar ??
|
|
373
|
+
return { [auth.envVar ?? AGENT_API_KEY_DEFAULT_ENV.KILO]: auth.value };
|
|
372
374
|
}
|
|
373
375
|
}
|
|
374
376
|
return {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@getforgeai/cli",
|
|
3
|
-
"version": "0.1.0-beta.
|
|
3
|
+
"version": "0.1.0-beta.7",
|
|
4
4
|
"description": "ForgeAI CLI — runs AI coding agents in local Docker containers with your own AI tokens, and connects them to the ForgeAI cockpit.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"ora": "^8.2.0",
|
|
48
48
|
"socket.io-client": "^4.8.3",
|
|
49
49
|
"zod": "^4.3.6",
|
|
50
|
-
"@getforgeai/protocol": "0.1.0-beta.
|
|
50
|
+
"@getforgeai/protocol": "0.1.0-beta.5"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^25.0.2",
|