Skip to content

Commit b447254

Browse files
authored
feat(kiloclaw): add Kilo CLI recovery agent (#1657)
* feat(kiloclaw): add kilo CLI run feature (spawn, poll, UI) Add the ability to run `kilo run --auto` on a KiloClaw instance from the dashboard, with real-time output polling and DB persistence. Stack: controller spawn route -> CF Worker DO proxy -> platform routes -> tRPC procedures -> React polling page. Includes admin visibility, changelog entry, and 12 controller route tests. * Fix dev-CLI * refactor(kiloclaw): use DB run ID in CLI run URL instead of prompt query param * Remove debug info * feat(kiloclaw): add admin CLI Runs tab with search and pagination * feat(kiloclaw): use KiloClaw default model for Kilo CLI runs Sync the Kilo CLI's model config with the user's selected KiloClaw default model (KILOCODE_DEFAULT_MODEL). Previously the CLI ignored this env var and fell back to kilo-auto/small. Now the model is written to opencode.json on both fresh installs and every boot, converting the kilocode/ provider prefix to kilo/ for the CLI's naming convention. * feat(kiloclaw): add system context prompt template for Kilo CLI runs Wrap the user's prompt with system context (key paths, architecture, diagnostic commands, and fix instructions) so the agent knows where to look and how to repair broken OpenClaw instances. The original user prompt is still stored for UI display; only the expanded prompt is passed to `kilo run --auto`. * refactor(kiloclaw): use shared constants in prompt template, improve CLI run UI Export path constants from config-writer and kilo-cli-config so the prompt template references them instead of duplicating string literals. Add openclaw doctor to diagnostics. Improve the CLI run detail page with SetPageTitle, remove max-height on output, and clean up layout. * refactor(kiloclaw): rebrand CLI run as recovery tool * fix(kiloclaw): wrap long output lines and auto-scroll on all updates * chore: remove migration 0060 before rebase * chore(db): regenerate migration 0060 for kiloclaw_cli_runs after rebase * chore: fix migration numbering, type errors, and formatting after rebase * docs(kiloclaw): update changelog entry to match recovery branding * fix(kiloclaw): set baseURL in kilo CLI config instead of deleting it * fix(kiloclaw): scope CLI run status to requested run ID and add GDPR cleanup * fix(kiloclaw): revert baseURL config patch — delete stale field instead of setting it * perf(kiloclaw): lazy-load CLI run output instead of fetching with list Exclude the output column from listAllCliRuns to avoid sending up to ~25MB per page. Add a dedicated getCliRunOutput procedure that fetches output for a single run on demand when selected in the admin panel. * fix(kiloclaw): scope cancelKiloCliRun DB update to specific run ID * chore(db): remove migration 0061 before merge with main * chore(db): regenerate migration 0064 after merge with main * fix(kilo-app): fix unsafe type access in useForceUpdate hook * Undo unwanted change
1 parent 4031cac commit b447254

29 files changed

+16600
-47
lines changed

kiloclaw/controller/src/bootstrap.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -279,6 +279,26 @@ describe('setupDirectories', () => {
279279
expect(env.INVOCATION_ID).toBe('1');
280280
expect(env.GOG_KEYRING_PASSWORD).toBe('kiloclaw');
281281
});
282+
283+
it('derives KILO_API_URL from KILOCODE_API_BASE_URL origin', () => {
284+
const { deps } = fakeDeps();
285+
const env: Record<string, string | undefined> = {
286+
KILOCODE_API_BASE_URL: 'https://api.example.com/v1',
287+
};
288+
289+
setupDirectories(env, deps);
290+
291+
expect(env.KILO_API_URL).toBe('https://api.example.com');
292+
});
293+
294+
it('does not set KILO_API_URL when KILOCODE_API_BASE_URL is absent', () => {
295+
const { deps } = fakeDeps();
296+
const env: Record<string, string | undefined> = {};
297+
298+
setupDirectories(env, deps);
299+
300+
expect(env.KILO_API_URL).toBeUndefined();
301+
});
282302
});
283303

284304
// ---- applyFeatureFlags ----

kiloclaw/controller/src/bootstrap.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -188,6 +188,11 @@ export function setupDirectories(env: EnvLike, deps: BootstrapDeps = defaultDeps
188188

189189
// GOG_KEYRING_PASSWORD is NOT a secret — see gog-credentials.ts for context.
190190
env.GOG_KEYRING_PASSWORD = 'kiloclaw';
191+
192+
// Derive the API origin for the Kilo CLI from the full base URL.
193+
if (env.KILOCODE_API_BASE_URL) {
194+
env.KILO_API_URL = new URL(env.KILOCODE_API_BASE_URL).origin;
195+
}
191196
}
192197

193198
// ---- Step 3: Feature flags ----

kiloclaw/controller/src/config-writer.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,7 @@ export function setNestedValue(obj: ConfigObject, path: string, value: string):
370370
current[segments[segments.length - 1]] = value;
371371
}
372372

373-
const DEFAULT_MCPORTER_CONFIG_PATH = '/root/.openclaw/workspace/config/mcporter.json';
373+
export const DEFAULT_MCPORTER_CONFIG_PATH = '/root/.openclaw/workspace/config/mcporter.json';
374374

375375
/**
376376
* Write mcporter.json with MCP server definitions derived from environment variables.

kiloclaw/controller/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { createPairingCache } from './pairing-cache';
1818
import { registerEnvRoutes } from './routes/env';
1919
import { registerGmailPushRoute } from './routes/gmail-push';
2020
import { registerFileRoutes } from './routes/files';
21+
import { registerKiloCliRunRoutes } from './routes/kilo-cli-run';
2122
import { CONTROLLER_COMMIT, CONTROLLER_VERSION } from './version';
2223
import { writeKiloCliConfig } from './kilo-cli-config';
2324
import { writeGogCredentials } from './gog-credentials';
@@ -362,6 +363,7 @@ export async function startController(env: NodeJS.ProcessEnv = process.env): Pro
362363
registerEnvRoutes(honoApp, supervisor, config.expectedToken);
363364
registerGmailPushRoute(honoApp, gmailWatchSupervisor ?? null, config.expectedToken);
364365
registerFileRoutes(honoApp, config.expectedToken, '/root/.openclaw');
366+
registerKiloCliRunRoutes(honoApp, config.expectedToken);
365367
honoApp.all(
366368
'*',
367369
createHttpProxy({

kiloclaw/controller/src/kilo-cli-config.test.ts

Lines changed: 72 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect, vi } from 'vitest';
2-
import { writeKiloCliConfig, type KiloCliConfigDeps } from './kilo-cli-config';
2+
import { writeKiloCliConfig, toKiloModelId, type KiloCliConfigDeps } from './kilo-cli-config';
33

44
function fakeDeps(existingConfig?: string) {
55
const written: { path: string; data: string; mode: number }[] = [];
@@ -34,6 +34,20 @@ function baseEnv(overrides: Record<string, string> = {}): Record<string, string
3434
};
3535
}
3636

37+
describe('toKiloModelId', () => {
38+
it('replaces kilocode/ prefix with kilo/', () => {
39+
expect(toKiloModelId('kilocode/anthropic/claude-opus-4.6')).toBe(
40+
'kilo/anthropic/claude-opus-4.6'
41+
);
42+
expect(toKiloModelId('kilocode/openai/gpt-5')).toBe('kilo/openai/gpt-5');
43+
});
44+
45+
it('passes through values without kilocode/ prefix', () => {
46+
expect(toKiloModelId('kilo/anthropic/claude-opus-4.6')).toBe('kilo/anthropic/claude-opus-4.6');
47+
expect(toKiloModelId('other/model')).toBe('other/model');
48+
});
49+
});
50+
3751
describe('writeKiloCliConfig', () => {
3852
it('returns false when feature flag is disabled', () => {
3953
const { deps, written } = fakeDeps();
@@ -72,13 +86,25 @@ describe('writeKiloCliConfig', () => {
7286
expect(seedConfig.$schema).toBe('https://app.kilo.ai/config.json');
7387
// No provider block — KiloAuthPlugin auto-registers via KILO_API_KEY env var
7488
expect(seedConfig.provider).toBeUndefined();
75-
// No model — CLI defaults to kilo-auto/small, user picks their own
89+
// No model when KILOCODE_DEFAULT_MODEL is not set
7690
expect(seedConfig.model).toBeUndefined();
7791
expect(seedConfig.permission.edit).toBe('allow');
7892
expect(seedConfig.permission.bash).toBe('allow');
7993
expect(written[0].mode).toBe(0o600);
8094
});
8195

96+
it('includes model in seed config when KILOCODE_DEFAULT_MODEL is set', () => {
97+
const { deps, written } = fakeDeps();
98+
const env = baseEnv({ KILOCODE_DEFAULT_MODEL: 'kilocode/anthropic/claude-opus-4.6' });
99+
const result = writeKiloCliConfig(env, '/tmp/kilo', deps);
100+
101+
expect(result).toBe(true);
102+
expect(written.length).toBeGreaterThanOrEqual(1);
103+
const seedConfig = JSON.parse(written[0].data);
104+
expect(seedConfig.model).toBe('kilo/anthropic/claude-opus-4.6');
105+
expect(seedConfig.permission.edit).toBe('allow');
106+
});
107+
82108
it('does not seed config on fresh install when config already exists', () => {
83109
const existing = JSON.stringify({ permission: { edit: 'allow', bash: 'allow' } });
84110
const { deps, written } = fakeDeps(existing);
@@ -99,71 +125,86 @@ describe('writeKiloCliConfig', () => {
99125
expect(written).toHaveLength(0);
100126
});
101127

102-
it('patches base URL on existing config using provider.kilo', () => {
103-
const existing = JSON.stringify({ permission: { edit: 'allow', bash: 'allow' } });
104-
const { deps, written } = fakeDeps(existing);
105-
const env = baseEnv({
106-
KILOCLAW_FRESH_INSTALL: 'false',
107-
KILOCODE_API_BASE_URL: 'https://tunnel.example.com/',
128+
it('removes stale provider.kilo.options.baseURL from existing config', () => {
129+
const existing = JSON.stringify({
130+
permission: { edit: 'allow', bash: 'allow' },
131+
provider: { kilo: { options: { baseURL: 'https://stale.example.com' } } },
108132
});
133+
const { deps, written } = fakeDeps(existing);
134+
const env = baseEnv({ KILOCLAW_FRESH_INSTALL: 'false' });
109135

110136
writeKiloCliConfig(env, '/tmp/kilo', deps);
111137

112138
expect(written).toHaveLength(1);
113139
const config = JSON.parse(written[0].data);
114-
expect(config.provider.kilo.options.baseURL).toBe('https://tunnel.example.com/');
140+
expect(config.provider.kilo.options.baseURL).toBeUndefined();
115141
});
116142

117-
it('does not set model from KILOCODE_DEFAULT_MODEL', () => {
143+
it('patches model from KILOCODE_DEFAULT_MODEL on existing config', () => {
118144
const existing = JSON.stringify({ permission: { edit: 'allow', bash: 'allow' } });
119145
const { deps, written } = fakeDeps(existing);
120146
const env = baseEnv({
121147
KILOCLAW_FRESH_INSTALL: 'false',
122148
KILOCODE_DEFAULT_MODEL: 'kilocode/openai/gpt-5',
123-
KILOCODE_API_BASE_URL: 'https://tunnel.example.com/',
124149
});
125150

126151
writeKiloCliConfig(env, '/tmp/kilo', deps);
127152

128153
expect(written).toHaveLength(1);
129154
const config = JSON.parse(written[0].data);
130-
// KILOCODE_DEFAULT_MODEL is for OpenClaw, not Kilo CLI
131-
expect(config.model).toBeUndefined();
132-
// But base URL is patched
133-
expect(config.provider.kilo.options.baseURL).toBe('https://tunnel.example.com/');
155+
expect(config.model).toBe('kilo/openai/gpt-5');
134156
});
135157

136-
it('creates provider structure when patching base URL on minimal config', () => {
137-
const existing = JSON.stringify({});
158+
it('patches model and scrubs stale baseURL together', () => {
159+
const existing = JSON.stringify({
160+
permission: { edit: 'allow', bash: 'allow' },
161+
provider: { kilo: { options: { baseURL: 'https://stale.example.com' } } },
162+
});
138163
const { deps, written } = fakeDeps(existing);
139164
const env = baseEnv({
140165
KILOCLAW_FRESH_INSTALL: 'false',
141-
KILOCODE_API_BASE_URL: 'https://tunnel.example.com/',
166+
KILOCODE_DEFAULT_MODEL: 'kilocode/openai/gpt-5',
167+
});
168+
169+
writeKiloCliConfig(env, '/tmp/kilo', deps);
170+
171+
expect(written).toHaveLength(1);
172+
const config = JSON.parse(written[0].data);
173+
expect(config.model).toBe('kilo/openai/gpt-5');
174+
expect(config.provider.kilo.options.baseURL).toBeUndefined();
175+
});
176+
177+
it('does not set model when KILOCODE_DEFAULT_MODEL is absent', () => {
178+
const existing = JSON.stringify({
179+
permission: { edit: 'allow', bash: 'allow' },
180+
provider: { kilo: { options: { baseURL: 'https://stale.example.com' } } },
142181
});
182+
const { deps, written } = fakeDeps(existing);
183+
const env = baseEnv({ KILOCLAW_FRESH_INSTALL: 'false' });
143184

144185
writeKiloCliConfig(env, '/tmp/kilo', deps);
145186

187+
expect(written).toHaveLength(1);
146188
const config = JSON.parse(written[0].data);
147-
expect(config.provider.kilo.options.baseURL).toBe('https://tunnel.example.com/');
189+
expect(config.model).toBeUndefined();
190+
// baseURL scrubbed as side effect
191+
expect(config.provider.kilo.options.baseURL).toBeUndefined();
148192
});
149193

150-
it('does not write when no env overrides set', () => {
194+
it('does not write when config has no stale baseURL and no model override', () => {
151195
const existing = JSON.stringify({ permission: { edit: 'allow' } });
152196
const { deps, written } = fakeDeps(existing);
153197
const env = baseEnv({ KILOCLAW_FRESH_INSTALL: 'false' });
154198

155199
writeKiloCliConfig(env, '/tmp/kilo', deps);
156200

157-
// No KILOCODE_API_BASE_URL → no patch needed, no write
201+
// Nothing to scrub or patch no write
158202
expect(written).toHaveLength(0);
159203
});
160204

161205
it('skips patch gracefully when config file contains corrupt JSON', () => {
162206
const { deps, written } = fakeDeps('not valid json {{{');
163-
const env = baseEnv({
164-
KILOCLAW_FRESH_INSTALL: 'false',
165-
KILOCODE_API_BASE_URL: 'https://tunnel.example.com/',
166-
});
207+
const env = baseEnv({ KILOCLAW_FRESH_INSTALL: 'false' });
167208

168209
const consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
169210
const result = writeKiloCliConfig(env, '/tmp/kilo', deps);
@@ -177,7 +218,7 @@ describe('writeKiloCliConfig', () => {
177218
consoleSpy.mockRestore();
178219
});
179220

180-
it('seeds config and then patches base URL on fresh install', () => {
221+
it('seeds config on fresh install without adding baseURL', () => {
181222
const { deps, written } = fakeDeps();
182223

183224
let seeded = false;
@@ -197,17 +238,18 @@ describe('writeKiloCliConfig', () => {
197238
});
198239

199240
const env = baseEnv({
200-
KILOCODE_API_BASE_URL: 'https://tunnel.example.com/',
241+
KILOCODE_API_BASE_URL: 'https://tunnel.example.com/api/gateway',
201242
});
202243

203244
const result = writeKiloCliConfig(env, '/tmp/kilo', deps);
204245

205246
expect(result).toBe(true);
206-
expect(written).toHaveLength(2); // seed + patch
247+
// Only the seed write — no baseURL patch (seeded config has no stale baseURL to scrub)
248+
expect(written).toHaveLength(1);
207249

208-
const finalConfig = JSON.parse(written[1].data);
250+
const finalConfig = JSON.parse(written[0].data);
209251
expect(finalConfig.$schema).toBe('https://app.kilo.ai/config.json');
210-
expect(finalConfig.provider.kilo.options.baseURL).toBe('https://tunnel.example.com/');
252+
expect(finalConfig.provider).toBeUndefined();
211253
expect(finalConfig.model).toBeUndefined();
212254
});
213255
});

kiloclaw/controller/src/kilo-cli-config.ts

Lines changed: 37 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,16 @@
1515
import fs from 'node:fs';
1616
import path from 'node:path';
1717

18-
const KILO_CONFIG_DIR = '/root/.config/kilo';
19-
const CONFIG_FILE = 'opencode.json';
18+
export const KILO_CONFIG_DIR = '/root/.config/kilo';
19+
export const CONFIG_FILE = 'opencode.json';
20+
21+
/** The Kilo CLI uses `kilo/` as the provider prefix, but KiloClaw uses `kilocode/`. */
22+
export function toKiloModelId(kilocodeModelId: string): string {
23+
if (kilocodeModelId.startsWith('kilocode/')) {
24+
return 'kilo/' + kilocodeModelId.slice('kilocode/'.length);
25+
}
26+
return kilocodeModelId;
27+
}
2028

2129
export type KiloCliConfigDeps = {
2230
mkdirSync: (dir: string, opts: { recursive: boolean }) => void;
@@ -49,33 +57,48 @@ export function writeKiloCliConfig(
4957
// No provider block needed — the KiloAuthPlugin auto-registers the "kilo"
5058
// provider when KILO_API_KEY is in the environment (set by bootstrap).
5159
if (isFreshInstall && !deps.existsSync(configPath)) {
52-
const config = {
60+
const config: Record<string, unknown> = {
5361
$schema: 'https://app.kilo.ai/config.json',
5462
permission: { edit: 'allow', bash: 'allow' },
5563
};
64+
if (env.KILOCODE_DEFAULT_MODEL) {
65+
config.model = toKiloModelId(env.KILOCODE_DEFAULT_MODEL);
66+
}
5667
deps.mkdirSync(configDir, { recursive: true });
5768
deps.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
5869
console.log('[kilo-cli] Seeded config at ' + configPath);
5970
}
6071

6172
// Patch config on every boot (if it exists).
62-
// Only writes when a change is actually made to avoid silent no-op writes.
63-
if (deps.existsSync(configPath) && env.KILOCODE_API_BASE_URL) {
73+
if (deps.existsSync(configPath)) {
6474
try {
6575
// JSON structure is open-ended (user may add arbitrary keys), so we use `any`
66-
// rather than a strict schema. The patch only touches provider.kilo.options.baseURL.
76+
// rather than a strict schema.
6777
// eslint-disable-next-line @typescript-eslint/no-explicit-any
6878
const config: any = JSON.parse(deps.readFileSync(configPath, 'utf8'));
79+
let dirty = false;
80+
81+
// Remove any stale provider.kilo.options.baseURL from the config file.
82+
// Setting baseURL in opencode.json is broken (the Kilo CLI ignores it
83+
// in certain code paths). The correct mechanism is the KILO_API_URL env
84+
// var, which bootstrap sets from KILOCODE_API_BASE_URL. Early deployments
85+
// may still have the broken field, so we scrub it on every boot.
86+
if (config.provider?.kilo?.options?.baseURL) {
87+
delete config.provider.kilo.options.baseURL;
88+
dirty = true;
89+
}
6990

70-
// Override the kilo provider's base URL for local dev (e.g., ngrok tunnel).
71-
// In production this env var is not set and the built-in default is used.
72-
config.provider = config.provider || {};
73-
config.provider.kilo = config.provider.kilo || {};
74-
config.provider.kilo.options = config.provider.kilo.options || {};
75-
config.provider.kilo.options.baseURL = env.KILOCODE_API_BASE_URL;
76-
console.log('[kilo-cli] Patched base URL: ' + env.KILOCODE_API_BASE_URL);
91+
// Sync Kilo CLI's model with the user's KiloClaw default model.
92+
// Updated on every boot so model changes in KiloClaw settings take effect.
93+
const defaultModel = env.KILOCODE_DEFAULT_MODEL;
94+
if (defaultModel) {
95+
config.model = toKiloModelId(defaultModel);
96+
dirty = true;
97+
}
7798

78-
deps.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
99+
if (dirty) {
100+
deps.writeFileSync(configPath, JSON.stringify(config, null, 2), { mode: 0o600 });
101+
}
79102
} catch (err) {
80103
console.error('[kilo-cli] Failed to patch config (corrupt JSON?), skipping:', err);
81104
}

0 commit comments

Comments
 (0)