Maintenance: pricing date, token est. label, listPaths refresh, MUTATING derive

This commit is contained in:
asepharyana
2026-09-09 12:43:03 +07:00
parent 4f3a7eef7c
commit dcede3c10a
10 changed files with 106 additions and 29 deletions
+1
View File
@@ -398,6 +398,7 @@ const hooks: AppHooks = {
for await (const rel of walk({ limit: 5000 })) found.push(rel);
return found;
},
fileChangeSeq: () => session.fileChangeSeq,
customCommands: () => customCommands,
registry: {
list: async () => {
+29 -14
View File
@@ -190,28 +190,43 @@ export function resolve(rules: PermissionEntry | undefined, tool: string, input:
* Read-only tools run; anything that writes or executes asks. `.env` is denied on
* read because a model that greps for a config value will find a credential, and
* "it was in the context" is not recoverable.
*
* Mutating entries are derived from `_meta.mutating` (tools.ts MUTATING_TOOLS) so
* a new write cannot be added without being gated — the loop below is the single
* source. Only the non-mutating special cases are hand-written here.
*/
export const DEFAULT_PERMISSIONS: PermissionConfig = {
const BASE_PERMISSIONS: PermissionConfig = {
read_file: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' },
read_many_files: { '*': 'allow', '*.env': 'deny', '*.env.*': 'deny', '*.env.example': 'allow', '*.pem': 'deny' },
write_file: 'ask',
edit_file: 'ask',
multi_edit: 'ask',
apply_patch: 'ask',
move_file: 'ask',
delete_file: 'ask',
insert_lines: 'ask',
delete_lines: 'ask',
replace_lines: 'ask',
append_file: 'ask',
prepend_file: 'ask',
bash: 'ask',
web_fetch: 'ask',
mcp_call: 'ask',
mcp_list: 'allow',
mcp_inspect: 'allow',
};
// Filled at import time from MUTATING_TOOLS so `_meta.mutating` is the single source.
// Dynamic import avoids a static cycle (tools.ts does not import permission.ts).
let _defaultPermissions: PermissionConfig | undefined;
function buildDefaults(): PermissionConfig {
if (_defaultPermissions) return _defaultPermissions;
const out: PermissionConfig = { ...BASE_PERMISSIONS };
try {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const toolsMod = require('./tools') as { MUTATING_TOOLS?: readonly string[] };
for (const name of toolsMod.MUTATING_TOOLS ?? []) {
if (!(name in out)) out[name] = 'ask';
}
} catch {
// tests that import permission in isolation still get BASE + known mutating fallback
for (const name of ['write_file','edit_file','multi_edit','apply_patch','move_file','delete_file','insert_lines','delete_lines','replace_lines','append_file','prepend_file','bash','mcp_call'] as const) {
if (!(name in out)) (out as Record<string, PermissionEntry>)[name] = 'ask';
}
}
_defaultPermissions = out;
return out;
}
export const DEFAULT_PERMISSIONS: PermissionConfig = buildDefaults();
/** Session, plugin, and read-only tools that never gate. */
const FREE = new Set([
'glob',
+9
View File
@@ -1,3 +1,12 @@
export const PRICING_VERIFIED_AT = '2026-09-09';
/**
* Source: hand-entered from provider pricing pages as of PRICING_VERIFIED_AT.
* Anthropic https://www.anthropic.com/pricing, OpenAI https://openai.com/api/pricing,
* DeepSeek https://api-docs.deepseek.com/quick_start/pricing, xAI https://x.ai/api.
* Rates drift; verify before billing. Update PRICING_VERIFIED_AT when changing RATES.
* Displayed in /cost so a stale table is visible.
*/
export type Rate = { inputPerMTok: number; outputPerMTok: number };
/**
+5 -3
View File
@@ -196,9 +196,11 @@ export function droppedSpan(before: ModelMessage[], after: ModelMessage[]): Mode
const KEEP_LADDER = [64, 32, 16, 8, 4] as const;
/**
* Token estimate used by the session harness. `len/4` undercounts tool envelopes
* (role + toolCallId + providerOptions); `len/3.6 + 8*msgs` tracks cl100k closer
* without pulling a tokenizer. Exported so session and tests share it.
* Token estimate used by the session harness — heuristic, not a tokenizer.
* `len/4` undercounts tool envelopes (role + toolCallId + providerOptions);
* `len/3.6 + 8*msgs` tracks cl100k closer without pulling a tokenizer.
* Every display of its value must label it an estimate (e.g. "~N tokens (est.)").
* Exported so session and tests share it.
*/
export function estimateTokens(messages: ModelMessage[]): number {
return Math.round(JSON.stringify(messages).length / 3.6 + messages.length * 8);
+5
View File
@@ -387,6 +387,9 @@ export class Session {
canUndo(): boolean { return this.snapshots.canUndo(); }
canRedo(): boolean { return this.snapshots.canRedo(); }
/** Monotonically increments when a file is first touched in a turn — lets the `@` completer know its cache is stale. */
fileChangeSeq = 0;
async undo(): Promise<string> {
const snap = this.snapshots.popForUndo();
if (!snap) throw new Error('nothing to undo');
@@ -426,6 +429,7 @@ export class Session {
} else {
await Bun.write(abs, st.content ?? '');
}
this.fileChangeSeq += 1;
} catch {
// best-effort per file; one failure should not stop the rest
}
@@ -599,6 +603,7 @@ export class Session {
try { content = await Bun.file(abs).text(); } catch { content = null; }
}
this.turnBeforeFiles.set(abs, { existed: exists, content });
this.fileChangeSeq += 1;
});
this.messages.push({ role: 'user', content: userText });
this.opts.onChange?.(this.messages);
+12 -3
View File
@@ -63,8 +63,10 @@ export type AppHooks = {
saveSession: () => Promise<string>;
/** Loaded AGENTS.md-style files, for /context. */
instructionFiles: () => string[];
/** Ignore-aware workspace paths for `@` completion, loaded on first use. */
/** Ignore-aware workspace paths for `@` completion, loaded on first use and invalidated when files change. */
listPaths: () => Promise<string[]>;
/** Monotonically increments when the workspace changes — lets the `@` completer know to re-walk. */
fileChangeSeq: () => number;
/** Custom slash commands from markdown files, for the menu and the parser. */
customCommands?: () => readonly CustomCommand[];
/** Registry index, installed set, and the install/remove actions. */
@@ -177,7 +179,8 @@ export function App({
const highlightedPath = fileMatches[Math.min(fileIndex, Math.max(0, fileMatches.length - 1))];
// The walk costs a full ignore-aware traversal, so it happens on the first `@`
// rather than at startup, and only once.
// rather than at startup, and re-runs when files change (listPaths is cached
// in hook, but App keeps seq so a stale `paths` is dropped).
useEffect(() => {
if (token === undefined || paths !== undefined) return;
let live = true;
@@ -189,6 +192,12 @@ export function App({
};
}, [hooks, paths, token]);
// A file mutated this turn: drop the cached walk so next `@` re-walks.
const seq = hooks.fileChangeSeq();
useEffect(() => {
setPaths(undefined);
}, [seq]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => bridge.bind(setPending), [bridge]);
useEffect(() => askBridge?.bind(setAsking), [askBridge]);
@@ -425,7 +434,7 @@ export function App({
if (ev.inputTokens !== undefined) {
merged.push({
kind: 'info',
text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} in context)`,
text: `${usageLine(hooks.config().model, ev.inputTokens, ev.outputTokens ?? 0)} (~${session.estimatedTokens()} est. in context)`,
key: nextKey(),
});
}
+3 -2
View File
@@ -1,4 +1,4 @@
import { costOf, formatUsd } from '../pricing';
import { costOf, formatUsd, PRICING_VERIFIED_AT } from '../pricing';
import type { Session } from '../session';
import { toolSetOf } from '../tools';
import { todoLines } from './transcript';
@@ -58,7 +58,8 @@ export function costPanel(
);
}
lines.push(`- context: ~${session.estimatedTokens()} tokens`, `- agent: \`${info.agent}\` thinking \`${info.thinking}\``);
lines.push(`- context: ~${session.estimatedTokens()} tokens (est.)`, `- agent: \`${info.agent}\` thinking \`${info.thinking}\``);
lines.push(`- pricing verified: ${PRICING_VERIFIED_AT} (est., verify before billing)`);
return { title: 'cost', hint: `session ${info.sessionId}`, body: lines.join('\n') };
}