@giovannijecha/jecode 0.8.6 → 0.8.7

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 (67) hide show
  1. package/README.md +18 -8
  2. package/assets/tokenizers/LICENSE +21 -0
  3. package/assets/tokenizers/o200k-base.tiktoken.gz +0 -0
  4. package/dist/cli-info.js +8 -13
  5. package/dist/commands.js +7 -3
  6. package/dist/config.js +33 -47
  7. package/dist/context/automatic.js +16 -1
  8. package/dist/context/budget.js +5 -2
  9. package/dist/context/compactor.js +91 -37
  10. package/dist/context/diagnostics.js +108 -0
  11. package/dist/context/lifetime.js +18 -0
  12. package/dist/context/manager.js +67 -0
  13. package/dist/context/manual.js +11 -10
  14. package/dist/context/measurement.js +75 -0
  15. package/dist/context/policy.js +13 -10
  16. package/dist/context/request-observation.js +46 -0
  17. package/dist/context/request-projection.js +8 -34
  18. package/dist/context/request.js +22 -0
  19. package/dist/context/tokenizer/bpe.js +68 -0
  20. package/dist/context/tokenizer/o200k.js +54 -0
  21. package/dist/context/tokenizer/vocabulary.js +34 -0
  22. package/dist/controller-request.js +47 -45
  23. package/dist/controller.js +13 -2
  24. package/dist/input-boundary.js +0 -62
  25. package/dist/launch.js +13 -9
  26. package/dist/model-command.js +7 -17
  27. package/dist/oauth-result-page.js +68 -0
  28. package/dist/openai-oauth-callback.js +2 -50
  29. package/dist/permission-command.js +9 -24
  30. package/dist/permissions.js +10 -8
  31. package/dist/providers/anthropic-wire.js +1 -1
  32. package/dist/providers/anthropic.js +3 -1
  33. package/dist/providers/input-measurement.js +85 -0
  34. package/dist/providers/ollama-wire.js +1 -1
  35. package/dist/providers/ollama.js +2 -0
  36. package/dist/providers/openai-codex.js +3 -0
  37. package/dist/providers/openai-stream.js +20 -8
  38. package/dist/providers/openai-summary.js +37 -0
  39. package/dist/providers/openai-wire.js +1 -1
  40. package/dist/providers/openai.js +3 -0
  41. package/dist/settings-command.js +5 -10
  42. package/dist/settings.js +1 -1
  43. package/dist/start.js +35 -51
  44. package/dist/timeline.js +2 -0
  45. package/dist/tui/activity.js +1 -1
  46. package/dist/tui/app-input.js +34 -17
  47. package/dist/tui/app-workflows.js +7 -2
  48. package/dist/tui/app.js +55 -28
  49. package/dist/tui/approve.js +3 -4
  50. package/dist/tui/blocks.js +5 -7
  51. package/dist/tui/command-workflow.js +14 -8
  52. package/dist/tui/components/command-menu.js +1 -1
  53. package/dist/tui/components/menu.js +19 -11
  54. package/dist/tui/components/status.js +1 -1
  55. package/dist/tui/frame.js +8 -3
  56. package/dist/tui/help.js +2 -1
  57. package/dist/tui/picker-layout.js +5 -1
  58. package/dist/tui/screen.js +7 -0
  59. package/dist/tui/session-view.js +0 -1
  60. package/dist/tui/transcript-grammar.js +1 -8
  61. package/dist/tui/transcript-view.js +4 -0
  62. package/dist/tui/turn-workflow.js +47 -76
  63. package/dist/tui/view.js +4 -4
  64. package/dist/ui/render.js +0 -4
  65. package/package.json +5 -1
  66. package/dist/batch-view.js +0 -42
  67. package/dist/batch.js +0 -269
@@ -1,18 +1,22 @@
1
1
  // One streamed provider request with a single safe context-overflow recovery.
2
- import { budgetRequestFromInputTokens, estimateRequestInputTokensResponsive, } from "./context/budget.js";
2
+ import { budgetRequestFromInputTokens, } from "./context/budget.js";
3
3
  import { isContextOverflow } from "./context/policy.js";
4
- import { projectToolResults, projectToolResultsNewest, toolResultProjectionBudget, } from "./context/request-projection.js";
5
- export async function requestAssistant(history, current, specs, options, events, signal) {
4
+ import { fitRequestInput } from "./context/request.js";
5
+ import { observePreparation, sendObserved } from "./context/request-observation.js";
6
+ export async function requestAssistant(history, current, specs, options, events, meter, signal) {
7
+ let preparing = performance.now();
6
8
  let policy = await options.contextPolicy();
7
- const prepared = await prepareContext(history, current, specs, options, events, policy, "budget", signal);
9
+ const prepared = await prepareContext(history, current, specs, options, events, meter, policy, "budget", signal);
8
10
  let context = prepared.context;
9
11
  let requestMessages = prepared.requestMessages;
10
12
  let inputTokens = prepared.inputTokens;
13
+ let measurement = prepared.measurement;
14
+ let clippedResults = prepared.clippedResults;
11
15
  let recovered = false;
12
16
  for (;;) {
13
17
  const budget = budgetRequestFromInputTokens(inputTokens, options.maxTokens, policy);
14
18
  try {
15
- const message = await options.provider.send({
19
+ const message = await sendObserved(options.provider, {
16
20
  model: options.model,
17
21
  system: options.system,
18
22
  messages: requestMessages,
@@ -25,59 +29,57 @@ export async function requestAssistant(history, current, specs, options, events,
25
29
  signal,
26
30
  onStream: (event) => events.onStream(event),
27
31
  onStatus: (status) => events.onStatus?.(status),
28
- });
29
- return { message, context, inputTokens: budget.inputTokens };
32
+ }, measurement, policy, performance.now() - preparing, clippedResults);
33
+ return { message, context, inputTokens: budget.inputTokens, measurement };
30
34
  }
31
35
  catch (error) {
32
- if (recovered)
36
+ if (recovered || !isContextOverflow(error))
33
37
  throw error;
34
- if (isContextOverflow(error))
35
- policy = await options.contextPolicy();
36
- const next = await prepareContext(history, context, specs, options, events, policy, "overflow", signal, error);
38
+ preparing = performance.now();
39
+ policy = await options.contextPolicy();
40
+ const next = await prepareContext(history, context, specs, options, events, meter, policy, "overflow", signal, error);
37
41
  if (sameContext(next.context, context))
38
42
  throw error;
39
43
  context = next.context;
40
44
  requestMessages = next.requestMessages;
41
45
  inputTokens = next.inputTokens;
46
+ measurement = next.measurement;
47
+ clippedResults = next.clippedResults;
42
48
  recovered = true;
43
49
  }
44
50
  }
45
51
  }
46
- async function prepareContext(history, context, specs, options, events, policy, reason, signal, error) {
47
- const projectionBudget = toolResultProjectionBudget(policy);
48
- const initialProjection = projectToolResults(context, projectionBudget);
49
- const initialRequest = initialProjection.messages;
50
- const inputTokens = await estimateRequestInputTokensResponsive({
51
- system: options.system,
52
- messages: initialRequest,
53
- tools: specs,
54
- }, signal);
55
- const projected = await events.onContext?.(history, context, {
56
- reason,
57
- policy,
58
- inputTokens,
59
- projectionSaturated: initialProjection.saturated,
60
- ...(error === undefined ? {} : { error }),
52
+ async function prepareContext(history, context, specs, options, events, meter, policy, reason, signal, error) {
53
+ return observePreparation(policy, reason, signal, async () => {
54
+ const input = {
55
+ model: options.model,
56
+ effort: options.effort,
57
+ system: options.system,
58
+ messages: [...context],
59
+ tools: specs,
60
+ };
61
+ const initial = await meter.measure(input, signal);
62
+ const projected = await events.onContext?.(history, context, {
63
+ reason,
64
+ policy,
65
+ inputTokens: initial.inputTokens,
66
+ ...(error === undefined ? {} : { error }),
67
+ });
68
+ const semantic = projected === undefined ? clone(context) : clone(projected);
69
+ if (projected !== undefined)
70
+ meter.reset();
71
+ const semanticInput = { ...input, messages: semantic };
72
+ const measurement = projected === undefined ? initial : await meter.measure(semanticInput, signal);
73
+ const fitted = await fitRequestInput(semanticInput, meter, policy, measurement, signal);
74
+ budgetRequestFromInputTokens(fitted.measurement.inputTokens, options.maxTokens, policy);
75
+ return {
76
+ context: semantic,
77
+ requestMessages: fitted.messages,
78
+ inputTokens: fitted.measurement.inputTokens,
79
+ measurement: fitted.measurement,
80
+ clippedResults: fitted.clippedResults,
81
+ };
61
82
  });
62
- const semantic = projected === undefined ? clone(context) : clone(projected);
63
- const stableProjection = projected === undefined
64
- ? initialProjection
65
- : projectToolResults(semantic, projectionBudget);
66
- const requestMessages = stableProjection.saturated
67
- ? projectToolResultsNewest(semantic, projectionBudget).messages
68
- : stableProjection.messages;
69
- const canReuseEstimate = projected === undefined && !stableProjection.saturated;
70
- return {
71
- context: semantic,
72
- requestMessages,
73
- inputTokens: canReuseEstimate
74
- ? inputTokens
75
- : await estimateRequestInputTokensResponsive({
76
- system: options.system,
77
- messages: requestMessages,
78
- tools: specs,
79
- }, signal),
80
- };
81
83
  }
82
84
  function clone(messages) {
83
85
  return structuredClone([...messages]);
@@ -6,6 +6,7 @@
6
6
  import { isToolCall } from "./types.js";
7
7
  import { findTool, runTool, toolSpecs } from "./tools/index.js";
8
8
  import { requestAssistant } from "./controller-request.js";
9
+ import { inputMeter } from "./context/measurement.js";
9
10
  export const MAX_TOOL_CALLS_PER_RESPONSE = 32;
10
11
  /** Independent read calls share one bounded execution wave. */
11
12
  export const MAX_CONCURRENT_TOOL_CALLS = 4;
@@ -16,6 +17,7 @@ export const MAX_CONCURRENT_TOOL_CALLS = 4;
16
17
  */
17
18
  export async function runTurn(history, options, events, signal, modelHistory = history) {
18
19
  const specs = toolSpecs(options.tools);
20
+ const meter = options.inputMeter ?? inputMeter(options.provider);
19
21
  let context = modelHistory;
20
22
  throwIfAborted(signal);
21
23
  const append = (message) => {
@@ -43,13 +45,13 @@ export async function runTurn(history, options, events, signal, modelHistory = h
43
45
  if (options.maxModelRequests !== undefined &&
44
46
  requests >= options.maxModelRequests) {
45
47
  const requestLabel = options.maxModelRequests === 1 ? "request" : "requests";
46
- throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (--max-steps limit reached)`);
48
+ throw new Error(`stopped after ${options.maxModelRequests} model ${requestLabel} (request budget reached)`);
47
49
  }
48
50
  appendSteering(options.steering?.drain() ?? []);
49
51
  requests++;
50
52
  // The message is displayed as it streams; what comes back here is the
51
53
  // assembled version, which exists to be appended to the history.
52
- const response = await requestAssistant(history, context, specs, options, events, signal);
54
+ const response = await requestAssistant(history, context, specs, options, events, meter, signal);
53
55
  const assistant = response.message;
54
56
  context = response.context;
55
57
  throwIfAborted(signal);
@@ -63,6 +65,7 @@ export async function runTurn(history, options, events, signal, modelHistory = h
63
65
  assertToolCallIds(calls);
64
66
  if (assistant.usage !== undefined)
65
67
  events.onUsage?.(assistant.usage);
68
+ meter.observe(response.measurement, assistant.usage?.inputTokens);
66
69
  events.onRequestInput?.(assistant.usage !== undefined && assistant.usage.inputTokens > 0
67
70
  ? assistant.usage.inputTokens
68
71
  : response.inputTokens);
@@ -193,11 +196,17 @@ async function settle(call, current, total, options, events, signal, preview) {
193
196
  if (tool === undefined) {
194
197
  return refuse(call, `no such tool: ${call.name}`, "unknown tool");
195
198
  }
199
+ if (options.toolAllowed?.(call) === false) {
200
+ return refuse(call, "this tool is denied by the current session permissions", "denied");
201
+ }
196
202
  const approved = !tool.dangerous || await events.approve(call);
197
203
  throwIfAborted(signal);
198
204
  if (!approved) {
199
205
  return refuse(call, "the user declined this call — ask them how to proceed", "declined");
200
206
  }
207
+ if (options.toolAllowed?.(call) === false) {
208
+ return refuse(call, "this tool is denied by the current session permissions", "denied");
209
+ }
201
210
  throwIfAborted(signal);
202
211
  events.onToolStart?.(call, current, total);
203
212
  return runTool(tool, call, {
@@ -223,6 +232,8 @@ function refuse(call, reason, summary) {
223
232
  async function look(call, options, signal) {
224
233
  if (call.inputError !== undefined)
225
234
  return undefined;
235
+ if (options.toolAllowed?.(call) === false)
236
+ return undefined;
226
237
  const tool = findTool(options.tools, call.name);
227
238
  if (tool?.preview === undefined)
228
239
  return undefined;
@@ -1,6 +1,4 @@
1
1
  // One bounded ingress for text that can become a user prompt.
2
- import { Buffer } from "node:buffer";
3
- import { StringDecoder } from "node:string_decoder";
4
2
  import { MAX_TEXT_CODE_UNITS } from "./text-boundary.js";
5
3
  export const MAX_PROMPT_CODE_UNITS = MAX_TEXT_CODE_UNITS;
6
4
  export const PROMPT_LIMIT_MESSAGE = `Prompt cannot exceed ${MAX_PROMPT_CODE_UNITS.toLocaleString("en-US")} UTF-16 code units`;
@@ -18,63 +16,3 @@ export function assertPromptAppend(current, added) {
18
16
  if (added > MAX_PROMPT_CODE_UNITS - current)
19
17
  throw new PromptLimitError();
20
18
  }
21
- /**
22
- * Split raw UTF-8 input without letting one unterminated line grow past the
23
- * prompt boundary. Newline, CRLF, and a final line without a newline match the
24
- * line semantics used by batch mode.
25
- */
26
- export async function* boundedInputLines(source) {
27
- const decoder = new StringDecoder("utf8");
28
- let line = "";
29
- let pendingCr = false;
30
- const append = (text, from, to) => {
31
- const length = to - from;
32
- assertPromptAppend(line.length, length);
33
- if (length > 0)
34
- line += text.slice(from, to);
35
- };
36
- const consume = function* (text) {
37
- if (text === "")
38
- return;
39
- let from = 0;
40
- if (pendingCr) {
41
- if (text.startsWith("\n"))
42
- from = 1;
43
- pendingCr = false;
44
- yield line;
45
- line = "";
46
- }
47
- while (from < text.length) {
48
- const cr = text.indexOf("\r", from);
49
- const lf = text.indexOf("\n", from);
50
- const newline = cr === -1 ? lf : lf === -1 ? cr : Math.min(cr, lf);
51
- if (newline === -1) {
52
- append(text, from, text.length);
53
- return;
54
- }
55
- append(text, from, newline);
56
- if (text[newline] === "\r" && newline + 1 === text.length) {
57
- pendingCr = true;
58
- return;
59
- }
60
- yield line;
61
- line = "";
62
- from = text[newline] === "\r" && text[newline + 1] === "\n"
63
- ? newline + 2
64
- : newline + 1;
65
- }
66
- };
67
- for await (const chunk of source) {
68
- const text = typeof chunk === "string" ? chunk : decoder.write(Buffer.from(chunk));
69
- yield* consume(text);
70
- }
71
- const tail = decoder.end();
72
- if (tail !== "")
73
- yield* consume(tail);
74
- if (pendingCr) {
75
- yield line;
76
- line = "";
77
- }
78
- if (line !== "")
79
- yield line;
80
- }
package/dist/launch.js CHANGED
@@ -1,19 +1,23 @@
1
1
  export function parseLaunch(argv) {
2
+ if (argv.includes("--latest")) {
3
+ throw new Error("--latest has been renamed to --last; use `jecode -c` or `jecode resume --last`");
4
+ }
2
5
  const first = argv[0];
3
- if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
4
- if (first !== "resume")
5
- throw new Error(`unknown command ${first}`);
6
- const rest = argv.slice(1);
7
- const latest = rest.filter((value) => value === "--latest").length;
6
+ if (first === "resume" || first === "-c") {
7
+ const rest = first === "-c" ? ["--last", ...argv.slice(1)] : argv.slice(1);
8
+ const latest = rest.filter((value) => value === "--last").length;
8
9
  if (latest > 1)
9
- throw new Error("--latest may be passed only once");
10
+ throw new Error("--last may be passed only once; -c already selects the last session");
10
11
  return {
11
12
  kind: "resume",
12
13
  latest: latest === 1,
13
- configArgs: rest.filter((value) => value !== "--latest"),
14
+ configArgs: rest.filter((value) => value !== "--last"),
14
15
  };
15
16
  }
16
- if (argv.includes("--latest"))
17
- throw new Error("--latest requires `jecode resume`");
17
+ if (first !== undefined && !first.startsWith("--") && first !== "-h" && first !== "-v") {
18
+ throw new Error(`unknown command ${first}`);
19
+ }
20
+ if (argv.includes("--last"))
21
+ throw new Error("--last requires `jecode resume`; use `jecode -c` to continue directly");
18
22
  return { kind: "new", latest: false, configArgs: [...argv] };
19
23
  }
@@ -17,9 +17,6 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
17
17
  const connected = availability
18
18
  .filter((entry) => entry.blocked === undefined)
19
19
  .map((entry) => entry.provider);
20
- const disconnected = availability
21
- .filter((entry) => entry.blocked !== undefined)
22
- .map((entry) => entry.provider);
23
20
  if (connected.length === 0) {
24
21
  host.emit({
25
22
  kind: "notice",
@@ -68,10 +65,15 @@ export async function modelsCommand(session, host, behavior = {}, providers = PR
68
65
  });
69
66
  return false;
70
67
  }
71
- const description = catalogDescription(disconnected, failed);
68
+ if (failed.length > 0) {
69
+ host.emit({
70
+ kind: "notice",
71
+ text: `model catalogs unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
72
+ tone: "warn",
73
+ });
74
+ }
72
75
  const index = await choose({
73
76
  title: [],
74
- ...(description === undefined ? {} : { description }),
75
77
  options: choices.map((choice) => ({
76
78
  label: choice.model,
77
79
  // Provider identity is part of the choice, not optional help: keep it
@@ -136,18 +138,6 @@ async function alignEffort(provider, model, effort, host) {
136
138
  return { ok: false };
137
139
  }
138
140
  }
139
- function catalogDescription(disconnected, failed) {
140
- const parts = [
141
- "The connection on the right will run this model; API and account usage stay separate",
142
- disconnected.length === 0
143
- ? undefined
144
- : `Not connected: ${disconnected.map((provider) => providerLabel(provider.id)).join(", ")}`,
145
- failed.length === 0
146
- ? undefined
147
- : `Unavailable: ${failed.map((entry) => providerLabel(entry.provider.id)).join(", ")}`,
148
- ].filter((part) => part !== undefined);
149
- return `${parts.join(" · ")} · manage access in /providers`;
150
- }
151
141
  function emptyCatalogMessage(connected, failed) {
152
142
  if (failed.length === 1 && connected.length === 1) {
153
143
  const one = failed[0];
@@ -0,0 +1,68 @@
1
+ // The browser handoff back to the terminal after account sign-in.
2
+ import { readFileSync } from "node:fs";
3
+ export function oauthResultPage(success) {
4
+ const title = success ? "You're signed in" : "Sign-in failed";
5
+ const detail = success
6
+ ? "Return to your terminal. You can close this tab."
7
+ : "Return to your terminal to try again.";
8
+ return `<!doctype html>
9
+ <html lang="en">
10
+ <head>
11
+ <meta charset="utf-8">
12
+ <meta name="viewport" content="width=device-width, initial-scale=1">
13
+ <meta name="color-scheme" content="dark">
14
+ <title>${title} · Jecode</title>
15
+ <style>
16
+ :root {
17
+ color-scheme: dark;
18
+ --background: #1c2026;
19
+ --text: #ebeff4;
20
+ --muted: #9ca9b7;
21
+ --accent: #669bd2;
22
+ --error: #e87070;
23
+ }
24
+ * { box-sizing: border-box; }
25
+ body {
26
+ margin: 0;
27
+ min-height: 100vh;
28
+ min-height: 100svh;
29
+ display: grid;
30
+ place-items: center;
31
+ background: var(--background);
32
+ color: var(--text);
33
+ font-family: "Segoe UI", system-ui, sans-serif;
34
+ }
35
+ main { width: min(28rem, 100%); padding: 2rem; }
36
+ .identity { display: flex; align-items: center; gap: .75rem; margin-bottom: 2rem; }
37
+ .identity img { display: block; width: 3rem; height: 3rem; }
38
+ .identity span {
39
+ color: var(--accent);
40
+ font: 600 1.25rem/1 ui-monospace, "Cascadia Mono", Consolas, monospace;
41
+ letter-spacing: -.04em;
42
+ }
43
+ h1 { margin: 0; font-size: 1.75rem; font-weight: 600; line-height: 1.25; letter-spacing: -.025em; }
44
+ p { margin: .75rem 0 0; color: var(--muted); font-size: 1rem; line-height: 1.6; }
45
+ .failure h1 { color: var(--error); }
46
+ </style>
47
+ </head>
48
+ <body>
49
+ <main${success ? "" : ' class="failure"'}>
50
+ <div class="identity">
51
+ <img src="${mascotDataUri()}" width="48" height="48" alt="">
52
+ <span>jecode</span>
53
+ </div>
54
+ <h1>${title}</h1>
55
+ <p>${detail}</p>
56
+ </main>
57
+ <script>history.replaceState(null,"","/auth/complete")</script>
58
+ </body>
59
+ </html>`;
60
+ }
61
+ let mascot;
62
+ function mascotDataUri() {
63
+ if (mascot === undefined) {
64
+ const file = new URL("../assets/jeco-256.png", import.meta.url);
65
+ mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
66
+ }
67
+ return mascot;
68
+ }
@@ -1,9 +1,9 @@
1
1
  // Loopback callback used by the browser OAuth flow.
2
2
  import { timingSafeEqual } from "node:crypto";
3
- import { readFileSync } from "node:fs";
4
3
  import { createServer } from "node:http";
5
4
  import { leadingText } from "./text-boundary.js";
6
5
  import { providerLabel } from "./provider-label.js";
6
+ import { oauthResultPage } from "./oauth-result-page.js";
7
7
  const ACCOUNT_LABEL = providerLabel("openai-codex");
8
8
  const CALLBACK_PORTS = [1455, 1457];
9
9
  export const OPENAI_CALLBACK_PATH = "/auth/callback";
@@ -80,7 +80,7 @@ export async function openAICallback(state) {
80
80
  "referrer-policy": "no-referrer",
81
81
  "x-content-type-options": "nosniff",
82
82
  });
83
- response.end(resultPage(success));
83
+ response.end(oauthResultPage(success));
84
84
  await flushed;
85
85
  },
86
86
  close: () => closeServer(listening.server),
@@ -139,54 +139,6 @@ function closeServer(server) {
139
139
  server.closeIdleConnections();
140
140
  });
141
141
  }
142
- function resultPage(success) {
143
- const title = success ? "Signed in to Jecode" : "Jecode sign-in failed";
144
- const status = success ? "Authentication complete" : "Authentication stopped";
145
- const detail = success
146
- ? "Return to your terminal. Jecode will continue automatically."
147
- : "Return to your terminal to see what stopped the connection.";
148
- const state = success ? "success" : "failure";
149
- return `<!doctype html>
150
- <html lang="en">
151
- <head>
152
- <meta charset="utf-8">
153
- <meta name="viewport" content="width=device-width, initial-scale=1">
154
- <meta name="color-scheme" content="dark">
155
- <title>${title}</title>
156
- <style>
157
- :root{--night:#000;--steel:#669bd2;--steel-soft:#8db4dd;--bright:#ebeff4;--danger:#e87070}
158
- *{box-sizing:border-box}
159
- body{margin:0;min-height:100vh;display:grid;place-items:center;background:var(--night);color:var(--steel-soft);font-family:"Segoe UI",system-ui,sans-serif}
160
- main{width:min(34rem,calc(100vw - 3rem));padding:3rem 1.5rem;text-align:center}
161
- img{display:block;width:clamp(7.5rem,20vw,10rem);height:auto;margin:0 auto 1.75rem;filter:drop-shadow(0 1.25rem 2rem rgba(102,155,210,.16))}
162
- .rail{width:min(18rem,70vw);height:1px;margin:0 auto 1.5rem;background:linear-gradient(90deg,transparent,var(--steel),transparent)}
163
- .status{margin:0 0 .75rem;color:var(--steel);font:600 .72rem/1.2 ui-monospace,"Cascadia Mono",monospace;letter-spacing:.14em;text-transform:uppercase}
164
- h1{margin:0;color:var(--steel);font-size:clamp(2rem,6vw,3.25rem);font-weight:720;letter-spacing:-.04em;line-height:1.05}
165
- p:last-of-type{max-width:30rem;margin:1.25rem auto 0;color:var(--steel-soft);font-size:1.05rem;line-height:1.6}
166
- .failure h1,.failure .status{color:var(--danger)}
167
- @media (prefers-reduced-motion:no-preference){main{animation:arrive .45s ease-out both}@keyframes arrive{from{opacity:0;transform:translateY(.6rem)}to{opacity:1;transform:none}}}
168
- </style>
169
- </head>
170
- <body>
171
- <main class="${state}">
172
- <img src="${mascotDataUri()}" alt="Jeco, the Jecode gecko">
173
- <div class="rail" aria-hidden="true"></div>
174
- <p class="status">${status}</p>
175
- <h1>${title}</h1>
176
- <p>${detail}</p>
177
- </main>
178
- <script>history.replaceState(null,"","/auth/complete")</script>
179
- </body>
180
- </html>`;
181
- }
182
- let mascot;
183
- function mascotDataUri() {
184
- if (mascot === undefined) {
185
- const file = new URL("../assets/jeco-256.png", import.meta.url);
186
- mascot = `data:image/png;base64,${readFileSync(file).toString("base64")}`;
187
- }
188
- return mascot;
189
- }
190
142
  function sameState(expected, received) {
191
143
  if (received === null)
192
144
  return false;
@@ -9,17 +9,13 @@ export async function permissionsCommand(session, host) {
9
9
  }
10
10
  let selected = 0;
11
11
  while (true) {
12
- const index = await choose(permissionControlPicker(control, host, selected));
12
+ const index = await choose(permissionControlPicker(control, selected));
13
13
  if (index === undefined)
14
14
  return;
15
15
  const tool = control.listTools()[index];
16
16
  if (tool === undefined)
17
17
  return;
18
18
  selected = index;
19
- if (tool.locked) {
20
- lockedNotice(tool.name, host);
21
- continue;
22
- }
23
19
  if (tool.remembered > 0) {
24
20
  await reviewGrants(tool.name, control, choose, session.palette);
25
21
  }
@@ -29,12 +25,12 @@ export function permissionsPicker(tools, index = 0, adjust) {
29
25
  return {
30
26
  title: [],
31
27
  options: tools.map((tool) => {
32
- const description = toolDescription(tool);
28
+ const hint = toolHint(tool);
33
29
  return {
34
30
  label: tool.name,
35
- ...(description === undefined ? {} : { description }),
36
- value: tool.locked ? `${tool.mode} · locked` : tool.mode,
37
- adjustable: !tool.locked,
31
+ ...(hint === undefined ? {} : { hint }),
32
+ value: tool.mode,
33
+ adjustable: true,
38
34
  };
39
35
  }),
40
36
  visible: tools.length,
@@ -42,21 +38,17 @@ export function permissionsPicker(tools, index = 0, adjust) {
42
38
  index: Math.min(Math.max(0, index), Math.max(0, tools.length - 1)),
43
39
  };
44
40
  }
45
- export function permissionControlPicker(control, host, selected) {
41
+ export function permissionControlPicker(control, selected) {
46
42
  return permissionsPicker(control.listTools(), selected, (index, step) => {
47
43
  const tool = control.listTools()[index];
48
44
  if (tool === undefined)
49
- return permissionControlPicker(control, host, selected);
50
- if (tool.locked) {
51
- lockedNotice(tool.name, host);
52
- return permissionControlPicker(control, host, index);
53
- }
45
+ return permissionControlPicker(control, selected);
54
46
  const modes = modesFor(tool);
55
47
  const at = Math.max(0, modes.indexOf(tool.mode));
56
48
  const next = modes[(at + step + modes.length) % modes.length];
57
49
  if (next !== undefined)
58
50
  control.set(tool.name, next);
59
- return permissionControlPicker(control, host, index);
51
+ return permissionControlPicker(control, index);
60
52
  });
61
53
  }
62
54
  async function reviewGrants(tool, control, choose, pal) {
@@ -90,17 +82,10 @@ async function reviewGrants(tool, control, choose, pal) {
90
82
  function modesFor(tool) {
91
83
  return tool.dangerous ? ["ask", "allow", "deny"] : ["allow", "deny"];
92
84
  }
93
- function toolDescription(tool) {
85
+ function toolHint(tool) {
94
86
  const parts = [
95
87
  tool.dangerous ? undefined : "read only",
96
88
  tool.remembered === 0 ? undefined : `${tool.remembered} remembered`,
97
89
  ].filter((part) => part !== undefined);
98
90
  return parts.length === 0 ? undefined : parts.join(" · ");
99
91
  }
100
- function lockedNotice(tool, host) {
101
- host.emit({
102
- kind: "notice",
103
- text: `restart without --auto-approve to change ${tool}`,
104
- tone: "warn",
105
- });
106
- }
@@ -1,12 +1,11 @@
1
1
  // Session-local tool policies and remembered approval scopes.
2
2
  /** One permission control plane for one interactive process. */
3
- export function sessionPermissions(tools, autoApprove) {
3
+ export function sessionPermissions(tools) {
4
4
  const catalogue = [...tools];
5
5
  const byName = new Map(catalogue.map((tool) => [tool.name, tool]));
6
6
  const modes = new Map();
7
7
  const grants = new Map();
8
8
  const configured = (tool) => modes.get(tool.name) ?? defaultMode(tool);
9
- const effective = (tool) => autoApprove && tool.dangerous ? "allow" : configured(tool);
10
9
  const revokeTool = (name) => {
11
10
  for (const [key, grant] of grants) {
12
11
  if (grant.tools.includes(name))
@@ -18,14 +17,13 @@ export function sessionPermissions(tools, autoApprove) {
18
17
  return catalogue.map((tool) => ({
19
18
  name: tool.name,
20
19
  dangerous: tool.dangerous,
21
- mode: effective(tool),
20
+ mode: configured(tool),
22
21
  remembered: [...grants.values()].filter((grant) => grant.tools.includes(tool.name)).length,
23
- locked: autoApprove && tool.dangerous,
24
22
  }));
25
23
  },
26
24
  set(name, mode) {
27
25
  const tool = byName.get(name);
28
- if (tool === undefined || (autoApprove && tool.dangerous))
26
+ if (tool === undefined)
29
27
  return false;
30
28
  if (!tool.dangerous && mode === "ask")
31
29
  return false;
@@ -50,13 +48,17 @@ export function sessionPermissions(tools, autoApprove) {
50
48
  grants.clear();
51
49
  },
52
50
  availableTools() {
53
- return catalogue.filter((tool) => effective(tool) !== "deny");
51
+ return catalogue.filter((tool) => configured(tool) !== "deny");
52
+ },
53
+ allowed(call) {
54
+ const tool = byName.get(call.name);
55
+ return tool !== undefined && configured(tool) !== "deny";
54
56
  },
55
57
  approved(call) {
56
58
  const tool = byName.get(call.name);
57
59
  if (tool === undefined)
58
60
  return false;
59
- const mode = effective(tool);
61
+ const mode = configured(tool);
60
62
  if (mode === "allow")
61
63
  return true;
62
64
  if (mode === "deny")
@@ -65,7 +67,7 @@ export function sessionPermissions(tools, autoApprove) {
65
67
  },
66
68
  remember(call) {
67
69
  const tool = byName.get(call.name);
68
- if (tool === undefined || effective(tool) !== "ask")
70
+ if (tool === undefined || configured(tool) !== "ask")
69
71
  return;
70
72
  const scope = scopeFor(call);
71
73
  grants.set(scope.key, { key: scope.key, tools: grantTools(call), label: scope.summary });
@@ -38,7 +38,7 @@ export function stopNotice(data) {
38
38
  return `[refused: ${category}] ${why}`;
39
39
  }
40
40
  if (data.stop_reason === "max_tokens") {
41
- return "[truncated: hit max_tokens — raise --max-tokens]";
41
+ return "[truncated: hit max_tokens — raise max output tokens in /settings]";
42
42
  }
43
43
  return undefined;
44
44
  }
@@ -13,6 +13,7 @@ import { EFFORTS, requireSupportedEffort } from "../effort.js";
13
13
  import { assembleAnthropic } from "./anthropic-stream.js";
14
14
  import { isRetryableGenerationFailure, isRetryableReadFailure, throwProviderError, } from "./failure.js";
15
15
  import { fromWireResponse, stopNotice, toWireMessage, toWireTool } from "./anthropic-wire.js";
16
+ import { measureAnthropicInput } from "./input-measurement.js";
16
17
  const ENDPOINT = "https://api.anthropic.com/v1/messages";
17
18
  const MODELS = "https://api.anthropic.com/v1/models?limit=100";
18
19
  const API_VERSION = "2023-06-01";
@@ -36,7 +37,7 @@ export function anthropicEfforts(model) {
36
37
  export const anthropic = {
37
38
  id: ID,
38
39
  // Sonnet is the default because it is the one that can be left running.
39
- // Opus via `--model claude-opus-5`, Haiku via `--model claude-haiku-4-5`.
40
+ // Other models are selected through the shared model catalogue.
40
41
  defaultModel: "claude-sonnet-5",
41
42
  auth: { kind: "api-key", keyVar: KEY },
42
43
  blocked() {
@@ -72,6 +73,7 @@ export const anthropic = {
72
73
  throwProviderError(ID, signal, error);
73
74
  }
74
75
  },
76
+ measureInput: measureAnthropicInput,
75
77
  async send(req) {
76
78
  const key = requireKey();
77
79
  const body = {