@getforgeai/cli 0.1.0-beta.5 → 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 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";
@@ -129,6 +129,10 @@ configSetCmd
129
129
  .command("agent-type <value>")
130
130
  .description("Set agent type (CLAUDE_CODE, CODEX, OPENCODE or KILO)")
131
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));
132
136
  configSetCmd
133
137
  .command("agent-slots <value>")
134
138
  .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(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;
@@ -24,6 +24,57 @@ 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(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
+ }
27
78
  export async function configSetAgentSlotsAction(value) {
28
79
  const num = Number.parseInt(value, 10);
29
80
  if (Number.isNaN(num) || num < 1 || num > 20) {
@@ -55,6 +106,17 @@ export function configGetAction() {
55
106
  if (config.agentType === "KILO") {
56
107
  console.log(` Kilo model: ${config.kiloModel ?? "(Kilo default)"}`);
57
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
+ }
58
120
  console.log(` Agent slots: ${config.agentSlots ?? 5}`);
59
121
  console.log(` Opener: ${config.opener ?? "terminal"}`);
60
122
  console.log(` Server URL: ${config.serverUrl}`);
@@ -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
  }
@@ -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.
@@ -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;
@@ -23,13 +23,31 @@ export type McpBridgeConfig = {
23
23
  };
24
24
  export type AgentConfigOptions = {
25
25
  /**
26
- * Model in "provider/model" form (from CLI config, optional).
27
- * OpenCode: e.g. "anthropic/claude-sonnet-4-5".
28
- * Kilo: gateway models are "kilo/<vendor>/<model>", e.g.
29
- * "kilo/anthropic/claude-sonnet-4.5".
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
- return ["run", "--format", "json", prompt];
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
- return ["run", "--continue", "--format", "json", prompt];
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
- return ["run", "--auto", "--format", "json", prompt];
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
- return ["run", "--continue", "--auto", "--format", "json", prompt];
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
- function buildAgentConfig(profile, sessionIndex, mcpSecret, mcpHost) {
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 model = profile.type === "KILO" ? cliConfig.kiloModel : cliConfig.opencodeModel;
45
- return profile.buildConfig({ host: mcpHost, port: 20000 + sessionIndex, token: mcpSecret }, { model });
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,
@@ -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@getforgeai/cli",
3
- "version": "0.1.0-beta.5",
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.3"
50
+ "@getforgeai/protocol": "0.1.0-beta.4"
51
51
  },
52
52
  "devDependencies": {
53
53
  "@types/node": "^25.0.2",