@datename/fastcode-termux 0.1.6-beta → 0.1.8-beta.0

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.
Files changed (165) hide show
  1. package/LICENSE +4 -0
  2. package/README.md +50 -17
  3. package/dist/agent/approval-json.d.ts +43 -0
  4. package/dist/agent/approval-json.js +69 -0
  5. package/dist/agent/approval-native.d.ts +3 -0
  6. package/dist/agent/approval-native.js +165 -0
  7. package/dist/agent/approval-store.d.ts +16 -0
  8. package/dist/agent/approval-store.js +21 -0
  9. package/dist/agent/approval.d.ts +33 -0
  10. package/dist/agent/approval.js +40 -5
  11. package/dist/agent/compaction.d.ts +2 -0
  12. package/dist/agent/compaction.js +60 -17
  13. package/dist/agent/guards.d.ts +16 -16
  14. package/dist/agent/guards.js +978 -24
  15. package/dist/agent/policy/defaults.d.ts +16 -0
  16. package/dist/agent/policy/defaults.js +17 -0
  17. package/dist/agent/policy/egress.d.ts +32 -0
  18. package/dist/agent/policy/egress.js +280 -0
  19. package/dist/agent/process-tree.d.ts +20 -0
  20. package/dist/agent/process-tree.js +81 -10
  21. package/dist/agent/protocol.d.ts +20 -1
  22. package/dist/agent/protocol.js +165 -38
  23. package/dist/agent/registry.d.ts +3 -1
  24. package/dist/agent/registry.js +168 -11
  25. package/dist/agent/runner.d.ts +21 -0
  26. package/dist/agent/runner.js +44 -5
  27. package/dist/agent/shell-ast.d.ts +11 -0
  28. package/dist/agent/shell-ast.js +204 -0
  29. package/dist/agent/shell-session.d.ts +14 -0
  30. package/dist/agent/shell-session.js +251 -40
  31. package/dist/agent/tool-schemas.js +26 -7
  32. package/dist/agent/tools/append_file.js +3 -1
  33. package/dist/agent/tools/apply_patch.js +44 -4
  34. package/dist/agent/tools/copy_file.js +26 -3
  35. package/dist/agent/tools/delete_file.js +22 -3
  36. package/dist/agent/tools/grep.js +35 -4
  37. package/dist/agent/tools/make_dir.js +3 -1
  38. package/dist/agent/tools/move_path.d.ts +7 -0
  39. package/dist/agent/tools/move_path.js +31 -5
  40. package/dist/agent/tools/read_file.js +63 -3
  41. package/dist/agent/tools/read_many.js +21 -3
  42. package/dist/agent/tools/readers.d.ts +53 -1
  43. package/dist/agent/tools/readers.js +236 -26
  44. package/dist/agent/tools/replace_in_file.js +38 -5
  45. package/dist/agent/tools/shell.js +2 -2
  46. package/dist/agent/tools/types.d.ts +26 -0
  47. package/dist/agent/tools/validate.d.ts +15 -2
  48. package/dist/agent/tools/validate.js +66 -3
  49. package/dist/agent/tools/write_file.js +3 -1
  50. package/dist/app.d.ts +2 -1
  51. package/dist/app.js +526 -85
  52. package/dist/cli-main.js +363 -0
  53. package/dist/cli.js +397 -337
  54. package/dist/commands/about.js +1 -1
  55. package/dist/commands/clear.d.ts +1 -0
  56. package/dist/commands/clear.js +1 -1
  57. package/dist/commands/compact.d.ts +12 -0
  58. package/dist/commands/compact.js +14 -2
  59. package/dist/commands/context.js +5 -4
  60. package/dist/commands/index.js +2 -0
  61. package/dist/commands/jobs.js +3 -2
  62. package/dist/commands/kill.js +3 -1
  63. package/dist/commands/login.d.ts +2 -2
  64. package/dist/commands/login.js +15 -15
  65. package/dist/commands/model.d.ts +1 -13
  66. package/dist/commands/model.js +6 -38
  67. package/dist/commands/provider.d.ts +1 -15
  68. package/dist/commands/provider.js +4 -22
  69. package/dist/commands/skills.js +34 -4
  70. package/dist/commands/threads.d.ts +9 -0
  71. package/dist/commands/threads.js +26 -0
  72. package/dist/config/store.d.ts +21 -0
  73. package/dist/config/store.js +47 -9
  74. package/dist/config/trust.d.ts +9 -0
  75. package/dist/config/trust.js +41 -2
  76. package/dist/headless.d.ts +7 -0
  77. package/dist/headless.js +213 -0
  78. package/dist/index.js +19 -3
  79. package/dist/lib/debug.d.ts +2 -0
  80. package/dist/lib/debug.js +22 -0
  81. package/dist/lib/diagnostics.d.ts +19 -0
  82. package/dist/lib/diagnostics.js +32 -6
  83. package/dist/lib/highlight-subset.d.ts +21 -0
  84. package/dist/lib/highlight-subset.js +88 -0
  85. package/dist/lib/lifecycle.d.ts +5 -0
  86. package/dist/lib/lifecycle.js +62 -0
  87. package/dist/lib/markdown.js +22 -4
  88. package/dist/providers/deepseek.js +1 -1
  89. package/dist/providers/openaiCompatible.d.ts +24 -2
  90. package/dist/providers/openaiCompatible.js +20 -6
  91. package/dist/providers/spec.d.ts +12 -0
  92. package/dist/providers/spec.js +29 -0
  93. package/dist/session/log.d.ts +14 -2
  94. package/dist/session/log.js +56 -27
  95. package/dist/session/projection.d.ts +34 -1
  96. package/dist/session/projection.js +315 -46
  97. package/dist/skills/manager.d.ts +15 -0
  98. package/dist/skills/manager.js +135 -18
  99. package/dist/types.d.ts +15 -0
  100. package/dist/ui/StatusBar.js +1 -1
  101. package/package.json +3 -2
  102. package/src/agent/approval-json.ts +103 -0
  103. package/src/agent/approval-native.ts +168 -0
  104. package/src/agent/approval-store.ts +38 -0
  105. package/src/agent/approval.ts +58 -11
  106. package/src/agent/compaction.ts +62 -17
  107. package/src/agent/guards.ts +1049 -25
  108. package/src/agent/policy/defaults.ts +34 -0
  109. package/src/agent/policy/egress.ts +335 -0
  110. package/src/agent/process-tree.ts +105 -23
  111. package/src/agent/protocol.ts +187 -39
  112. package/src/agent/registry.ts +186 -14
  113. package/src/agent/runner.ts +50 -7
  114. package/src/agent/shell-ast.ts +227 -0
  115. package/src/agent/shell-session.ts +265 -41
  116. package/src/agent/tool-schemas.ts +26 -7
  117. package/src/agent/tools/append_file.ts +3 -1
  118. package/src/agent/tools/apply_patch.ts +45 -4
  119. package/src/agent/tools/copy_file.ts +27 -3
  120. package/src/agent/tools/delete_file.ts +22 -3
  121. package/src/agent/tools/grep.ts +36 -4
  122. package/src/agent/tools/make_dir.ts +3 -1
  123. package/src/agent/tools/move_path.ts +38 -5
  124. package/src/agent/tools/read_file.ts +64 -3
  125. package/src/agent/tools/read_many.ts +21 -3
  126. package/src/agent/tools/readers.ts +289 -26
  127. package/src/agent/tools/replace_in_file.ts +39 -5
  128. package/src/agent/tools/shell.ts +2 -2
  129. package/src/agent/tools/types.ts +43 -1
  130. package/src/agent/tools/validate.ts +73 -4
  131. package/src/agent/tools/write_file.ts +3 -1
  132. package/src/app.ts +533 -82
  133. package/src/{cli.ts → cli-main.ts} +34 -6
  134. package/src/commands/about.ts +1 -1
  135. package/src/commands/clear.ts +1 -1
  136. package/src/commands/compact.ts +14 -2
  137. package/src/commands/context.ts +5 -4
  138. package/src/commands/index.ts +2 -0
  139. package/src/commands/jobs.ts +3 -2
  140. package/src/commands/kill.ts +3 -1
  141. package/src/commands/login.ts +15 -15
  142. package/src/commands/model.ts +6 -38
  143. package/src/commands/provider.ts +4 -22
  144. package/src/commands/skills.ts +34 -4
  145. package/src/commands/threads.ts +28 -0
  146. package/src/config/store.ts +47 -9
  147. package/src/config/trust.ts +45 -2
  148. package/src/headless.ts +225 -0
  149. package/src/index.ts +19 -3
  150. package/src/lib/debug.ts +24 -0
  151. package/src/lib/diagnostics.ts +32 -6
  152. package/src/lib/highlight-subset.ts +93 -0
  153. package/src/lib/lifecycle.ts +69 -0
  154. package/src/lib/markdown.ts +22 -4
  155. package/src/providers/deepseek.ts +1 -1
  156. package/src/providers/openaiCompatible.ts +47 -10
  157. package/src/providers/spec.ts +42 -0
  158. package/src/session/log.ts +70 -27
  159. package/src/session/projection.ts +360 -47
  160. package/src/skills/manager.ts +140 -18
  161. package/src/types/highlight-shims.d.ts +14 -0
  162. package/src/types.ts +16 -1
  163. package/src/ui/StatusBar.ts +1 -1
  164. package/tsconfig.json +1 -0
  165. /package/dist/{cli.d.ts → cli-main.d.ts} +0 -0
package/dist/app.js CHANGED
@@ -12,11 +12,13 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
12
12
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
13
13
  import { Box, Static, Text, useApp, useInput } from "ink";
14
14
  import { AGENT_SYSTEM_PROMPT, stripToolCalls, } from "./agent/protocol.js";
15
- import { compactionConfigFromEnv } from "./agent/compaction.js";
15
+ import { compactionConfigFromEnv, removeSummaryFile } from "./agent/compaction.js";
16
16
  import { resolveToolProtocol, withToolCapability } from "./agent/protocol-mode.js";
17
17
  import { runAgent } from "./agent/runner.js";
18
+ import { decisionForOption, parseApprovalChoice } from "./agent/approval.js";
19
+ import { nativeApproval } from "./agent/approval-native.js";
18
20
  import { toolSchemas } from "./agent/tool-schemas.js";
19
- import { shutdownShells } from "./agent/shell-session.js";
21
+ import { setShellPoolSize as applyShellPoolSize, shutdownShells, shutdownShellsAsync } from "./agent/shell-session.js";
20
22
  import { Banner } from "./ui/Banner.js";
21
23
  import { Spinner } from "./ui/Spinner.js";
22
24
  import { StatusBar } from "./ui/StatusBar.js";
@@ -24,35 +26,19 @@ import { MessageBubble } from "./ui/Message.js";
24
26
  import { Prompt } from "./ui/Prompt.js";
25
27
  import { Picker } from "./ui/Picker.js";
26
28
  import { COMMANDS, findCommand, parseSlash } from "./commands/index.js";
27
- import { saveScopedKey } from "./commands/login.js";
28
- import { getProvider, getProviderOrThrow, PROVIDERS } from "./providers/index.js";
29
- import { loadConfig, saveConfig, resolveApiKey, getLegacy, } from "./config/store.js";
29
+ import { maskLoginEcho, saveScopedKey } from "./commands/login.js";
30
+ import { isValidModelId } from "./commands/model.js";
31
+ import { getProvider, getProviderOrThrow, probeProvider, PROVIDERS } from "./providers/index.js";
32
+ import { loadConfig, saveConfig, resolveApiKey, resolveProviderKey, getLegacy, } from "./config/store.js";
30
33
  import { getTheme } from "./config/themes.js";
31
34
  import { createStartupTranscript } from "./lib/startup.js";
32
35
  import { shouldUseLiveStreamingUi, shouldUseStableTerminalUi } from "./lib/uiMode.js";
33
36
  import { buildPluginsPrompt } from "./plugins/manager.js";
34
37
  import { buildSkillsPrompt } from "./skills/manager.js";
35
- import { createSessionLog, latestUnclosedSession, loggingEnabled, openSessionLog } from "./session/log.js";
38
+ import { createSessionLog, latestUnclosedSession, loggingEnabled, openSessionLog, staleSummaryRefs } from "./session/log.js";
36
39
  import { resumeSession } from "./session/projection.js";
37
40
  import { isTrusted, trustProject } from "./config/trust.js";
38
41
  import { TrustGate } from "./ui/TrustGate.js";
39
- /**
40
- * Mask the secret part of a `/login` invocation in the transcript:
41
- * `/login prov` → unchanged, `/login prov <key>` → key masked,
42
- * `/login prov model <key>` → provider/model shown, key masked.
43
- */
44
- function maskLoginEcho(args) {
45
- const parts = ["/login", args[0] ?? ""];
46
- if (args.length === 2) {
47
- parts.push("********");
48
- }
49
- else if (args.length >= 3) {
50
- parts.push(args[1]);
51
- for (let i = 2; i < args.length; i += 1)
52
- parts.push("********");
53
- }
54
- return parts.filter((p) => p !== "").join(" ");
55
- }
56
42
  function positiveInt(value, fallback) {
57
43
  return typeof value === "number" && Number.isFinite(value) && value > 0 ? Math.floor(value) : fallback;
58
44
  }
@@ -74,6 +60,17 @@ function optionalBudget(name) {
74
60
  const value = Number(process.env[name]);
75
61
  return Number.isFinite(value) && value > 0 ? value : undefined;
76
62
  }
63
+ function uniqueModelList(models) {
64
+ const seen = new Set();
65
+ const result = [];
66
+ for (const model of models) {
67
+ if (!model?.id || seen.has(model.id))
68
+ continue;
69
+ seen.add(model.id);
70
+ result.push(model);
71
+ }
72
+ return result;
73
+ }
77
74
  function unclosedNotice(session) {
78
75
  const where = session.cwd || "(unknown directory)";
79
76
  const files = session.activeFiles?.length ?? 0;
@@ -91,7 +88,7 @@ function streamErrorMessage(message, provider, model) {
91
88
  }
92
89
  return `Error: ${message}`;
93
90
  }
94
- export const App = ({ initialPrompt, resume }) => {
91
+ export const App = ({ initialPrompt, resume, ui = "tty" }) => {
95
92
  const { exit } = useApp();
96
93
  const startupRef = useRef(null);
97
94
  if (!startupRef.current) {
@@ -166,6 +163,11 @@ export const App = ({ initialPrompt, resume }) => {
166
163
  const [pendingLogin, setPendingLogin] = useState(null);
167
164
  const [picker, setPicker] = useState(null);
168
165
  const [pickerIndex, setPickerIndex] = useState(0);
166
+ const modelSetupIdRef = useRef(0);
167
+ const openModelSetupPickerRef = useRef(null);
168
+ const plainApprovalRef = useRef(null);
169
+ const [approvalFeedbackPrompt, setApprovalFeedbackPrompt] = useState(null);
170
+ const approvalFeedbackRef = useRef(null);
169
171
  const [busy, setBusy] = useState(false);
170
172
  const [expanded, setExpanded] = useState(false);
171
173
  const steerQueueRef = useRef([]);
@@ -178,22 +180,59 @@ export const App = ({ initialPrompt, resume }) => {
178
180
  const sawDeltaRef = useRef(false);
179
181
  const lastStreamFlushRef = useRef(0);
180
182
  const ctrlCRef = useRef(0);
183
+ const exitConfirmRef = useRef(0);
184
+ const busyRef = useRef(false);
181
185
  const initialPromptSubmittedRef = useRef(false);
186
+ const quittingRef = useRef(false);
187
+ const quitCleanly = useCallback(async () => {
188
+ if (quittingRef.current)
189
+ return;
190
+ quittingRef.current = true;
191
+ if (abortRef.current) {
192
+ abortRef.current.abort();
193
+ abortRef.current = null;
194
+ }
195
+ // Give the aborted run a chance to append its final facts before the
196
+ // session save point is closed.
197
+ const idleDeadline = Date.now() + 1000;
198
+ while (busyRef.current && Date.now() < idleDeadline)
199
+ await new Promise((resolve) => setTimeout(resolve, 20));
200
+ try {
201
+ await shutdownShellsAsync(400);
202
+ }
203
+ catch {
204
+ shutdownShells();
205
+ }
206
+ try {
207
+ sessionRef.current?.close?.({ reason: "exit" });
208
+ }
209
+ catch {
210
+ // Session close is best effort during exit.
211
+ }
212
+ exit();
213
+ }, [exit]);
214
+ useEffect(() => {
215
+ applyShellPoolSize(config.shellPoolSize);
216
+ }, [config.shellPoolSize]);
182
217
  const theme = getTheme(config.theme);
183
218
  const stableUi = useMemo(() => shouldUseStableTerminalUi(), []);
184
219
  const liveStreamingUi = useMemo(() => shouldUseLiveStreamingUi(), []);
185
220
  // The banner picks a font that fits the terminal width (see ui/Banner.ts)
186
221
  // and renders nothing when even the smallest font is too wide.
187
222
  const showBanner = transcript.length <= startup.transcript.length && !busy;
188
- const promptPlaceholder = pendingLogin
189
- ? pendingLogin.step === "provider"
190
- ? "Provider id (e.g. deepseek) · Esc to cancel"
191
- : pendingLogin.step === "model"
192
- ? "Model id (e.g. deepseek-chat) · Esc to cancel"
193
- : pendingLogin.provider && getLegacy(pendingLogin.provider)
194
- ? "Paste API key (masked), or type 'keep' · Esc to cancel"
195
- : "Paste API key (masked) · Esc to cancel"
196
- : undefined;
223
+ const promptPlaceholder = approvalFeedbackPrompt
224
+ ? `${approvalFeedbackPrompt} · Esc to cancel`
225
+ : pendingLogin
226
+ ? pendingLogin.step === "loading-models"
227
+ ? "Loading available models…"
228
+ : pendingLogin.step === "model-manual"
229
+ ? "Model id (e.g. deepseek-flash) · Esc to cancel"
230
+ : pendingLogin.hasExistingKey
231
+ ? "API key (masked), or Enter to keep existing key · Esc to cancel"
232
+ : pendingLogin.provider && getLegacy(pendingLogin.provider)
233
+ ? "API key (masked), or type 'keep' · Esc to cancel"
234
+ : "Paste API key (masked) · Esc to cancel"
235
+ : undefined;
197
236
  const promptMask = pendingLogin?.step === "key" ? "*" : undefined;
198
237
  // Persist config whenever it changes.
199
238
  useEffect(() => {
@@ -208,11 +247,14 @@ export const App = ({ initialPrompt, resume }) => {
208
247
  // Esc cancels streaming. Double Ctrl+C quits.
209
248
  useInput((inputChar, key) => {
210
249
  if (picker) {
211
- if (key.escape) {
212
- picker.onCancel?.();
250
+ if (key.escape || (key.ctrl && inputChar === "c")) {
251
+ const onCancel = picker.onCancel;
213
252
  setPicker(null);
253
+ onCancel?.();
214
254
  return;
215
255
  }
256
+ if (picker.items.length === 0)
257
+ return;
216
258
  if (key.upArrow) {
217
259
  setPickerIndex((i) => Math.max(0, i - 1));
218
260
  return;
@@ -231,11 +273,20 @@ export const App = ({ initialPrompt, resume }) => {
231
273
  }
232
274
  return;
233
275
  }
234
- if (key.escape) {
276
+ if (key.escape || (key.ctrl && inputChar === "c" && (approvalFeedbackRef.current || plainApprovalRef.current || pendingLogin))) {
277
+ if (approvalFeedbackRef.current) {
278
+ approvalFeedbackRef.current.cancel();
279
+ return;
280
+ }
281
+ if (plainApprovalRef.current) {
282
+ plainApprovalRef.current.cancel();
283
+ return;
284
+ }
235
285
  if (pendingLogin) {
286
+ modelSetupIdRef.current += 1;
236
287
  setPendingLogin(null);
237
288
  setInput("");
238
- pushSystem("API key entry cancelled.");
289
+ pushSystem("Model configuration cancelled. Existing settings were not changed.");
239
290
  return;
240
291
  }
241
292
  if (abortRef.current) {
@@ -246,8 +297,19 @@ export const App = ({ initialPrompt, resume }) => {
246
297
  }
247
298
  if (key.ctrl && inputChar === "c") {
248
299
  ctrlCRef.current += 1;
300
+ if (busy && ctrlCRef.current === 1) {
301
+ if (abortRef.current) {
302
+ abortRef.current.abort();
303
+ abortRef.current = null;
304
+ }
305
+ pushSystem("Cancelled current run; partial output kept. Press Ctrl+C again to quit, or type 继续 to continue.");
306
+ setTimeout(() => {
307
+ ctrlCRef.current = 0;
308
+ }, 1500);
309
+ return;
310
+ }
249
311
  if (ctrlCRef.current >= 2) {
250
- exit();
312
+ void quitCleanly();
251
313
  }
252
314
  else {
253
315
  pushSystem("Press Ctrl+C again to quit.");
@@ -275,6 +337,325 @@ export const App = ({ initialPrompt, resume }) => {
275
337
  { id: idRef.current, role: "user", content: text, markdown: false },
276
338
  ]);
277
339
  }, []);
340
+ const finishModelSetup = useCallback((setup, providerId, modelId, keyReceipt = undefined) => {
341
+ if (setup.id !== modelSetupIdRef.current)
342
+ return;
343
+ const provider = getProvider(providerId);
344
+ if (!provider) {
345
+ setPendingLogin(null);
346
+ setInput("");
347
+ pushSystem("Provider is no longer available. Configuration was not changed.");
348
+ return;
349
+ }
350
+ setConfig((current) => ({ ...current, providerId: provider.id, model: modelId }));
351
+ setPendingLogin(null);
352
+ setInput("");
353
+ modelSetupIdRef.current += 1;
354
+ pushSystem(keyReceipt
355
+ ? `${keyReceipt}\nActive model: ${provider.id} · ${modelId}.`
356
+ : `Provider/model set to ${provider.id} · ${modelId}. ${provider.authEnv.length === 0 ? "No API key is required." : ""}`);
357
+ }, [pushSystem]);
358
+ const continueModelSetup = useCallback((setup, providerId, modelId) => {
359
+ if (setup.id !== modelSetupIdRef.current)
360
+ return;
361
+ const provider = getProvider(providerId);
362
+ if (!provider) {
363
+ setPendingLogin(null);
364
+ pushSystem("Unknown provider. Configuration was not changed.");
365
+ return;
366
+ }
367
+ if (provider.authEnv.length === 0) {
368
+ finishModelSetup(setup, provider.id, modelId);
369
+ return;
370
+ }
371
+ const existing = resolveApiKey(provider.id, modelId, provider.authEnv);
372
+ setPendingLogin({
373
+ ...setup,
374
+ step: "key",
375
+ provider: provider.id,
376
+ model: modelId,
377
+ hasExistingKey: existing.status === "ok",
378
+ });
379
+ setPicker(null);
380
+ setInput("");
381
+ pushSystem(`Selected ${provider.label} · ${modelId}. Enter the API key for this provider. ` +
382
+ "The key is provider-wide and will be reused for its models." +
383
+ (existing.status === "ok" ? " Press Enter on an empty input to keep the existing key." : ""));
384
+ }, [finishModelSetup, pushSystem]);
385
+ const openModelSetupPicker = useCallback(async (setup, step, providerId = undefined) => {
386
+ if (setup.id !== modelSetupIdRef.current)
387
+ return;
388
+ if (step === "provider") {
389
+ setPendingLogin({ ...setup, step: "provider", provider: undefined, model: undefined });
390
+ const currentProvider = setup.provider ?? config.providerId;
391
+ setPickerIndex(Math.max(0, PROVIDERS.findIndex((provider) => provider.id === currentProvider)));
392
+ setPicker({
393
+ title: "Configure provider · choose a provider",
394
+ current: currentProvider,
395
+ items: PROVIDERS.map((provider) => ({
396
+ value: provider.id,
397
+ label: `${provider.id.padEnd(12)} ${provider.label}`,
398
+ hint: provider.authEnv.length === 0 ? "no API key required" : provider.defaultModel,
399
+ })),
400
+ onSelect: (id) => {
401
+ const selected = getProvider(id);
402
+ if (!selected)
403
+ return;
404
+ const nextSetup = {
405
+ ...setup,
406
+ provider: selected.id,
407
+ model: selected.id === config.providerId ? config.model : selected.defaultModel,
408
+ };
409
+ void openModelSetupPickerRef.current?.(nextSetup, "model", selected.id);
410
+ },
411
+ onCancel: () => {
412
+ if (setup.id !== modelSetupIdRef.current)
413
+ return;
414
+ modelSetupIdRef.current += 1;
415
+ setPendingLogin(null);
416
+ setInput("");
417
+ pushSystem("Model configuration cancelled. Existing settings were not changed.");
418
+ },
419
+ });
420
+ return;
421
+ }
422
+ const provider = getProvider(providerId);
423
+ if (!provider) {
424
+ setPendingLogin(null);
425
+ pushSystem("Unknown provider. Configuration was not changed.");
426
+ return;
427
+ }
428
+ setPendingLogin({ ...setup, step: "loading-models", provider: provider.id });
429
+ setInput("");
430
+ setPickerIndex(0);
431
+ setPicker({
432
+ title: `Loading models · ${provider.label}`,
433
+ current: undefined,
434
+ items: [],
435
+ onSelect: () => { },
436
+ onCancel: () => {
437
+ if (setup.id !== modelSetupIdRef.current)
438
+ return;
439
+ modelSetupIdRef.current += 1;
440
+ setPendingLogin(null);
441
+ setInput("");
442
+ pushSystem("Model configuration cancelled. Existing settings were not changed.");
443
+ },
444
+ });
445
+ const { key } = resolveProviderKey(provider.id, provider.authEnv);
446
+ let models = [];
447
+ let online = false;
448
+ try {
449
+ models = uniqueModelList(await provider.listModels(key));
450
+ online = await probeProvider(provider, key);
451
+ }
452
+ catch {
453
+ online = false;
454
+ }
455
+ if (setup.id !== modelSetupIdRef.current)
456
+ return;
457
+ if (!models.some((model) => model.id === provider.defaultModel))
458
+ models.unshift({ id: provider.defaultModel, label: "provider default" });
459
+ const manualModelValue = "__fastcode_manual_model_id__";
460
+ const items = [
461
+ ...models.slice(0, 49).map((model) => ({
462
+ value: model.id,
463
+ label: model.id,
464
+ hint: model.label && model.label !== model.id ? model.label : undefined,
465
+ })),
466
+ { value: manualModelValue, label: "Enter model ID manually…" },
467
+ ];
468
+ const currentModel = setup.model ?? provider.defaultModel;
469
+ const currentIndex = items.findIndex((item) => item.value === currentModel);
470
+ setPendingLogin({ ...setup, step: "model", provider: provider.id, model: currentModel });
471
+ setPickerIndex(currentIndex >= 0 ? currentIndex : 0);
472
+ pushSystem(`Models for ${provider.label}: ${models.length}${online ? "" : " (offline — showing built-in/cached models)"}.`);
473
+ setPicker({
474
+ title: `Configure model · ${provider.label}${online ? "" : " · offline list"}`,
475
+ current: currentModel,
476
+ items,
477
+ onSelect: (id) => {
478
+ if (setup.id !== modelSetupIdRef.current)
479
+ return;
480
+ if (id === manualModelValue) {
481
+ setPendingLogin({ ...setup, step: "model-manual", provider: provider.id });
482
+ setInput("");
483
+ pushSystem("Enter a model ID (letters, numbers, . _ : / + -; up to 200 characters).");
484
+ return;
485
+ }
486
+ continueModelSetup(setup, provider.id, id);
487
+ },
488
+ onCancel: () => {
489
+ if (setup.id === modelSetupIdRef.current)
490
+ void openModelSetupPickerRef.current?.(setup, "provider");
491
+ },
492
+ });
493
+ }, [config.model, config.providerId, continueModelSetup, pushSystem]);
494
+ openModelSetupPickerRef.current = openModelSetupPicker;
495
+ const startModelSetup = useCallback(({ provider, model, flow = "login" }) => {
496
+ const setup = {
497
+ id: ++modelSetupIdRef.current,
498
+ flow,
499
+ originalProvider: config.providerId,
500
+ originalModel: config.model,
501
+ provider,
502
+ model,
503
+ };
504
+ setPendingLogin(setup);
505
+ setInput("");
506
+ if (flow === "model" && provider) {
507
+ void openModelSetupPicker(setup, "model", provider);
508
+ }
509
+ else if (provider && model) {
510
+ continueModelSetup(setup, provider, model);
511
+ }
512
+ else if (provider) {
513
+ void openModelSetupPicker(setup, "model", provider);
514
+ }
515
+ else {
516
+ void openModelSetupPicker(setup, "provider");
517
+ }
518
+ }, [config.model, config.providerId, continueModelSetup, openModelSetupPicker]);
519
+ const requestApproval = useCallback(async (request) => {
520
+ const defaultDecision = () => {
521
+ const option = request.options[request.defaultIndex ?? 0];
522
+ if (!option || option.kind === "allow")
523
+ return { kind: "deny", reason: "approval timeout/cancel (fail closed)" };
524
+ return decisionForOption(option);
525
+ };
526
+ if (ui === "native" && process.platform !== "win32") {
527
+ const native = await nativeApproval(request, abortRef.current?.signal);
528
+ if (native)
529
+ return native;
530
+ }
531
+ const preview = request.preview?.kind === "diff" && request.preview.text
532
+ ? `\n\n${request.preview.text.slice(0, 4000)}`
533
+ : "";
534
+ pushSystem(`[approval] Tier ${request.tier ?? 2} · ${request.tool}\n${request.prompt ?? "Approval required"}${preview}`);
535
+ if (ui === "json") {
536
+ return { kind: "deny", reason: "JSON approval mode requires the headless protocol channel (not wired in this build)" };
537
+ }
538
+ const approvalSignal = abortRef.current?.signal;
539
+ if (ui === "plain") {
540
+ return await new Promise((resolve) => {
541
+ let settled = false;
542
+ let timer;
543
+ const onAbort = () => finish({ kind: "deny", reason: "run cancelled" });
544
+ const finish = (decision) => {
545
+ if (settled)
546
+ return;
547
+ settled = true;
548
+ if (timer)
549
+ clearTimeout(timer);
550
+ approvalSignal?.removeEventListener("abort", onAbort);
551
+ plainApprovalRef.current = null;
552
+ resolve(decision);
553
+ };
554
+ approvalSignal?.addEventListener("abort", onAbort, { once: true });
555
+ const lines = request.options.map((option, index) => `${index + 1}. ${option.label}`);
556
+ pushSystem(`Choose an option:\n${lines.join("\n")}\nEnter a number (1-${request.options.length}); Esc cancels.`);
557
+ plainApprovalRef.current = {
558
+ request,
559
+ submit: (text) => {
560
+ const option = parseApprovalChoice(request, text);
561
+ if (!option) {
562
+ pushSystem(`Invalid approval choice. Enter 1-${request.options.length}.`);
563
+ return;
564
+ }
565
+ if (option.kind === "feedback") {
566
+ const prompt = option.prompt || "Why should this be denied?";
567
+ pushSystem(`${prompt}\nType your reason and press Enter; Esc cancels.`);
568
+ plainApprovalRef.current = {
569
+ request,
570
+ submit: (feedbackText) => {
571
+ const feedback = feedbackText.trim();
572
+ if (!feedback) {
573
+ pushSystem("Feedback cannot be empty. Type a reason, or Esc to cancel.");
574
+ return;
575
+ }
576
+ finish({ kind: "feedback", text: feedback });
577
+ },
578
+ cancel: () => finish({ kind: "deny", reason: "cancelled during feedback" }),
579
+ };
580
+ return;
581
+ }
582
+ finish(decisionForOption(option));
583
+ },
584
+ cancel: () => finish({ kind: "deny", reason: "cancelled" }),
585
+ };
586
+ timer = setTimeout(() => {
587
+ pushSystem("Approval timed out; applying the most conservative option.");
588
+ finish(defaultDecision());
589
+ }, Math.max(1000, request.timeoutMs ?? 60_000));
590
+ timer.unref?.();
591
+ });
592
+ }
593
+ return await new Promise((resolve) => {
594
+ let settled = false;
595
+ let timer;
596
+ const onAbort = () => finish({ kind: "deny", reason: "run cancelled" });
597
+ const finish = (decision) => {
598
+ if (settled)
599
+ return;
600
+ settled = true;
601
+ if (timer)
602
+ clearTimeout(timer);
603
+ approvalSignal?.removeEventListener("abort", onAbort);
604
+ if (approvalFeedbackRef.current) {
605
+ approvalFeedbackRef.current = null;
606
+ setApprovalFeedbackPrompt(null);
607
+ }
608
+ setPicker(null);
609
+ resolve(decision);
610
+ };
611
+ approvalSignal?.addEventListener("abort", onAbort, { once: true });
612
+ const items = request.options.map((option, index) => ({
613
+ value: option.id ?? String(index),
614
+ label: option.label,
615
+ }));
616
+ const defaultIndex = Math.max(0, Math.min(request.defaultIndex ?? 0, items.length - 1));
617
+ setPickerIndex(defaultIndex);
618
+ setPicker({
619
+ title: `Approval · Tier ${request.tier ?? 2}`,
620
+ items,
621
+ current: items[defaultIndex]?.value,
622
+ onSelect: (value) => {
623
+ const index = items.findIndex((item) => item.value === value);
624
+ const option = request.options[index];
625
+ if (!option) {
626
+ finish({ kind: "deny", reason: "unknown approval option" });
627
+ return;
628
+ }
629
+ if (option.kind === "feedback") {
630
+ const prompt = option.prompt || "Why should this be denied?";
631
+ setPicker(null);
632
+ setInput("");
633
+ setApprovalFeedbackPrompt(prompt);
634
+ pushSystem(`${prompt}\nType your reason and press Enter; Esc cancels.`);
635
+ approvalFeedbackRef.current = {
636
+ submit: (text) => {
637
+ const feedback = text.trim();
638
+ if (!feedback) {
639
+ pushSystem("Feedback cannot be empty. Type a reason, or Esc to cancel.");
640
+ return;
641
+ }
642
+ finish({ kind: "feedback", text: feedback });
643
+ },
644
+ cancel: () => finish({ kind: "deny", reason: "cancelled during feedback" }),
645
+ };
646
+ return;
647
+ }
648
+ finish(decisionForOption(option));
649
+ },
650
+ onCancel: () => finish({ kind: "deny", reason: "cancelled" }),
651
+ });
652
+ timer = setTimeout(() => {
653
+ pushSystem("Approval timed out; applying the most conservative option.");
654
+ finish(defaultDecision());
655
+ }, Math.max(1000, request.timeoutMs ?? 60_000));
656
+ timer.unref?.();
657
+ });
658
+ }, [pushSystem, ui]);
278
659
  const actions = useMemo(() => ({
279
660
  setProvider: (id) => setConfig((c) => ({ ...c, providerId: id })),
280
661
  setModel: (id) => setConfig((c) => ({ ...c, model: id })),
@@ -283,6 +664,10 @@ export const App = ({ initialPrompt, resume }) => {
283
664
  setAgentMode: (enabled) => setConfig((c) => ({ ...c, agentMode: enabled })),
284
665
  setSystemPrompt: (p) => setConfig((c) => ({ ...c, systemPrompt: p })),
285
666
  setSkillsRegistry: (url) => setConfig((c) => ({ ...c, skillsRegistry: url })),
667
+ setShellPoolSize: (size) => {
668
+ const applied = applyShellPoolSize(size);
669
+ setConfig((c) => ({ ...c, shellPoolSize: applied }));
670
+ },
286
671
  enableSkill: (name) => setConfig((c) => ({
287
672
  ...c,
288
673
  enabledSkills: Array.from(new Set([...(c.enabledSkills ?? []), name])),
@@ -316,14 +701,16 @@ export const App = ({ initialPrompt, resume }) => {
316
701
  setExpanded: (value) => setExpanded(value),
317
702
  setInput: (value) => setInput(value),
318
703
  pushSystemMessage: pushSystem,
319
- startLogin: ({ provider, model }) => {
320
- setPendingLogin({
321
- step: provider ? (model ? "key" : "model") : "provider",
322
- provider,
323
- model,
324
- });
325
- },
326
- }), [pushSystem]);
704
+ startLogin: (options) => startModelSetup(options),
705
+ }), [pushSystem, startModelSetup]);
706
+ const appendCompactionFact = useCallback((info) => {
707
+ const file = sessionRef.current?.file;
708
+ sessionRef.current.append({ type: "compacted", ...info });
709
+ if (typeof file === "string") {
710
+ for (const ref of staleSummaryRefs(file))
711
+ removeSummaryFile(ref);
712
+ }
713
+ }, []);
327
714
  const runSlash = useCallback(async (raw) => {
328
715
  const parsed = parseSlash(raw);
329
716
  if (!parsed)
@@ -333,7 +720,17 @@ export const App = ({ initialPrompt, resume }) => {
333
720
  pushSystem(`Unknown command "/${parsed.name}". Try /help.`);
334
721
  return true;
335
722
  }
336
- if (busy && !["cancel", "steer", "jobs", "kill", "attach", "expand", "context"].includes(cmd.name)) {
723
+ if (cmd.name === "exit" && busy) {
724
+ const now = Date.now();
725
+ if (now - exitConfirmRef.current > 5000) {
726
+ exitConfirmRef.current = now;
727
+ pushSystem("A run is active. /exit will stop it, keep the partial output, and save the session. " +
728
+ "Type /exit again within 5s to confirm, or /cancel to stop first.");
729
+ return true;
730
+ }
731
+ exitConfirmRef.current = 0;
732
+ }
733
+ if (busy && !["cancel", "steer", "jobs", "kill", "attach", "expand", "context", "threads", "exit"].includes(cmd.name)) {
337
734
  pushSystem(`/${cmd.name} is unavailable while a run is active. Try /cancel or /steer.`);
338
735
  return true;
339
736
  }
@@ -373,15 +770,22 @@ export const App = ({ initialPrompt, resume }) => {
373
770
  setPicker(res.picker);
374
771
  setInput("");
375
772
  }
773
+ if (res?.compacted)
774
+ appendCompactionFact(res.compacted);
775
+ if (res?.cleared)
776
+ sessionRef.current.append({ type: "cleared" });
376
777
  if (res?.quit)
377
- exit();
778
+ void quitCleanly();
378
779
  }
379
780
  catch (err) {
380
- pushSystem(`Command failed: ${err.message}`);
781
+ pushSystem(parsed.name === "login"
782
+ ? "Login failed. The credential was not included in this error message."
783
+ : `Command failed: ${err.message}`);
381
784
  }
382
785
  return true;
383
786
  }, [
384
787
  actions,
788
+ appendCompactionFact,
385
789
  config.agentMode,
386
790
  config.enabledPlugins,
387
791
  config.enabledSkills,
@@ -392,7 +796,7 @@ export const App = ({ initialPrompt, resume }) => {
392
796
  config.theme,
393
797
  busy,
394
798
  expanded,
395
- exit,
799
+ quitCleanly,
396
800
  history,
397
801
  pushSystem,
398
802
  pushUser,
@@ -404,6 +808,16 @@ export const App = ({ initialPrompt, resume }) => {
404
808
  const text = raw.trim();
405
809
  if (!text)
406
810
  return;
811
+ if (approvalFeedbackRef.current) {
812
+ approvalFeedbackRef.current.submit(text);
813
+ setInput("");
814
+ return;
815
+ }
816
+ if (plainApprovalRef.current) {
817
+ plainApprovalRef.current.submit(text);
818
+ setInput("");
819
+ return;
820
+ }
407
821
  if (busy) {
408
822
  if (text.startsWith("/")) {
409
823
  await runSlash(text);
@@ -443,6 +857,7 @@ export const App = ({ initialPrompt, resume }) => {
443
857
  lastStreamFlushRef.current = 0;
444
858
  const ctrl = new AbortController();
445
859
  abortRef.current = ctrl;
860
+ busyRef.current = true;
446
861
  setBusy(true);
447
862
  let receivedDone = false;
448
863
  try {
@@ -488,6 +903,7 @@ export const App = ({ initialPrompt, resume }) => {
488
903
  steerQueueRef.current = [];
489
904
  return queued;
490
905
  },
906
+ requestApproval,
491
907
  onDelta: (delta, full) => {
492
908
  if (!sawDeltaRef.current) {
493
909
  sawDeltaRef.current = true;
@@ -504,14 +920,14 @@ export const App = ({ initialPrompt, resume }) => {
504
920
  })) {
505
921
  switch (ev.type) {
506
922
  case "compaction":
507
- sessionRef.current.append({
508
- type: "compacted",
923
+ appendCompactionFact({
509
924
  summaryRef: ev.summaryFile,
510
925
  source: ev.source,
511
926
  beforeTokens: ev.beforeTokens,
512
927
  afterTokens: ev.afterTokens,
513
928
  droppedMessages: ev.droppedMessages,
514
929
  keepMessages: ev.keptMessages,
930
+ reason: ev.reason === "heap" ? "heap" : "threshold",
515
931
  });
516
932
  if (!compactionNotified) {
517
933
  compactionNotified = true;
@@ -540,6 +956,16 @@ export const App = ({ initialPrompt, resume }) => {
540
956
  ok: ev.ok,
541
957
  output: ev.output,
542
958
  sideEffects: ev.sideEffects,
959
+ ...(ev.code ? { code: ev.code } : {}),
960
+ ...(ev.hint ? { hint: ev.hint } : {}),
961
+ ...(ev.truncated !== undefined ? { truncated: ev.truncated } : {}),
962
+ ...(ev.totalLines !== undefined ? { totalLines: ev.totalLines } : {}),
963
+ ...(ev.returnedLines !== undefined ? { returnedLines: ev.returnedLines } : {}),
964
+ ...(ev.totalBytes !== undefined ? { totalBytes: ev.totalBytes } : {}),
965
+ ...(ev.returnedBytes !== undefined ? { returnedBytes: ev.returnedBytes } : {}),
966
+ ...(ev.filesRead !== undefined ? { filesRead: ev.filesRead } : {}),
967
+ ...(ev.filesSkipped !== undefined ? { filesSkipped: ev.filesSkipped } : {}),
968
+ ...(ev.matches !== undefined ? { matches: ev.matches } : {}),
543
969
  });
544
970
  const collapsed = stableUi && !expanded;
545
971
  const body = collapsed ? collapseToolOutput(ev.output) : ev.output;
@@ -558,6 +984,25 @@ export const App = ({ initialPrompt, resume }) => {
558
984
  case "loop_detected":
559
985
  pushSystem(`Loop detected: \`${ev.fingerprint.split(":")[0]}\` repeated ${ev.occurrences} times. Asking the model to change approach.`);
560
986
  break;
987
+ case "egress_denied": {
988
+ idRef.current += 1;
989
+ sessionRef.current.append({
990
+ type: "egress_denied",
991
+ outlet: ev.outlet,
992
+ target: ev.target,
993
+ rule: ev.rule,
994
+ mode: ev.mode,
995
+ wouldBlock: ev.wouldBlock,
996
+ code: ev.code,
997
+ reason: ev.reason,
998
+ });
999
+ const target = ev.target ? ` target=${JSON.stringify(ev.target)}` : "";
1000
+ setTranscript((prev) => [
1001
+ ...prev,
1002
+ { id: idRef.current, role: "system", content: `[egress] ${ev.mode} rule=${ev.rule}${target} would_block=${ev.wouldBlock}\n${ev.reason}`, markdown: true },
1003
+ ]);
1004
+ break;
1005
+ }
561
1006
  case "notice":
562
1007
  pushSystem(ev.text);
563
1008
  break;
@@ -574,7 +1019,7 @@ export const App = ({ initialPrompt, resume }) => {
574
1019
  break;
575
1020
  case "error":
576
1021
  if (ev.kind === "aborted")
577
- pushSystem("Cancelled.");
1022
+ pushSystem("Cancelled current run; partial output kept. Type 继续 to continue.");
578
1023
  else if (ev.kind === "stream_error")
579
1024
  pushSystem(streamErrorMessage(ev.message, provider, config.model));
580
1025
  else
@@ -607,10 +1052,12 @@ export const App = ({ initialPrompt, resume }) => {
607
1052
  setStreamingText("");
608
1053
  setAgentActivity(null);
609
1054
  setStreamPhase("thinking");
1055
+ busyRef.current = false;
610
1056
  setBusy(false);
611
1057
  abortRef.current = null;
612
1058
  }
613
1059
  }, [
1060
+ appendCompactionFact,
614
1061
  busy,
615
1062
  config.agentMode,
616
1063
  config.enabledSkills,
@@ -621,6 +1068,7 @@ export const App = ({ initialPrompt, resume }) => {
621
1068
  history,
622
1069
  pushSystem,
623
1070
  pushUser,
1071
+ requestApproval,
624
1072
  runSlash,
625
1073
  ]);
626
1074
  // Guided `/login`: provider → model → key. Text prompts only; the key step
@@ -628,57 +1076,50 @@ export const App = ({ initialPrompt, resume }) => {
628
1076
  const handleWizardSubmit = useCallback(async (raw) => {
629
1077
  const value = raw.trim();
630
1078
  setInput("");
631
- if (!value || !pendingLogin)
1079
+ if (!pendingLogin)
632
1080
  return;
633
- if (pendingLogin.step === "provider") {
634
- const provider = getProvider(value);
635
- if (!provider) {
636
- pushSystem(`Unknown provider "${value}". Available: ${PROVIDERS.map((p) => p.id).join(", ")}`);
1081
+ if (pendingLogin.step === "model-manual") {
1082
+ if (!isValidModelId(value)) {
1083
+ pushSystem("Invalid model ID. Use 1-200 letters, numbers, or . _ : / + -; configuration was not changed.");
637
1084
  return;
638
1085
  }
639
- if (provider.authEnv.length === 0) {
640
- pushSystem(`${provider.label} doesn't require an API key. Nothing to save.`);
641
- setPendingLogin(null);
642
- return;
643
- }
644
- setPendingLogin({ step: "model", provider: provider.id });
645
- const models = await provider.listModels(undefined).catch(() => []);
646
- const names = models.slice(0, 8).map((m) => m.id).join(", ");
647
- pushSystem(`Provider: ${provider.label} (${provider.id}).\n` +
648
- `Type the model id to bind this key to — e.g. \`${provider.defaultModel}\`.` +
649
- (names ? `\n known models: ${names}` : ""));
1086
+ continueModelSetup(pendingLogin, pendingLogin.provider, value);
650
1087
  return;
651
1088
  }
652
- if (pendingLogin.step === "model") {
653
- const provider = getProvider(pendingLogin.provider);
654
- if (!provider) {
655
- setPendingLogin(null);
656
- return;
657
- }
658
- setPendingLogin({ step: "key", provider: provider.id, model: value });
659
- pushSystem(`Saved from model: ${provider.id} · ${value}.\n` +
660
- `This key will be reused by every ${provider.label} model.\n` +
661
- `Paste the API key (input is masked).` +
662
- (getLegacy(provider.id) ? `\nOr type \`keep\` to re-bind the stored ${provider.label} key.` : ""));
1089
+ if (pendingLogin.step !== "key")
663
1090
  return;
664
- }
665
1091
  const provider = getProvider(pendingLogin.provider);
666
1092
  if (!provider) {
667
1093
  setPendingLogin(null);
1094
+ pushSystem("Provider is no longer available. Configuration was not changed.");
1095
+ return;
1096
+ }
1097
+ if (!value) {
1098
+ if (pendingLogin.hasExistingKey) {
1099
+ finishModelSetup(pendingLogin, provider.id, pendingLogin.model);
1100
+ }
1101
+ else {
1102
+ pushSystem("An API key is required for this provider. Configuration was not changed.");
1103
+ }
668
1104
  return;
669
1105
  }
670
1106
  let key = value;
671
1107
  if (value.toLowerCase() === "keep") {
672
1108
  const legacy = getLegacy(provider.id);
673
1109
  if (!legacy) {
674
- pushSystem(`No stored ${provider.label} key to reuse — paste the key instead.`);
1110
+ pushSystem(`No stored ${provider.label} key to reuse. Paste the API key; configuration was not changed.`);
675
1111
  return;
676
1112
  }
677
1113
  key = legacy;
678
1114
  }
679
- pushSystem(saveScopedKey(provider.id, provider.label, pendingLogin.model, key));
680
- setPendingLogin(null);
681
- }, [pendingLogin, pushSystem]);
1115
+ try {
1116
+ const receipt = saveScopedKey(provider.id, provider.label, pendingLogin.model, key);
1117
+ finishModelSetup(pendingLogin, provider.id, pendingLogin.model, receipt);
1118
+ }
1119
+ catch {
1120
+ pushSystem("Could not save the API key. Credential details were omitted; model settings were not changed.");
1121
+ }
1122
+ }, [continueModelSetup, finishModelSetup, pendingLogin, pushSystem]);
682
1123
  useEffect(() => {
683
1124
  if (!initialPrompt || initialPromptSubmittedRef.current)
684
1125
  return;