@getforgeai/cli 0.1.0-beta.4 → 0.1.0-beta.6
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 +11 -3
- package/dist/commands/auth-setup-agent.d.ts +1 -0
- package/dist/commands/auth-setup-agent.js +30 -0
- package/dist/commands/config-set.d.ts +6 -0
- package/dist/commands/config-set.js +66 -0
- package/dist/commands/connect.js +5 -5
- package/dist/config/config-manager.d.ts +15 -0
- package/dist/config/config-manager.js +13 -0
- package/dist/lib/connection-manager.js +38 -0
- package/dist/lib/daemon-entry.js +6 -5
- package/dist/lib/rest-client.d.ts +6 -0
- package/dist/orchestrator/agent-profiles.d.ts +30 -3
- package/dist/orchestrator/agent-profiles.js +162 -8
- 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 +40 -9
- 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 +1 -1
- package/dist/vm/agent-auth-manager.js +15 -3
- package/dist/vm/session-manager.d.ts +9 -0
- package/dist/vm/session-manager.js +15 -1
- package/package.json +2 -2
- package/rootfs/Dockerfile +18 -16
package/dist/cli.js
CHANGED
|
@@ -13,14 +13,14 @@ 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";
|
|
20
20
|
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
|
-
import { setupCodexAuthAction, setupOpenCodeAuthAction, } from "./commands/auth-setup-agent.js";
|
|
23
|
+
import { setupCodexAuthAction, setupKiloAuthAction, setupOpenCodeAuthAction, } from "./commands/auth-setup-agent.js";
|
|
24
24
|
const program = new Command();
|
|
25
25
|
program
|
|
26
26
|
.name("forge")
|
|
@@ -55,6 +55,10 @@ authCmd
|
|
|
55
55
|
.command("setup-opencode")
|
|
56
56
|
.description("Store OpenCode credentials (import opencode login or provider API key)")
|
|
57
57
|
.action(() => setupOpenCodeAuthAction());
|
|
58
|
+
authCmd
|
|
59
|
+
.command("setup-kilo")
|
|
60
|
+
.description("Store Kilo credentials (import kilo login or gateway API key)")
|
|
61
|
+
.action(() => setupKiloAuthAction());
|
|
58
62
|
program
|
|
59
63
|
.command("connect")
|
|
60
64
|
.description("Connect to ForgeAI Cloud (spawns background daemon by default)")
|
|
@@ -123,8 +127,12 @@ const configSetCmd = configCmd
|
|
|
123
127
|
.description("Set a configuration value");
|
|
124
128
|
configSetCmd
|
|
125
129
|
.command("agent-type <value>")
|
|
126
|
-
.description("Set agent type (CLAUDE_CODE, CODEX or
|
|
130
|
+
.description("Set agent type (CLAUDE_CODE, CODEX, OPENCODE or KILO)")
|
|
127
131
|
.action((value) => configSetAgentTypeAction(value));
|
|
132
|
+
configSetCmd
|
|
133
|
+
.command("models <engine> <models>")
|
|
134
|
+
.description("Declare the models usable on this device for an engine, comma-separated (first = default); 'none' clears")
|
|
135
|
+
.action((engine, models) => configSetModelsAction(engine, models));
|
|
128
136
|
configSetCmd
|
|
129
137
|
.command("agent-slots <value>")
|
|
130
138
|
.description("Set agent slots (1-20)")
|
|
@@ -11,5 +11,6 @@
|
|
|
11
11
|
* encrypted file elsewhere) and injected into agent containers at spawn.
|
|
12
12
|
*/
|
|
13
13
|
export declare function setupCodexAuthAction(): Promise<void>;
|
|
14
|
+
export declare function setupKiloAuthAction(): Promise<void>;
|
|
14
15
|
export declare function setupOpenCodeAuthAction(): Promise<void>;
|
|
15
16
|
//# sourceMappingURL=auth-setup-agent.d.ts.map
|
|
@@ -59,6 +59,36 @@ export async function setupCodexAuthAction() {
|
|
|
59
59
|
process.exit(1);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
|
+
export async function setupKiloAuthAction() {
|
|
63
|
+
try {
|
|
64
|
+
const imported = await tryImportHostAuthJson("KILO");
|
|
65
|
+
if (!imported) {
|
|
66
|
+
console.log(chalk.dim("No Kilo login found to import. You can paste a Kilo gateway API key instead\n" +
|
|
67
|
+
"(from your profile at app.kilo.ai — or run `kilo auth login` first, then\n" +
|
|
68
|
+
"re-run this command to import it)."));
|
|
69
|
+
const key = await ask("Kilo API key: ");
|
|
70
|
+
if (!key) {
|
|
71
|
+
console.error(chalk.red("No API key provided."));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
await saveAgentAuth("KILO", {
|
|
75
|
+
kind: "api_key",
|
|
76
|
+
envVar: "KILO_API_KEY",
|
|
77
|
+
value: key,
|
|
78
|
+
});
|
|
79
|
+
console.log(chalk.green("Kilo API key stored. Agents can now authenticate."));
|
|
80
|
+
}
|
|
81
|
+
const model = await ask("Kilo model (e.g. kilo/anthropic/claude-sonnet-4.5) — leave empty for Kilo's default: ");
|
|
82
|
+
if (model) {
|
|
83
|
+
setConfig({ kiloModel: model });
|
|
84
|
+
console.log(chalk.green(`Kilo model set to ${model}.`));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
catch (err) {
|
|
88
|
+
console.error(chalk.red(`Failed: ${err instanceof Error ? err.message : String(err)}`));
|
|
89
|
+
process.exit(1);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
62
92
|
const OPENCODE_PROVIDER_ENV_VARS = {
|
|
63
93
|
anthropic: "ANTHROPIC_API_KEY",
|
|
64
94
|
openai: "OPENAI_API_KEY",
|
|
@@ -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(engine: string, value: 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;
|
|
@@ -7,6 +7,7 @@ const VALID_OPENERS = ["terminal", "vscode", "finder"];
|
|
|
7
7
|
const AGENT_AUTH_SETUP_HINTS = {
|
|
8
8
|
CODEX: "forge auth setup-codex",
|
|
9
9
|
OPENCODE: "forge auth setup-opencode",
|
|
10
|
+
KILO: "forge auth setup-kilo",
|
|
10
11
|
};
|
|
11
12
|
export async function configSetAgentTypeAction(value) {
|
|
12
13
|
const upper = value.toUpperCase();
|
|
@@ -23,6 +24,57 @@ export async function configSetAgentTypeAction(value) {
|
|
|
23
24
|
console.log(chalk.dim(`Make sure credentials are configured: ${hint}`));
|
|
24
25
|
}
|
|
25
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(engine, value) {
|
|
33
|
+
const upper = engine.toUpperCase();
|
|
34
|
+
if (!isAgentType(upper)) {
|
|
35
|
+
console.error(chalk.red(`Invalid engine "${engine}". Allowed: ${AGENT_TYPE.join(", ")}`));
|
|
36
|
+
process.exitCode = 1;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
const config = getConfig();
|
|
40
|
+
const lockedByOrgs = config.modelsLockedByOrgs ?? [];
|
|
41
|
+
if (lockedByOrgs.length > 0) {
|
|
42
|
+
console.error(chalk.red(`The model list is locked by organization(s): ${lockedByOrgs.join(", ")} — edit it from their cockpit (Settings → CLI Devices).`));
|
|
43
|
+
process.exitCode = 1;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
const models = value.trim().toLowerCase() === "none"
|
|
47
|
+
? []
|
|
48
|
+
: value
|
|
49
|
+
.split(",")
|
|
50
|
+
.map((m) => m.trim())
|
|
51
|
+
.filter(Boolean);
|
|
52
|
+
// Mirror the server-side AvailableModelsSchema bounds so an invalid list
|
|
53
|
+
// fails HERE instead of turning every register POST/PATCH into a 400.
|
|
54
|
+
if (models.length > 20) {
|
|
55
|
+
console.error(chalk.red("Too many models — 20 maximum per engine."));
|
|
56
|
+
process.exitCode = 1;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
const tooLong = models.find((m) => m.length > 120);
|
|
60
|
+
if (tooLong) {
|
|
61
|
+
console.error(chalk.red(`Model id too long (120 chars max): "${tooLong.slice(0, 40)}..."`));
|
|
62
|
+
process.exitCode = 1;
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const availableModels = { ...(config.availableModels ?? {}) };
|
|
66
|
+
if (models.length === 0) {
|
|
67
|
+
delete availableModels[upper];
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
availableModels[upper] = models;
|
|
71
|
+
}
|
|
72
|
+
setConfig({ availableModels });
|
|
73
|
+
const applied = await tryReRegister();
|
|
74
|
+
console.log(chalk.green(models.length === 0
|
|
75
|
+
? `Model list cleared for ${upper}. ${applied ? "(Applied immediately)" : "(Will apply on next connect)"}`
|
|
76
|
+
: `Models for ${upper}: ${models.join(", ")} (default: ${models[0]}). ${applied ? "(Applied immediately)" : "(Will apply on next connect)"}`));
|
|
77
|
+
}
|
|
26
78
|
export async function configSetAgentSlotsAction(value) {
|
|
27
79
|
const num = Number.parseInt(value, 10);
|
|
28
80
|
if (Number.isNaN(num) || num < 1 || num > 20) {
|
|
@@ -51,6 +103,20 @@ export function configGetAction() {
|
|
|
51
103
|
if (config.agentType === "OPENCODE") {
|
|
52
104
|
console.log(` OpenCode model: ${config.opencodeModel ?? "(OpenCode default)"}`);
|
|
53
105
|
}
|
|
106
|
+
if (config.agentType === "KILO") {
|
|
107
|
+
console.log(` Kilo model: ${config.kiloModel ?? "(Kilo default)"}`);
|
|
108
|
+
}
|
|
109
|
+
const declared = config.availableModels ?? {};
|
|
110
|
+
const declaredEntries = Object.entries(declared).filter(([, models]) => models.length > 0);
|
|
111
|
+
if (declaredEntries.length > 0) {
|
|
112
|
+
const lockNote = (config.modelsLockedByOrgs ?? []).length > 0
|
|
113
|
+
? ` (locked by: ${(config.modelsLockedByOrgs ?? []).join(", ")})`
|
|
114
|
+
: "";
|
|
115
|
+
console.log(` Declared models:${lockNote}`);
|
|
116
|
+
for (const [engine, models] of declaredEntries) {
|
|
117
|
+
console.log(` ${engine}: ${models.join(", ")}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
54
120
|
console.log(` Agent slots: ${config.agentSlots ?? 5}`);
|
|
55
121
|
console.log(` Opener: ${config.opener ?? "terminal"}`);
|
|
56
122
|
console.log(` Server URL: ${config.serverUrl}`);
|
package/dist/commands/connect.js
CHANGED
|
@@ -123,14 +123,14 @@ async function connectForeground(options) {
|
|
|
123
123
|
if (!ctx)
|
|
124
124
|
return;
|
|
125
125
|
// Ensure agent credentials are available for container authentication
|
|
126
|
-
if (ctx.agentType === "CODEX" ||
|
|
126
|
+
if (ctx.agentType === "CODEX" ||
|
|
127
|
+
ctx.agentType === "OPENCODE" ||
|
|
128
|
+
ctx.agentType === "KILO") {
|
|
127
129
|
try {
|
|
128
130
|
const { hasAgentAuth } = await import("../vm/agent-auth-manager.js");
|
|
129
131
|
if (!(await hasAgentAuth(ctx.agentType))) {
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
: "forge auth setup-opencode";
|
|
133
|
-
console.warn(chalk.yellow(`[connect] No ${ctx.agentType} credentials stored. Agents may fail to authenticate. Run: ${setupCmd}`));
|
|
132
|
+
const { getAgentProfile } = await import("../orchestrator/agent-profiles.js");
|
|
133
|
+
console.warn(chalk.yellow(`[connect] No ${ctx.agentType} credentials stored. Agents may fail to authenticate. Run: ${getAgentProfile(ctx.agentType).authSetupHint}`));
|
|
134
134
|
}
|
|
135
135
|
}
|
|
136
136
|
catch {
|
|
@@ -13,6 +13,21 @@ export type CliConfig = {
|
|
|
13
13
|
agentType?: string;
|
|
14
14
|
/** OpenCode model in "provider/model" form (used when agentType is OPENCODE). */
|
|
15
15
|
opencodeModel?: string;
|
|
16
|
+
/** Kilo model, e.g. "kilo/anthropic/claude-sonnet-4.5" (used when agentType is KILO). */
|
|
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[];
|
|
16
31
|
agentSlots?: number;
|
|
17
32
|
opener?: Opener;
|
|
18
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
|
}
|
|
@@ -235,6 +235,23 @@ export function establishConnections(ctx, append = false) {
|
|
|
235
235
|
console.log(chalk.green(`${prefix(org.orgSlug)}Agent type updated by server: ${previousType} → ${payload.agentType}`));
|
|
236
236
|
}
|
|
237
237
|
}
|
|
238
|
+
// Server-pushed model governance (cockpit list edits + lock). The lock
|
|
239
|
+
// is tracked per org; the local availableModels stays the owner's
|
|
240
|
+
// declaration (a locked org enforces its own list server-side).
|
|
241
|
+
if (payload.modelsLocked !== undefined) {
|
|
242
|
+
const lockedByOrgs = new Set(getConfig().modelsLockedByOrgs ?? []);
|
|
243
|
+
if (payload.modelsLocked) {
|
|
244
|
+
lockedByOrgs.add(org.orgSlug);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
lockedByOrgs.delete(org.orgSlug);
|
|
248
|
+
}
|
|
249
|
+
setConfig({ modelsLockedByOrgs: Array.from(lockedByOrgs) });
|
|
250
|
+
}
|
|
251
|
+
if (payload.availableModels !== undefined ||
|
|
252
|
+
payload.modelsLocked !== undefined) {
|
|
253
|
+
console.log(chalk.green(`${prefix(org.orgSlug)}Model governance updated by this organization${payload.modelsLocked ? " (list locked — its cockpit owns it there)" : ""}`));
|
|
254
|
+
}
|
|
238
255
|
if (payload.agentSlots != null && payload.agentSlots !== ctx.agentSlots) {
|
|
239
256
|
const previous = ctx.agentSlots;
|
|
240
257
|
ctx.agentSlots = payload.agentSlots;
|
|
@@ -281,8 +298,29 @@ export function establishConnections(ctx, append = false) {
|
|
|
281
298
|
name: ctx.cliName,
|
|
282
299
|
agentType: ctx.agentType,
|
|
283
300
|
agentSlots: ctx.agentSlots,
|
|
301
|
+
availableModels: getConfig().availableModels,
|
|
284
302
|
});
|
|
285
303
|
console.log(chalk.green(`${prefix(org.orgSlug)}CLI registered as "${ctx.cliName}"`));
|
|
304
|
+
// Lock state is tracked PER ORG (one machine can serve several orgs;
|
|
305
|
+
// org B must not clear org A's lock, and org A's restricted list must
|
|
306
|
+
// not overwrite the owner's local declaration — enforcement is
|
|
307
|
+
// server-side per org: a locked org ignores CLI-sent lists anyway).
|
|
308
|
+
{
|
|
309
|
+
const lockedByOrgs = new Set(getConfig().modelsLockedByOrgs ?? []);
|
|
310
|
+
const wasLocked = lockedByOrgs.has(org.orgSlug);
|
|
311
|
+
if (device.modelsLocked) {
|
|
312
|
+
lockedByOrgs.add(org.orgSlug);
|
|
313
|
+
}
|
|
314
|
+
else {
|
|
315
|
+
lockedByOrgs.delete(org.orgSlug);
|
|
316
|
+
}
|
|
317
|
+
if (wasLocked !== Boolean(device.modelsLocked)) {
|
|
318
|
+
setConfig({ modelsLockedByOrgs: Array.from(lockedByOrgs) });
|
|
319
|
+
if (device.modelsLocked) {
|
|
320
|
+
console.log(chalk.dim(`${prefix(org.orgSlug)}Model list is locked by this organization — its cockpit owns the list there`));
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
}
|
|
286
324
|
// The server is source of truth for agentType after first registration
|
|
287
325
|
// (a cockpit edit made while this CLI was offline must survive the
|
|
288
326
|
// reconnect). Adopt the returned value.
|
package/dist/lib/daemon-entry.js
CHANGED
|
@@ -18,14 +18,14 @@ if (!ctx) {
|
|
|
18
18
|
}
|
|
19
19
|
// Check agent credential availability for the configured engine
|
|
20
20
|
try {
|
|
21
|
-
if (ctx.agentType === "CODEX" ||
|
|
21
|
+
if (ctx.agentType === "CODEX" ||
|
|
22
|
+
ctx.agentType === "OPENCODE" ||
|
|
23
|
+
ctx.agentType === "KILO") {
|
|
22
24
|
const { hasAgentAuth } = await import("../vm/agent-auth-manager.js");
|
|
23
25
|
if (!(await hasAgentAuth(ctx.agentType))) {
|
|
24
|
-
const
|
|
25
|
-
? "forge auth setup-codex"
|
|
26
|
-
: "forge auth setup-opencode";
|
|
26
|
+
const { getAgentProfile } = await import("../orchestrator/agent-profiles.js");
|
|
27
27
|
console.warn(chalk.yellow(`[daemon] No ${ctx.agentType} credentials stored — agents will fail to authenticate.`));
|
|
28
|
-
console.warn(chalk.yellow(`[daemon] Run: ${
|
|
28
|
+
console.warn(chalk.yellow(`[daemon] Run: ${getAgentProfile(ctx.agentType).authSetupHint}`));
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
31
|
else {
|
|
@@ -179,6 +179,7 @@ async function handleReRegisterCommand() {
|
|
|
179
179
|
name: ctx.cliName,
|
|
180
180
|
agentType: ctx.agentType,
|
|
181
181
|
agentSlots: ctx.agentSlots,
|
|
182
|
+
availableModels: (await import("../config/config-manager.js")).getConfig().availableModels,
|
|
182
183
|
});
|
|
183
184
|
}
|
|
184
185
|
catch {
|
|
@@ -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;
|
|
@@ -9,6 +9,10 @@
|
|
|
9
9
|
* | CLAUDE_CODE | claude | /session/workspace/.claude/mcp_config.json (via --mcp-config) | --continue |
|
|
10
10
|
* | CODEX | codex | /home/agent/.codex/config.toml | exec resume --last |
|
|
11
11
|
* | OPENCODE | opencode | /home/agent/.config/opencode/opencode.json | run --continue |
|
|
12
|
+
* | KILO | kilo | /home/agent/.config/kilo/kilo.json | run --continue |
|
|
13
|
+
*
|
|
14
|
+
* KILO (kilo.ai) is an OpenCode fork: same config shape, same JSONL event
|
|
15
|
+
* stream (so it shares OpenCodeJsonlExtractor), Kilo gateway auth on top.
|
|
12
16
|
*/
|
|
13
17
|
import type { AgentType } from "@getforgeai/protocol";
|
|
14
18
|
import type { AgentStreamExtractor, QuestionData, ToolActivityData, TokenUsageData } from "./stream-json-extractor.js";
|
|
@@ -18,9 +22,32 @@ export type McpBridgeConfig = {
|
|
|
18
22
|
token: string;
|
|
19
23
|
};
|
|
20
24
|
export type AgentConfigOptions = {
|
|
21
|
-
/**
|
|
25
|
+
/**
|
|
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").
|
|
31
|
+
*/
|
|
22
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;
|
|
23
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;
|
|
24
51
|
export type AgentAuthErrorPatterns = {
|
|
25
52
|
/** Substrings matched against stderr only (avoids stdout false positives). */
|
|
26
53
|
stderr: string[];
|
|
@@ -40,9 +67,9 @@ export type AgentProfile = {
|
|
|
40
67
|
/** Build the agent config file content (JSON or TOML depending on engine). */
|
|
41
68
|
buildConfig(mcp: McpBridgeConfig, opts?: AgentConfigOptions): string;
|
|
42
69
|
/** argv for a fresh non-interactive run. */
|
|
43
|
-
buildArgs(prompt: string): string[];
|
|
70
|
+
buildArgs(prompt: string, opts?: AgentConfigOptions): string[];
|
|
44
71
|
/** argv to continue the latest on-disk session in the same home dir. */
|
|
45
|
-
buildContinueArgs(prompt: string): string[];
|
|
72
|
+
buildContinueArgs(prompt: string, opts?: AgentConfigOptions): string[];
|
|
46
73
|
/** Non-secret env vars added to every agent spawn. */
|
|
47
74
|
spawnEnv: Record<string, string>;
|
|
48
75
|
/**
|
|
@@ -9,11 +9,46 @@
|
|
|
9
9
|
* | CLAUDE_CODE | claude | /session/workspace/.claude/mcp_config.json (via --mcp-config) | --continue |
|
|
10
10
|
* | CODEX | codex | /home/agent/.codex/config.toml | exec resume --last |
|
|
11
11
|
* | OPENCODE | opencode | /home/agent/.config/opencode/opencode.json | run --continue |
|
|
12
|
+
* | KILO | kilo | /home/agent/.config/kilo/kilo.json | run --continue |
|
|
13
|
+
*
|
|
14
|
+
* KILO (kilo.ai) is an OpenCode fork: same config shape, same JSONL event
|
|
15
|
+
* stream (so it shares OpenCodeJsonlExtractor), Kilo gateway auth on top.
|
|
12
16
|
*/
|
|
13
17
|
import { DEFAULT_AGENT_TYPE, isAgentType } from "@getforgeai/protocol";
|
|
14
18
|
import { CodexJsonlExtractor } from "./codex-jsonl-extractor.js";
|
|
15
19
|
import { OpenCodeJsonlExtractor } from "./opencode-jsonl-extractor.js";
|
|
16
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
|
+
}
|
|
17
52
|
/** Narrow an arbitrary config/dispatch string to a supported AgentType. */
|
|
18
53
|
export function resolveAgentType(value) {
|
|
19
54
|
if (value && isAgentType(value))
|
|
@@ -56,11 +91,14 @@ const claudeProfile = {
|
|
|
56
91
|
},
|
|
57
92
|
}, null, 2);
|
|
58
93
|
},
|
|
59
|
-
buildArgs(prompt) {
|
|
94
|
+
buildArgs(prompt, opts) {
|
|
60
95
|
return [
|
|
61
96
|
"--dangerously-skip-permissions",
|
|
62
97
|
"--mcp-config",
|
|
63
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] : []),
|
|
64
102
|
"-p",
|
|
65
103
|
prompt,
|
|
66
104
|
"--output-format",
|
|
@@ -69,8 +107,8 @@ const claudeProfile = {
|
|
|
69
107
|
"--include-partial-messages",
|
|
70
108
|
];
|
|
71
109
|
},
|
|
72
|
-
buildContinueArgs(prompt) {
|
|
73
|
-
return ["--continue", ...this.buildArgs(prompt)];
|
|
110
|
+
buildContinueArgs(prompt, opts) {
|
|
111
|
+
return ["--continue", ...this.buildArgs(prompt, opts)];
|
|
74
112
|
},
|
|
75
113
|
spawnEnv: {},
|
|
76
114
|
nativeSkillDiscovery: true,
|
|
@@ -106,12 +144,17 @@ const codexProfile = {
|
|
|
106
144
|
type: "CODEX",
|
|
107
145
|
binary: "codex",
|
|
108
146
|
configTargetPath: "/home/agent/.codex/config.toml",
|
|
109
|
-
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);
|
|
110
151
|
return [
|
|
111
152
|
"# Generated by ForgeAI — do not edit",
|
|
112
153
|
"# The Docker container is the security boundary; Codex's own sandbox is disabled.",
|
|
113
154
|
'approval_policy = "never"',
|
|
114
155
|
'sandbox_mode = "danger-full-access"',
|
|
156
|
+
...(opts?.model ? [`model = ${tomlString(opts.model)}`] : []),
|
|
157
|
+
...(effort ? [`model_reasoning_effort = ${tomlString(effort)}`] : []),
|
|
115
158
|
"",
|
|
116
159
|
"[mcp_servers.forgeai]",
|
|
117
160
|
'command = "node"',
|
|
@@ -200,11 +243,26 @@ const openCodeProfile = {
|
|
|
200
243
|
},
|
|
201
244
|
}, null, 2);
|
|
202
245
|
},
|
|
203
|
-
buildArgs(prompt) {
|
|
204
|
-
|
|
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
|
+
];
|
|
205
255
|
},
|
|
206
|
-
buildContinueArgs(prompt) {
|
|
207
|
-
|
|
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
|
+
];
|
|
208
266
|
},
|
|
209
267
|
spawnEnv: { OPENCODE_PERMISSION: OPENCODE_PERMISSION_JSON },
|
|
210
268
|
// OpenCode reads .claude/skills natively (OPENCODE_DISABLE_CLAUDE_CODE_SKILLS opts out)
|
|
@@ -224,12 +282,105 @@ const openCodeProfile = {
|
|
|
224
282
|
authSetupHint: "forge auth setup-opencode",
|
|
225
283
|
};
|
|
226
284
|
// ---------------------------------------------------------------------------
|
|
285
|
+
// Kilo (kilo.ai — OpenCode fork with the Kilo gateway on top)
|
|
286
|
+
// ---------------------------------------------------------------------------
|
|
287
|
+
/**
|
|
288
|
+
* Permission override merged over any config (KILO_PERMISSION env is merged
|
|
289
|
+
* over file config), so a repo-level kilo.json can never re-introduce "ask"
|
|
290
|
+
* prompts. Belt to the --auto flag's suspenders.
|
|
291
|
+
*/
|
|
292
|
+
const KILO_PERMISSION_JSON = JSON.stringify({
|
|
293
|
+
"*": "allow",
|
|
294
|
+
external_directory: { "**": "allow" },
|
|
295
|
+
});
|
|
296
|
+
const kiloProfile = {
|
|
297
|
+
type: "KILO",
|
|
298
|
+
binary: "kilo",
|
|
299
|
+
configTargetPath: "/home/agent/.config/kilo/kilo.json",
|
|
300
|
+
buildConfig(mcp, opts) {
|
|
301
|
+
return JSON.stringify({
|
|
302
|
+
$schema: "https://app.kilo.ai/config.json",
|
|
303
|
+
...(opts?.model ? { model: opts.model } : {}),
|
|
304
|
+
permission: {
|
|
305
|
+
"*": "allow",
|
|
306
|
+
external_directory: { "**": "allow" },
|
|
307
|
+
},
|
|
308
|
+
// Headless container hygiene: no auto-upgrade, no product telemetry
|
|
309
|
+
autoupdate: false,
|
|
310
|
+
experimental: { openTelemetry: false },
|
|
311
|
+
mcp: {
|
|
312
|
+
forgeai: {
|
|
313
|
+
type: "local",
|
|
314
|
+
command: ["node", "/session/forge-mcp-relay.mjs"],
|
|
315
|
+
environment: {
|
|
316
|
+
FORGEAI_MCP_HOST: mcp.host,
|
|
317
|
+
FORGEAI_MCP_PORT: String(mcp.port),
|
|
318
|
+
FORGEAI_MCP_TOKEN: mcp.token,
|
|
319
|
+
},
|
|
320
|
+
enabled: true,
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
}, null, 2);
|
|
324
|
+
},
|
|
325
|
+
buildArgs(prompt, opts) {
|
|
326
|
+
// --auto: auto-approve permissions (headless runs REJECT asks otherwise).
|
|
327
|
+
// Spawn must close stdin: with a non-TTY open stdin, `kilo run` blocks
|
|
328
|
+
// reading it to EOF before sending the prompt (Bun.stdin.text()).
|
|
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
|
+
];
|
|
338
|
+
},
|
|
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
|
+
];
|
|
350
|
+
},
|
|
351
|
+
spawnEnv: {
|
|
352
|
+
KILO_PERMISSION: KILO_PERMISSION_JSON,
|
|
353
|
+
KILO_DISABLE_AUTOUPDATE: "1",
|
|
354
|
+
KILO_TELEMETRY_LEVEL: "off",
|
|
355
|
+
},
|
|
356
|
+
// Kilo scans .claude/skills/**/SKILL.md natively (OpenCode heritage)
|
|
357
|
+
nativeSkillDiscovery: true,
|
|
358
|
+
// Sessions live in SQLite (kilo.db + -wal/-shm) keyed to the project dir
|
|
359
|
+
stateDir: "/home/agent/.local/share/kilo",
|
|
360
|
+
snapshotExcludes: ["auth.json", "mcp-auth.json", "log", "repos", "bin"],
|
|
361
|
+
authErrorPatterns: {
|
|
362
|
+
// Kilo surfaces auth failures as error events from the gateway/provider —
|
|
363
|
+
// it never prints "Not logged in" on the run path, and env-var names in
|
|
364
|
+
// the stdout tail would be tool-output false positives (ForgeAI itself
|
|
365
|
+
// injects KILO_API_KEY). Keep only API-error-shaped tokens.
|
|
366
|
+
stderr: [],
|
|
367
|
+
combined: [
|
|
368
|
+
'"authentication_error"',
|
|
369
|
+
"invalid_api_key",
|
|
370
|
+
"Incorrect API key",
|
|
371
|
+
"AuthError",
|
|
372
|
+
],
|
|
373
|
+
},
|
|
374
|
+
authSetupHint: "forge auth setup-kilo",
|
|
375
|
+
};
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
227
377
|
// Registry
|
|
228
378
|
// ---------------------------------------------------------------------------
|
|
229
379
|
const PROFILES = {
|
|
230
380
|
CLAUDE_CODE: claudeProfile,
|
|
231
381
|
CODEX: codexProfile,
|
|
232
382
|
OPENCODE: openCodeProfile,
|
|
383
|
+
KILO: kiloProfile,
|
|
233
384
|
};
|
|
234
385
|
export function getAgentProfile(agentType) {
|
|
235
386
|
return PROFILES[resolveAgentType(agentType)];
|
|
@@ -245,6 +396,9 @@ export function createStreamExtractor(agentType, onText, onQuestion, onToolActiv
|
|
|
245
396
|
case "CODEX":
|
|
246
397
|
return new CodexJsonlExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
|
|
247
398
|
case "OPENCODE":
|
|
399
|
+
// Kilo is an OpenCode fork emitting the same JSONL event stream
|
|
400
|
+
// (text/tool_use/step_start/step_finish/error with sessionID + part).
|
|
401
|
+
case "KILO":
|
|
248
402
|
return new OpenCodeJsonlExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
|
|
249
403
|
default:
|
|
250
404
|
return new StreamJsonExtractor(onText, onQuestion, onToolActivity, onTokenUsage, sessionId);
|
|
@@ -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,8 +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
|
-
|
|
43
|
-
|
|
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) {
|
|
48
|
+
const cliConfig = getConfig();
|
|
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);
|
|
44
62
|
}
|
|
45
63
|
/**
|
|
46
64
|
* Bash script installing the staged config + context files into the container.
|
|
@@ -141,9 +159,13 @@ export const dockerConnector = {
|
|
|
141
159
|
});
|
|
142
160
|
}
|
|
143
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;
|
|
144
166
|
sm.prepareWorkspace(sessionId, {
|
|
145
167
|
contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
|
|
146
|
-
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
|
|
168
|
+
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost(), modelOpts),
|
|
147
169
|
skillFiles,
|
|
148
170
|
});
|
|
149
171
|
// 5. Copy workspace files from volume mount into place inside container
|
|
@@ -173,7 +195,7 @@ export const dockerConnector = {
|
|
|
173
195
|
const prompt = params.prompt
|
|
174
196
|
? appendSkillPointer(params.prompt, profile, params.skill?.name)
|
|
175
197
|
: buildDefaultPrompt(params.skillName);
|
|
176
|
-
const agentArgs = profile.buildArgs(prompt);
|
|
198
|
+
const agentArgs = profile.buildArgs(prompt, modelOpts);
|
|
177
199
|
const env = {};
|
|
178
200
|
vmLog.magenta("docker-connector", `Spawning ${profile.type} agent in container ${sessionId}`, sessionId);
|
|
179
201
|
const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, env, profile.type);
|
|
@@ -230,12 +252,17 @@ export const dockerConnector = {
|
|
|
230
252
|
vmLog.cyan("docker-connector", `Resuming ${profile.type} in existing container ${session.id}`, session.id);
|
|
231
253
|
// Restart MCP bridge (stopped on previous agent exit)
|
|
232
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);
|
|
233
260
|
// Continue the on-disk session for workflow resume in the existing
|
|
234
261
|
// container (full context)
|
|
235
262
|
if (params.workflowContext) {
|
|
236
263
|
const continuePrompt = params.prompt ??
|
|
237
264
|
"Continue your work from where you left off. Do not repeat completed work.";
|
|
238
|
-
const agentArgs = profile.buildContinueArgs(continuePrompt);
|
|
265
|
+
const agentArgs = profile.buildContinueArgs(continuePrompt, modelOpts);
|
|
239
266
|
const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
|
|
240
267
|
// Keep container alive for next idle window cycle
|
|
241
268
|
proxy.once("exit", () => {
|
|
@@ -251,7 +278,7 @@ export const dockerConnector = {
|
|
|
251
278
|
// Board task resume: continue with user's answer (container alive from ask-human)
|
|
252
279
|
const resumePrompt = ctx?.response ?? "Continue your work.";
|
|
253
280
|
await startSessionMcpBridge(session, params);
|
|
254
|
-
const agentArgs = profile.buildContinueArgs(resumePrompt);
|
|
281
|
+
const agentArgs = profile.buildContinueArgs(resumePrompt, modelOpts);
|
|
255
282
|
const proxy = await sm.spawnAgent(params.taskId, profile.binary, agentArgs, {}, profile.type);
|
|
256
283
|
return {
|
|
257
284
|
pid: proxy.pid,
|
|
@@ -297,9 +324,13 @@ export const dockerConnector = {
|
|
|
297
324
|
});
|
|
298
325
|
}
|
|
299
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;
|
|
300
331
|
sm.prepareWorkspace(sessionId, {
|
|
301
332
|
contextJson: JSON.stringify(params.taskContext ?? {}, null, 2),
|
|
302
|
-
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost()),
|
|
333
|
+
mcpConfigJson: buildAgentConfig(profile, session.sessionIndex, session.mcpSecret, await resolveMcpRelayHost(), modelOpts),
|
|
303
334
|
skillFiles,
|
|
304
335
|
});
|
|
305
336
|
await sm.exec(params.taskId, "bash", [
|
|
@@ -329,14 +360,14 @@ export const dockerConnector = {
|
|
|
329
360
|
vmLog.cyan("docker-connector", `Spawning ${profile.type} continue (snapshot restored) in ${sessionId}`, sessionId);
|
|
330
361
|
const continuePrompt = params.prompt ??
|
|
331
362
|
"Continue your work from where you left off. Do not repeat completed work.";
|
|
332
|
-
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);
|
|
333
364
|
}
|
|
334
365
|
else {
|
|
335
366
|
vmLog.warn("docker-connector", `Snapshot restore failed — falling back to prompt in ${sessionId}`, sessionId);
|
|
336
367
|
const prompt = params.prompt
|
|
337
368
|
? appendSkillPointer(params.prompt, profile, params.skill?.name)
|
|
338
369
|
: buildDefaultPrompt(params.skillName);
|
|
339
|
-
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);
|
|
340
371
|
}
|
|
341
372
|
// Workflow: keep container alive for idle window
|
|
342
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,
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
* Storage: macOS Keychain / encrypted file via secure-store.ts. The payload
|
|
14
14
|
* is stored as a JSON string.
|
|
15
15
|
*/
|
|
16
|
-
export type AuthAgent = "CODEX" | "OPENCODE";
|
|
16
|
+
export type AuthAgent = "CODEX" | "OPENCODE" | "KILO";
|
|
17
17
|
export type AgentAuthPayload = {
|
|
18
18
|
kind: "api_key";
|
|
19
19
|
value: string;
|
|
@@ -30,17 +30,29 @@ const STORE_LOCATIONS = {
|
|
|
30
30
|
fileName: "opencode-auth.json",
|
|
31
31
|
fileSalt: "forgeai-opencode-auth-salt",
|
|
32
32
|
},
|
|
33
|
+
KILO: {
|
|
34
|
+
keychainService: "ForgeAI-kilo-auth",
|
|
35
|
+
keychainAccount: "kilo-auth",
|
|
36
|
+
fileName: "kilo-auth.json",
|
|
37
|
+
fileSalt: "forgeai-kilo-auth-salt",
|
|
38
|
+
},
|
|
33
39
|
};
|
|
34
40
|
/** Container path where each engine expects its auth.json. */
|
|
35
41
|
export const AGENT_AUTH_JSON_PATHS = {
|
|
36
42
|
CODEX: "/home/agent/.codex/auth.json",
|
|
37
43
|
OPENCODE: "/home/agent/.local/share/opencode/auth.json",
|
|
44
|
+
KILO: "/home/agent/.local/share/kilo/auth.json",
|
|
38
45
|
};
|
|
39
46
|
/** Host path each engine's own CLI stores its auth.json at (for import). */
|
|
40
47
|
export function getHostAuthJsonPath(agent) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
48
|
+
switch (agent) {
|
|
49
|
+
case "CODEX":
|
|
50
|
+
return join(homedir(), ".codex", "auth.json");
|
|
51
|
+
case "OPENCODE":
|
|
52
|
+
return join(homedir(), ".local", "share", "opencode", "auth.json");
|
|
53
|
+
case "KILO":
|
|
54
|
+
return join(homedir(), ".local", "share", "kilo", "auth.json");
|
|
55
|
+
}
|
|
44
56
|
}
|
|
45
57
|
export async function saveAgentAuth(agent, payload) {
|
|
46
58
|
await saveSecret(STORE_LOCATIONS[agent], JSON.stringify(payload));
|
|
@@ -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
|
|
@@ -318,6 +318,11 @@ export class SessionManager {
|
|
|
318
318
|
secretEnv: await this.getAgentSecretEnv(resolveAgentType(agentType)),
|
|
319
319
|
user: "agent",
|
|
320
320
|
});
|
|
321
|
+
// Agents run one-shot and never receive stdin input. Close it so engines
|
|
322
|
+
// that read piped stdin to EOF before acting (OpenCode and Kilo both
|
|
323
|
+
// concatenate non-TTY stdin into the prompt via Bun.stdin.text()) don't
|
|
324
|
+
// block forever on the open docker-exec pipe.
|
|
325
|
+
dockerExecProcess.stdin?.end();
|
|
321
326
|
// Create a kill function that removes the container when the process exits
|
|
322
327
|
const killFn = async (signal) => {
|
|
323
328
|
try {
|
|
@@ -359,6 +364,13 @@ export class SessionManager {
|
|
|
359
364
|
return { [auth.envVar ?? "ANTHROPIC_API_KEY"]: auth.value };
|
|
360
365
|
}
|
|
361
366
|
}
|
|
367
|
+
if (agentType === "KILO") {
|
|
368
|
+
const auth = await this.getAgentAuth("KILO");
|
|
369
|
+
if (auth.kind === "api_key") {
|
|
370
|
+
// Kilo gateway key (app.kilo.ai profile) — read at request time
|
|
371
|
+
return { [auth.envVar ?? "KILO_API_KEY"]: auth.value };
|
|
372
|
+
}
|
|
373
|
+
}
|
|
362
374
|
return {};
|
|
363
375
|
}
|
|
364
376
|
/** Load (and cache) stored Codex/OpenCode credentials, or throw with a fix hint. */
|
|
@@ -494,7 +506,9 @@ export class SessionManager {
|
|
|
494
506
|
*/
|
|
495
507
|
async refreshToken(agentType = "CLAUDE_CODE") {
|
|
496
508
|
const resolved = resolveAgentType(agentType);
|
|
497
|
-
if (resolved === "CODEX" ||
|
|
509
|
+
if (resolved === "CODEX" ||
|
|
510
|
+
resolved === "OPENCODE" ||
|
|
511
|
+
resolved === "KILO") {
|
|
498
512
|
const previous = this.agentAuthCache[resolved];
|
|
499
513
|
delete this.agentAuthCache[resolved];
|
|
500
514
|
const fresh = await loadAgentAuth(resolved);
|
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.6",
|
|
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.4"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^25.0.2",
|
package/rootfs/Dockerfile
CHANGED
|
@@ -6,23 +6,25 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|
|
6
6
|
RUN apt-get update -qq && \
|
|
7
7
|
apt-get install -y -qq --no-install-recommends \
|
|
8
8
|
ca-certificates curl git openssh-client build-essential \
|
|
9
|
-
python3 procps less sudo gnupg && \
|
|
9
|
+
python3 procps less sudo gnupg passwd xz-utils && \
|
|
10
10
|
rm -rf /var/lib/apt/lists/*
|
|
11
11
|
|
|
12
|
-
# Node.js 20 LTS
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
# Node.js 20 LTS from the official nodejs.org tarball — pinned version, no
|
|
13
|
+
# NodeSource apt repo (which intermittently 403s and breaks builds).
|
|
14
|
+
ARG TARGETARCH=arm64
|
|
15
|
+
ARG NODE_VERSION=20.20.2
|
|
16
|
+
RUN NODE_ARCH=$([ "$TARGETARCH" = "amd64" ] && echo "x64" || echo "$TARGETARCH") && \
|
|
17
|
+
curl -fsSL "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE_VERSION}-linux-${NODE_ARCH}.tar.xz" | \
|
|
18
|
+
tar -xJ -C /usr/local --strip-components=1 && \
|
|
19
|
+
node --version && npm --version
|
|
20
20
|
|
|
21
|
-
# Agent CLIs: Claude Code (primary), Codex and
|
|
21
|
+
# Agent CLIs: Claude Code (primary), Codex, OpenCode and Kilo (alternative engines).
|
|
22
22
|
# No output pipe: `| tail` would mask npm failures (pipeline exit = tail's).
|
|
23
23
|
# The `command -v` checks make a partial install fail the build loudly.
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
# @kilocode/cli ships its platform binary via optionalDependencies + postinstall,
|
|
25
|
+
# so scripts must stay enabled for it.
|
|
26
|
+
RUN npm install -g --loglevel=error @anthropic-ai/claude-code @openai/codex opencode-ai @kilocode/cli && \
|
|
27
|
+
command -v claude && command -v codex && command -v opencode && command -v kilo
|
|
26
28
|
|
|
27
29
|
# ForgeAI MCP relay (baked-in copy for reference only — at runtime the CLI
|
|
28
30
|
# stages its own copy into /session/forge-mcp-relay.mjs so the relay version
|
|
@@ -30,10 +32,10 @@ RUN npm install -g --loglevel=error @anthropic-ai/claude-code @openai/codex open
|
|
|
30
32
|
RUN mkdir -p /opt/forgeai
|
|
31
33
|
COPY forge-mcp-relay.mjs /opt/forgeai/forge-mcp-relay.mjs
|
|
32
34
|
|
|
33
|
-
# Create non-root user (Claude Code refuses --dangerously-skip-permissions as root)
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
35
|
+
# Create non-root user (Claude Code refuses --dangerously-skip-permissions as root).
|
|
36
|
+
# passwd is installed with the base packages above: re-running apt-get update
|
|
37
|
+
# here hits the NodeSource repo again, which intermittently 403s and fails builds.
|
|
38
|
+
RUN useradd -m -s /bin/bash -u 1001 agent
|
|
37
39
|
|
|
38
40
|
# Working directories (owned by agent user)
|
|
39
41
|
RUN mkdir -p /workspace /session /shared && \
|