kimiflare 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # kimiflare
2
2
 
3
- A terminal coding agent powered by **[Kimi-K2.6](https://developers.cloudflare.com/workers-ai/models/kimi-k2.6/)** on Cloudflare Workers AI. It's Claude Code, but the model is Moonshot's 1T-parameter open-source Kimi running directly on your Cloudflare account — no middleman, no AI Gateway, no OpenAI SDK. You bring the token, your traffic goes straight to Cloudflare.
3
+ A terminal coding agent powered by **[Kimi-K2.6](https://developers.cloudflare.com/workers-ai/models/kimi-k2.6/)** on Cloudflare Workers AI. Moonshot's 1T-parameter open-source model runs directly on your Cloudflare account. You bring the token, your traffic goes straight to Cloudflare.
4
4
 
5
5
  ```
6
6
  $ kimiflare
@@ -180,7 +180,7 @@ All tool calls show inline; mutating ones require per-call approval the first ti
180
180
  @cf/moonshotai/kimi-k2.6
181
181
  ```
182
182
 
183
- No AI Gateway, no proxy, no OpenAI SDK. Direct `fetch` to Workers AI, OpenAI-compatible `messages` + `tools` payload, SSE stream with reasoning + content + tool-call deltas accumulated by index.
183
+ Direct `fetch` to Workers AI, OpenAI-compatible `messages` + `tools` payload, SSE stream with reasoning + content + tool-call deltas accumulated by index.
184
184
 
185
185
  ## Development
186
186
 
package/dist/index.js CHANGED
@@ -142,7 +142,33 @@ var init_errors = __esm({
142
142
  }
143
143
  });
144
144
 
145
+ // src/agent/messages.ts
146
+ function sanitizeString(str) {
147
+ return str.replace(/[\uD800-\uDFFF]/g, "\uFFFD");
148
+ }
149
+ function jsonReplacer(_key, value) {
150
+ if (typeof value === "string") {
151
+ return sanitizeString(value);
152
+ }
153
+ return value;
154
+ }
155
+ var init_messages = __esm({
156
+ "src/agent/messages.ts"() {
157
+ "use strict";
158
+ }
159
+ });
160
+
145
161
  // src/agent/client.ts
162
+ function cleanErrorMessage(msg) {
163
+ return msg.replace(/^(AiError:\s*)+/, "").trim();
164
+ }
165
+ function isRetryable(err, attempt) {
166
+ if (attempt >= MAX_ATTEMPTS - 1) return false;
167
+ if (err.code !== void 0 && RETRYABLE_CODES.has(err.code)) return true;
168
+ if (err.httpStatus !== void 0 && err.httpStatus >= 500 && err.httpStatus < 600) return true;
169
+ if (err.message.includes("Internal server error")) return true;
170
+ return false;
171
+ }
146
172
  async function* runKimi(opts2) {
147
173
  const url = `https://api.cloudflare.com/client/v4/accounts/${opts2.accountId}/ai/run/${opts2.model}`;
148
174
  const body = {
@@ -156,15 +182,26 @@ async function* runKimi(opts2) {
156
182
  body.reasoning_effort = opts2.reasoningEffort;
157
183
  }
158
184
  for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) {
159
- const res = await fetch(url, {
160
- method: "POST",
161
- headers: {
162
- Authorization: `Bearer ${opts2.apiToken}`,
163
- "Content-Type": "application/json"
164
- },
165
- body: JSON.stringify(body),
166
- signal: opts2.signal
167
- });
185
+ let res;
186
+ try {
187
+ res = await fetch(url, {
188
+ method: "POST",
189
+ headers: {
190
+ Authorization: `Bearer ${opts2.apiToken}`,
191
+ "Content-Type": "application/json"
192
+ },
193
+ body: JSON.stringify(body, jsonReplacer),
194
+ signal: opts2.signal
195
+ });
196
+ } catch (fetchErr) {
197
+ const msg = fetchErr instanceof Error ? fetchErr.message : String(fetchErr);
198
+ if (attempt < MAX_ATTEMPTS - 1) {
199
+ const delay = 500 * 2 ** attempt + Math.random() * 250;
200
+ await sleep(delay, opts2.signal);
201
+ continue;
202
+ }
203
+ throw new KimiApiError(`kimiflare: network error: ${msg}`, void 0, void 0);
204
+ }
168
205
  const contentType = res.headers.get("content-type") ?? "";
169
206
  if (!contentType.includes("text/event-stream")) {
170
207
  const text = await res.text();
@@ -174,13 +211,15 @@ async function* runKimi(opts2) {
174
211
  } catch {
175
212
  }
176
213
  const err = extractCloudflareError(parsed);
177
- if (err?.code === RETRYABLE_CODE && attempt < MAX_ATTEMPTS - 1) {
214
+ const rawMsg = err?.message ?? `HTTP ${res.status}: ${text.slice(0, 300)}`;
215
+ const msg = cleanErrorMessage(rawMsg);
216
+ const apiErr = new KimiApiError(`kimiflare: ${msg}`, err?.code, res.status);
217
+ if (isRetryable(apiErr, attempt)) {
178
218
  const delay = 500 * 2 ** attempt + Math.random() * 250;
179
219
  await sleep(delay, opts2.signal);
180
220
  continue;
181
221
  }
182
- const msg = err?.message ?? `HTTP ${res.status}: ${text.slice(0, 300)}`;
183
- throw new KimiApiError(`kimiflare: ${msg}`, err?.code, res.status);
222
+ throw apiErr;
184
223
  }
185
224
  if (!res.body) throw new KimiApiError("kimiflare: empty response body", void 0, res.status);
186
225
  yield* parseStream(res.body, opts2.signal);
@@ -257,9 +296,14 @@ async function* parseStream(body, signal) {
257
296
  }
258
297
  function extractCloudflareError(parsed) {
259
298
  if (!parsed || typeof parsed !== "object") return null;
260
- const p = parsed;
261
- if (p.success === false && Array.isArray(p.errors) && p.errors.length > 0) {
262
- return { code: p.errors[0]?.code, message: p.errors[0]?.message };
299
+ const cf = parsed;
300
+ if (cf.success === false && Array.isArray(cf.errors) && cf.errors.length > 0) {
301
+ return { code: cf.errors[0]?.code, message: cf.errors[0]?.message };
302
+ }
303
+ const oai = parsed;
304
+ if (oai.object === "error" && typeof oai.message === "string") {
305
+ const codeNum = typeof oai.code === "number" ? oai.code : void 0;
306
+ return { code: codeNum, message: oai.message };
263
307
  }
264
308
  return null;
265
309
  }
@@ -277,13 +321,14 @@ function sleep(ms, signal) {
277
321
  signal?.addEventListener("abort", onAbort, { once: true });
278
322
  });
279
323
  }
280
- var RETRYABLE_CODE, MAX_ATTEMPTS;
324
+ var RETRYABLE_CODES, MAX_ATTEMPTS;
281
325
  var init_client = __esm({
282
326
  "src/agent/client.ts"() {
283
327
  "use strict";
284
328
  init_sse();
285
329
  init_errors();
286
- RETRYABLE_CODE = 3040;
330
+ init_messages();
331
+ RETRYABLE_CODES = /* @__PURE__ */ new Set([3040]);
287
332
  MAX_ATTEMPTS = 5;
288
333
  }
289
334
  });
@@ -360,9 +405,17 @@ async function runAgentTurn(opts2) {
360
405
  }
361
406
  const assistantMsg = {
362
407
  role: "assistant",
363
- content: content || null,
364
- ...reasoning ? { reasoning_content: reasoning } : {},
365
- ...toolCalls.length ? { tool_calls: toolCalls } : {}
408
+ content: content ? sanitizeString(content) : null,
409
+ ...reasoning ? { reasoning_content: sanitizeString(reasoning) } : {},
410
+ ...toolCalls.length ? {
411
+ tool_calls: toolCalls.map((tc) => ({
412
+ ...tc,
413
+ function: {
414
+ name: tc.function.name,
415
+ arguments: sanitizeString(tc.function.arguments)
416
+ }
417
+ }))
418
+ } : {}
366
419
  };
367
420
  opts2.messages.push(assistantMsg);
368
421
  opts2.callbacks.onAssistantFinal?.(assistantMsg);
@@ -377,7 +430,7 @@ async function runAgentTurn(opts2) {
377
430
  opts2.messages.push({
378
431
  role: "tool",
379
432
  tool_call_id: result.tool_call_id,
380
- content: result.content,
433
+ content: sanitizeString(result.content),
381
434
  name: result.name
382
435
  });
383
436
  opts2.callbacks.onToolResult?.(result);
@@ -390,6 +443,7 @@ var init_loop = __esm({
390
443
  "use strict";
391
444
  init_client();
392
445
  init_registry();
446
+ init_messages();
393
447
  }
394
448
  });
395
449
 
@@ -1465,6 +1519,7 @@ var init_markdown = __esm({
1465
1519
 
1466
1520
  // src/ui/chat.tsx
1467
1521
  import { Box as Box4, Text as Text4 } from "ink";
1522
+ import Spinner2 from "ink-spinner";
1468
1523
  import { jsx as jsx4, jsxs as jsxs4 } from "react/jsx-runtime";
1469
1524
  function ChatView({ events, showReasoning, theme, verbose }) {
1470
1525
  return /* @__PURE__ */ jsx4(Box4, { flexDirection: "column", children: events.map((e, i) => {
@@ -1498,7 +1553,8 @@ function EventView({
1498
1553
  " ",
1499
1554
  evt.reasoning.length > 400 ? evt.reasoning.slice(0, 400) + "\u2026" : evt.reasoning
1500
1555
  ] }) }) : null,
1501
- evt.text ? /* @__PURE__ */ jsx4(MD, { text: evt.text, theme }) : null
1556
+ evt.text ? /* @__PURE__ */ jsx4(MD, { text: evt.text, theme }) : null,
1557
+ evt.streaming && /* @__PURE__ */ jsx4(Text4, { color: theme.spinner, children: /* @__PURE__ */ jsx4(Spinner2, { type: "dots" }) })
1502
1558
  ] });
1503
1559
  }
1504
1560
  if (evt.kind === "tool") {
@@ -1524,26 +1580,21 @@ var init_chat = __esm({
1524
1580
  });
1525
1581
 
1526
1582
  // src/ui/status.tsx
1583
+ import { useEffect, useState } from "react";
1527
1584
  import { Box as Box5, Text as Text5 } from "ink";
1585
+ import Spinner3 from "ink-spinner";
1528
1586
  import { jsx as jsx5, jsxs as jsxs5 } from "react/jsx-runtime";
1529
- function StatusBar({ model, usage, thinking, theme, mode, effort, contextLimit }) {
1587
+ function StatusBar({ model, usage, thinking, turnStartedAt, theme, mode, effort, contextLimit }) {
1588
+ const [now, setNow] = useState(Date.now());
1530
1589
  const modeColor = mode === "plan" ? theme.modeBadge.plan : mode === "auto" ? theme.modeBadge.auto : theme.modeBadge.edit;
1531
1590
  const warn = usage && usage.prompt_tokens / contextLimit >= 0.8;
1591
+ useEffect(() => {
1592
+ if (!thinking || turnStartedAt === null) return;
1593
+ const id = setInterval(() => setNow(Date.now()), 1e3);
1594
+ return () => clearInterval(id);
1595
+ }, [thinking, turnStartedAt]);
1596
+ const elapsed = turnStartedAt !== null ? formatElapsed(now - turnStartedAt) : null;
1532
1597
  const leftParts = [`${shortModel(model)}`, effort];
1533
- if (thinking) leftParts.push("thinking\u2026");
1534
- const rightParts = [];
1535
- if (usage) {
1536
- const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
1537
- const uncachedIn = usage.prompt_tokens - cached;
1538
- const cost = uncachedIn * PRICE_IN_PER_M / 1e6 + cached * PRICE_IN_CACHED_PER_M / 1e6 + usage.completion_tokens * PRICE_OUT_PER_M / 1e6;
1539
- const pct = Math.round(usage.prompt_tokens / contextLimit * 100);
1540
- rightParts.push(
1541
- `in ${usage.prompt_tokens}${cached ? ` (${cached} cached)` : ""}`,
1542
- `out ${usage.completion_tokens}`,
1543
- `ctx ${pct}%`,
1544
- `${cost.toFixed(5)}`
1545
- );
1546
- }
1547
1598
  return /* @__PURE__ */ jsxs5(Box5, { flexDirection: "column", children: [
1548
1599
  /* @__PURE__ */ jsxs5(Box5, { children: [
1549
1600
  /* @__PURE__ */ jsxs5(Text5, { color: modeColor, bold: true, children: [
@@ -1552,10 +1603,18 @@ function StatusBar({ model, usage, thinking, theme, mode, effort, contextLimit }
1552
1603
  "]"
1553
1604
  ] }),
1554
1605
  /* @__PURE__ */ jsx5(Text5, { children: " " }),
1555
- /* @__PURE__ */ jsx5(Text5, { color: theme.info.color, dimColor: theme.info.dim, children: leftParts.join(" \xB7 ") })
1606
+ thinking ? /* @__PURE__ */ jsxs5(Text5, { color: theme.spinner, children: [
1607
+ /* @__PURE__ */ jsx5(Spinner3, { type: "dots" }),
1608
+ " ",
1609
+ "thinking",
1610
+ elapsed ? ` \xB7 ${elapsed}` : ""
1611
+ ] }) : /* @__PURE__ */ jsxs5(Text5, { color: theme.info.color, dimColor: theme.info.dim, children: [
1612
+ leftParts.join(" \xB7 "),
1613
+ " \xB7 ready"
1614
+ ] })
1556
1615
  ] }),
1557
- rightParts.length > 0 && /* @__PURE__ */ jsxs5(Box5, { children: [
1558
- /* @__PURE__ */ jsx5(Text5, { color: theme.info.color, dimColor: theme.info.dim, children: rightParts.join(" \xB7 ") }),
1616
+ usage && /* @__PURE__ */ jsxs5(Box5, { children: [
1617
+ /* @__PURE__ */ jsx5(Text5, { color: theme.info.color, dimColor: theme.info.dim, children: buildRightParts(usage, contextLimit).join(" \xB7 ") }),
1559
1618
  warn ? /* @__PURE__ */ jsxs5(Text5, { color: theme.warn, bold: true, children: [
1560
1619
  " \xB7 ",
1561
1620
  "/compact recommended"
@@ -1563,10 +1622,29 @@ function StatusBar({ model, usage, thinking, theme, mode, effort, contextLimit }
1563
1622
  ] })
1564
1623
  ] });
1565
1624
  }
1625
+ function buildRightParts(usage, contextLimit) {
1626
+ const cached = usage.prompt_tokens_details?.cached_tokens ?? 0;
1627
+ const uncachedIn = usage.prompt_tokens - cached;
1628
+ const cost = uncachedIn * PRICE_IN_PER_M / 1e6 + cached * PRICE_IN_CACHED_PER_M / 1e6 + usage.completion_tokens * PRICE_OUT_PER_M / 1e6;
1629
+ const pct = Math.round(usage.prompt_tokens / contextLimit * 100);
1630
+ return [
1631
+ `in ${usage.prompt_tokens}${cached ? ` (${cached} cached)` : ""}`,
1632
+ `out ${usage.completion_tokens}`,
1633
+ `ctx ${pct}%`,
1634
+ `${cost.toFixed(5)}`
1635
+ ];
1636
+ }
1566
1637
  function shortModel(m) {
1567
1638
  const last = m.split("/").at(-1) ?? m;
1568
1639
  return last;
1569
1640
  }
1641
+ function formatElapsed(ms) {
1642
+ const total = Math.floor(ms / 1e3);
1643
+ const m = Math.floor(total / 60);
1644
+ const s = total % 60;
1645
+ if (m === 0) return `${s}s`;
1646
+ return `${m}m ${s}s`;
1647
+ }
1570
1648
  var PRICE_IN_PER_M, PRICE_IN_CACHED_PER_M, PRICE_OUT_PER_M;
1571
1649
  var init_status = __esm({
1572
1650
  "src/ui/status.tsx"() {
@@ -1671,12 +1749,13 @@ var init_resume_picker = __esm({
1671
1749
  });
1672
1750
 
1673
1751
  // src/ui/task-list.tsx
1674
- import { useEffect, useState } from "react";
1752
+ import { useEffect as useEffect2, useState as useState2 } from "react";
1675
1753
  import { Box as Box8, Text as Text8 } from "ink";
1754
+ import Spinner4 from "ink-spinner";
1676
1755
  import { jsx as jsx8, jsxs as jsxs8 } from "react/jsx-runtime";
1677
1756
  function TaskList({ tasks, theme, startedAt, tokensDelta }) {
1678
- const [now, setNow] = useState(Date.now());
1679
- useEffect(() => {
1757
+ const [now, setNow] = useState2(Date.now());
1758
+ useEffect2(() => {
1680
1759
  if (startedAt === null) return;
1681
1760
  const allDone2 = tasks.length > 0 && tasks.every((t) => t.status === "completed");
1682
1761
  if (allDone2) return;
@@ -1689,7 +1768,7 @@ function TaskList({ tasks, theme, startedAt, tokensDelta }) {
1689
1768
  const total = tasks.length;
1690
1769
  const allDone = done === total;
1691
1770
  const header = active ? active.title : allDone ? `${total} tasks done` : `${done}/${total}`;
1692
- const elapsed = startedAt ? formatElapsed(now - startedAt) : null;
1771
+ const elapsed = startedAt ? formatElapsed2(now - startedAt) : null;
1693
1772
  const headerStats = [elapsed, tokensDelta > 0 ? `\u2191 ${formatTokens(tokensDelta)} tokens` : null].filter(Boolean).join(" \xB7 ");
1694
1773
  const visibleTasks = tasks.slice(0, MAX_VISIBLE);
1695
1774
  const hiddenPending = Math.max(0, tasks.length - visibleTasks.length);
@@ -1723,7 +1802,8 @@ function TaskRow({ task, theme }) {
1723
1802
  if (task.status === "in_progress") {
1724
1803
  return /* @__PURE__ */ jsxs8(Text8, { color: theme.accent, bold: true, children: [
1725
1804
  " ",
1726
- "\u25A0 ",
1805
+ /* @__PURE__ */ jsx8(Spinner4, { type: "dots" }),
1806
+ " ",
1727
1807
  task.title
1728
1808
  ] });
1729
1809
  }
@@ -1733,7 +1813,7 @@ function TaskRow({ task, theme }) {
1733
1813
  task.title
1734
1814
  ] });
1735
1815
  }
1736
- function formatElapsed(ms) {
1816
+ function formatElapsed2(ms) {
1737
1817
  const total = Math.floor(ms / 1e3);
1738
1818
  const m = Math.floor(total / 60);
1739
1819
  const s = total % 60;
@@ -2273,7 +2353,7 @@ var init_source = __esm({
2273
2353
  });
2274
2354
 
2275
2355
  // src/ui/text-input.tsx
2276
- import { useState as useState2, useEffect as useEffect2, useRef } from "react";
2356
+ import { useState as useState3, useEffect as useEffect3, useRef } from "react";
2277
2357
  import { Text as Text9, useInput } from "ink";
2278
2358
  import { jsx as jsx9 } from "react/jsx-runtime";
2279
2359
  function shouldTreatAsPaste(input) {
@@ -2308,9 +2388,9 @@ function CustomTextInput({
2308
2388
  mask,
2309
2389
  enablePaste = false
2310
2390
  }) {
2311
- const [cursorOffset, setCursorOffset] = useState2(value.length);
2391
+ const [cursorOffset, setCursorOffset] = useState3(value.length);
2312
2392
  const pastesRef = useRef(/* @__PURE__ */ new Map());
2313
- useEffect2(() => {
2393
+ useEffect3(() => {
2314
2394
  if (!focus) return;
2315
2395
  setCursorOffset((prev) => prev > value.length ? value.length : prev);
2316
2396
  }, [value, focus]);
@@ -2469,7 +2549,7 @@ var init_text_input = __esm({
2469
2549
  "use strict";
2470
2550
  init_source();
2471
2551
  PASTE_CHAR_THRESHOLD = 200;
2472
- PASTE_NEWLINE_THRESHOLD = 3;
2552
+ PASTE_NEWLINE_THRESHOLD = 1;
2473
2553
  }
2474
2554
  });
2475
2555
 
@@ -2586,15 +2666,15 @@ var init_update_check = __esm({
2586
2666
  });
2587
2667
 
2588
2668
  // src/ui/onboarding.tsx
2589
- import { useState as useState3 } from "react";
2669
+ import { useState as useState4 } from "react";
2590
2670
  import { Box as Box9, Text as Text10 } from "ink";
2591
2671
  import { Fragment, jsx as jsx10, jsxs as jsxs9 } from "react/jsx-runtime";
2592
2672
  function Onboarding({ onDone }) {
2593
- const [step, setStep] = useState3("accountId");
2594
- const [accountId, setAccountId] = useState3("");
2595
- const [apiToken, setApiToken] = useState3("");
2596
- const [model, setModel] = useState3(DEFAULT_MODEL);
2597
- const [savedPath, setSavedPath] = useState3(null);
2673
+ const [step, setStep] = useState4("accountId");
2674
+ const [accountId, setAccountId] = useState4("");
2675
+ const [apiToken, setApiToken] = useState4("");
2676
+ const [model, setModel] = useState4(DEFAULT_MODEL);
2677
+ const [savedPath, setSavedPath] = useState4(null);
2598
2678
  const stepIndex = STEPS.indexOf(step) + 1;
2599
2679
  const handleAccountIdSubmit = (value) => {
2600
2680
  const trimmed = value.trim();
@@ -2917,7 +2997,7 @@ var app_exports = {};
2917
2997
  __export(app_exports, {
2918
2998
  renderApp: () => renderApp
2919
2999
  });
2920
- import { useState as useState4, useRef as useRef2, useEffect as useEffect3, useCallback } from "react";
3000
+ import { useState as useState5, useRef as useRef2, useEffect as useEffect4, useCallback } from "react";
2921
3001
  import { Box as Box11, Text as Text12, useApp, useInput as useInput2, render } from "ink";
2922
3002
  import { existsSync } from "fs";
2923
3003
  import { join as join5 } from "path";
@@ -2925,27 +3005,28 @@ import { unlink } from "fs/promises";
2925
3005
  import { jsx as jsx12, jsxs as jsxs11 } from "react/jsx-runtime";
2926
3006
  function App({ initialCfg }) {
2927
3007
  const { exit } = useApp();
2928
- const [cfg, setCfg] = useState4(initialCfg);
2929
- const [events, setEvents] = useState4([]);
2930
- const [input, setInput] = useState4("");
2931
- const [busy, setBusy] = useState4(false);
2932
- const [usage, setUsage] = useState4(null);
2933
- const [showReasoning, setShowReasoning] = useState4(false);
2934
- const [perm, setPerm] = useState4(null);
2935
- const [queue, setQueue] = useState4([]);
2936
- const [history, setHistory] = useState4([]);
2937
- const [historyIndex, setHistoryIndex] = useState4(-1);
2938
- const [draftInput, setDraftInput] = useState4("");
2939
- const [mode, setMode] = useState4("edit");
2940
- const [effort, setEffort] = useState4(
3008
+ const [cfg, setCfg] = useState5(initialCfg);
3009
+ const [events, setEvents] = useState5([]);
3010
+ const [input, setInput] = useState5("");
3011
+ const [busy, setBusy] = useState5(false);
3012
+ const [usage, setUsage] = useState5(null);
3013
+ const [showReasoning, setShowReasoning] = useState5(false);
3014
+ const [perm, setPerm] = useState5(null);
3015
+ const [queue, setQueue] = useState5([]);
3016
+ const [history, setHistory] = useState5([]);
3017
+ const [historyIndex, setHistoryIndex] = useState5(-1);
3018
+ const [draftInput, setDraftInput] = useState5("");
3019
+ const [mode, setMode] = useState5("edit");
3020
+ const [effort, setEffort] = useState5(
2941
3021
  initialCfg?.reasoningEffort ?? DEFAULT_REASONING_EFFORT
2942
3022
  );
2943
- const [theme, setTheme] = useState4(resolveTheme(initialCfg?.theme));
2944
- const [resumeSessions, setResumeSessions] = useState4(null);
2945
- const [tasks, setTasks] = useState4([]);
2946
- const [tasksStartedAt, setTasksStartedAt] = useState4(null);
2947
- const [tasksStartTokens, setTasksStartTokens] = useState4(0);
2948
- const [verbose, setVerbose] = useState4(false);
3023
+ const [theme, setTheme] = useState5(resolveTheme(initialCfg?.theme));
3024
+ const [resumeSessions, setResumeSessions] = useState5(null);
3025
+ const [tasks, setTasks] = useState5([]);
3026
+ const [tasksStartedAt, setTasksStartedAt] = useState5(null);
3027
+ const [tasksStartTokens, setTasksStartTokens] = useState5(0);
3028
+ const [turnStartedAt, setTurnStartedAt] = useState5(null);
3029
+ const [verbose, setVerbose] = useState5(false);
2949
3030
  const messagesRef = useRef2([
2950
3031
  {
2951
3032
  role: "system",
@@ -2965,7 +3046,35 @@ function App({ initialCfg }) {
2965
3046
  const effortRef = useRef2(effort);
2966
3047
  const tasksRef = useRef2([]);
2967
3048
  const usageRef = useRef2(null);
2968
- useEffect3(() => {
3049
+ const updateCheckedRef = useRef2(false);
3050
+ const compactSuggestedRef = useRef2(false);
3051
+ useEffect4(() => {
3052
+ if (!cfg || updateCheckedRef.current) return;
3053
+ updateCheckedRef.current = true;
3054
+ void checkForUpdate().then((result) => {
3055
+ if (result.hasUpdate) {
3056
+ setEvents((e) => [
3057
+ ...e,
3058
+ {
3059
+ kind: "info",
3060
+ key: mkKey(),
3061
+ text: `update available: ${result.localVersion} \u2192 ${result.latestVersion}`
3062
+ }
3063
+ ]);
3064
+ void isGitRepo().then((git) => {
3065
+ setEvents((e) => [
3066
+ ...e,
3067
+ {
3068
+ kind: "info",
3069
+ key: mkKey(),
3070
+ text: git ? "run: git pull && npm install && npm run build then restart kimiflare" : "run: npm update -g kimiflare then restart"
3071
+ }
3072
+ ]);
3073
+ });
3074
+ }
3075
+ });
3076
+ }, [cfg]);
3077
+ useEffect4(() => {
2969
3078
  modeRef.current = mode;
2970
3079
  messagesRef.current[0] = {
2971
3080
  role: "system",
@@ -2980,7 +3089,7 @@ function App({ initialCfg }) {
2980
3089
  executorRef.current.clearSessionPermissions();
2981
3090
  }
2982
3091
  }, [mode, cfg?.model]);
2983
- useEffect3(() => {
3092
+ useEffect4(() => {
2984
3093
  effortRef.current = effort;
2985
3094
  }, [effort]);
2986
3095
  const saveSessionSafe = useCallback(async () => {
@@ -3053,7 +3162,7 @@ function App({ initialCfg }) {
3053
3162
  return;
3054
3163
  }
3055
3164
  setBusy(true);
3056
- setEvents((e) => [...e, { kind: "info", key: mkKey(), text: "compacting conversation\u2026" }]);
3165
+ setTurnStartedAt(Date.now());
3057
3166
  const controller = new AbortController();
3058
3167
  activeControllerRef.current = controller;
3059
3168
  try {
@@ -3090,6 +3199,7 @@ function App({ initialCfg }) {
3090
3199
  }
3091
3200
  } finally {
3092
3201
  setBusy(false);
3202
+ setTurnStartedAt(null);
3093
3203
  activeControllerRef.current = null;
3094
3204
  }
3095
3205
  }, [cfg, busy, saveSessionSafe]);
@@ -3133,8 +3243,9 @@ function App({ initialCfg }) {
3133
3243
  "Do not call `tasks_set` for this. Just read what you need, then write the file."
3134
3244
  ].join("\n");
3135
3245
  setEvents((e) => [...e, { kind: "user", key: mkKey(), text: "/init" }]);
3136
- messagesRef.current.push({ role: "user", content: prompt });
3246
+ messagesRef.current.push({ role: "user", content: sanitizeString(prompt) });
3137
3247
  setBusy(true);
3248
+ setTurnStartedAt(Date.now());
3138
3249
  const controller = new AbortController();
3139
3250
  activeControllerRef.current = controller;
3140
3251
  try {
@@ -3229,6 +3340,7 @@ function App({ initialCfg }) {
3229
3340
  }
3230
3341
  } finally {
3231
3342
  setBusy(false);
3343
+ setTurnStartedAt(null);
3232
3344
  activeAsstIdRef.current = null;
3233
3345
  activeControllerRef.current = null;
3234
3346
  }
@@ -3273,11 +3385,12 @@ function App({ initialCfg }) {
3273
3385
  if (c === "/clear") {
3274
3386
  messagesRef.current = [messagesRef.current[0]];
3275
3387
  sessionIdRef.current = null;
3276
- setEvents([{ kind: "info", key: mkKey(), text: "conversation cleared" }]);
3388
+ setEvents([]);
3277
3389
  setUsage(null);
3278
3390
  setTasks([]);
3279
3391
  setTasksStartedAt(null);
3280
3392
  setTasksStartTokens(0);
3393
+ compactSuggestedRef.current = false;
3281
3394
  return true;
3282
3395
  }
3283
3396
  if (c === "/reasoning") {
@@ -3484,8 +3597,9 @@ use: /thinking low | medium | high`
3484
3597
  if (trimmed.startsWith("/") && handleSlash(trimmed)) return;
3485
3598
  const display = displayText?.trim() || trimmed;
3486
3599
  setEvents((e) => [...e, { kind: "user", key: mkKey(), text: display }]);
3487
- messagesRef.current.push({ role: "user", content: trimmed });
3600
+ messagesRef.current.push({ role: "user", content: sanitizeString(trimmed) });
3488
3601
  setBusy(true);
3602
+ setTurnStartedAt(Date.now());
3489
3603
  const controller = new AbortController();
3490
3604
  activeControllerRef.current = controller;
3491
3605
  try {
@@ -3599,13 +3713,14 @@ use: /thinking low | medium | high`
3599
3713
  }
3600
3714
  } finally {
3601
3715
  setBusy(false);
3716
+ setTurnStartedAt(null);
3602
3717
  activeAsstIdRef.current = null;
3603
3718
  activeControllerRef.current = null;
3604
3719
  }
3605
3720
  },
3606
3721
  [cfg, handleSlash, updateAssistant, updateTool, saveSessionSafe]
3607
3722
  );
3608
- useEffect3(() => {
3723
+ useEffect4(() => {
3609
3724
  if (!busy && queue.length > 0) {
3610
3725
  const next = queue[0];
3611
3726
  setQueue((q) => q.slice(1));
@@ -3632,20 +3747,18 @@ use: /thinking low | medium | high`
3632
3747
  },
3633
3748
  [busy, processMessage]
3634
3749
  );
3635
- useEffect3(() => {
3750
+ useEffect4(() => {
3751
+ if (compactSuggestedRef.current) return;
3636
3752
  if (usage && usage.prompt_tokens / CONTEXT_LIMIT >= AUTO_COMPACT_SUGGEST_PCT) {
3637
- setEvents((e) => {
3638
- const last = e[e.length - 1];
3639
- if (last?.kind === "info" && last.text.startsWith("context ")) return e;
3640
- return [
3641
- ...e,
3642
- {
3643
- kind: "info",
3644
- key: mkKey(),
3645
- text: `context ${Math.round(usage.prompt_tokens / CONTEXT_LIMIT * 100)}% full \u2014 run /compact to summarize older turns`
3646
- }
3647
- ];
3648
- });
3753
+ compactSuggestedRef.current = true;
3754
+ setEvents((e) => [
3755
+ ...e,
3756
+ {
3757
+ kind: "info",
3758
+ key: mkKey(),
3759
+ text: `context ${Math.round(usage.prompt_tokens / CONTEXT_LIMIT * 100)}% full \u2014 run /compact to summarize older turns`
3760
+ }
3761
+ ]);
3649
3762
  }
3650
3763
  }, [usage]);
3651
3764
  if (!cfg) {
@@ -3699,6 +3812,7 @@ use: /thinking low | medium | high`
3699
3812
  model: cfg.model,
3700
3813
  usage,
3701
3814
  thinking: busy,
3815
+ turnStartedAt,
3702
3816
  theme,
3703
3817
  mode,
3704
3818
  effort,
@@ -3767,6 +3881,7 @@ var init_app = __esm({
3767
3881
  init_system_prompt();
3768
3882
  init_compact();
3769
3883
  init_executor();
3884
+ init_messages();
3770
3885
  init_chat();
3771
3886
  init_status();
3772
3887
  init_permission();