Skip to content

Commit 125f1ae

Browse files
authored
Merge branch 'develop' into claude/nice-snyder-aa92c2
2 parents 4ae6759 + d056588 commit 125f1ae

10 files changed

Lines changed: 807 additions & 67 deletions

File tree

‎RELEASE-NOTES.md‎

Lines changed: 93 additions & 35 deletions
Large diffs are not rendered by default.

‎extensions/levelcode-ai/agent.js‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -311,6 +311,9 @@ function runCommand(root, command, onChunk, onExit, onStart, timeoutMs) {
311311
async function runTool(tu, ctx) {
312312
const root = ctx.root;
313313
const input = tu.input || {};
314+
// The same logger runAgent uses, resolved HERE: runAgent's `dbg` is local to runAgent, so it is not
315+
// in scope in this function — and a bare dbg(...) below is a ReferenceError only once the line runs.
316+
const dbg = ctx.dbg || (() => {});
314317
try {
315318
if (tu.name === 'list_files') {
316319
ctx.post({ type: 'agentTool', icon: 'list-tree', text: 'list_files ' + (input.glob || '') });

‎extensions/levelcode-ai/extension.js‎

Lines changed: 83 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -426,20 +426,37 @@ async function renewSession(endpoint) {
426426
// Not applied: the stored session is no longer the one this request was about — a sign-in, a
427427
// sign-out, another refresh that finished first, or another window. What the caller needs is
428428
// whether a token is in place to retry with.
429-
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
429+
const token = await storedToken();
430430
dbg('cloud.refresh', { superseded: true, outcome, token: !!token });
431-
if (!token && cloudSignedIn) {
432-
// Gone, and not by this window's hand — a sign-out or an expiry HERE clears the flag before it
433-
// deletes anything. Another window got there first, so this one catches up: the card if the
434-
// session ended, and a popover and footer that stop claiming it is live either way.
435-
cloudSignedIn = false;
436-
if (sessionExpiredPending()) { postSessionExpired(); }
437-
await postAccount(false);
438-
sendConfigToWebview();
439-
}
431+
if (!!token !== cloudSignedIn) { await catchUpWithStoredSession(token); }
440432
return !!token;
441433
}
442434

435+
/**
436+
* The stored access token, read between session changes rather than in the middle of one. Every
437+
* change this window makes moves the token and `cloudSignedIn` together inside the lock, so a token
438+
* read this way that disagrees with the flag was changed by ANOTHER window.
439+
*/
440+
function storedToken() {
441+
return withSessionLock(() => ctx.secrets.get(ACCOUNT_TOKEN_KEY));
442+
}
443+
444+
/**
445+
* The stored session belongs to every window, and a window only hears about the changes it makes
446+
* itself. When what is stored stops matching what this window is showing, another window has signed
447+
* out, signed in, or found the expiry — and a chat that is already open sends no second `ready` to
448+
* notice. Bring this window level: the flag, the popover and footer, and the card. An expiry that
449+
* is now waiting gets its card; if none is (the other window signed in), the account message takes
450+
* a card that is still up back down.
451+
* @param {string|undefined} token what SecretStorage holds now
452+
*/
453+
async function catchUpWithStoredSession(token) {
454+
cloudSignedIn = !!token;
455+
if (!token && sessionExpiredPending()) { postSessionExpired(); }
456+
await postAccount(false);
457+
sendConfigToWebview();
458+
}
459+
443460
/**
444461
* Every change to the stored session — a sign-in, a sign-out, a refresh's new tokens, an expiry —
445462
* runs through here, one at a time.
@@ -580,11 +597,21 @@ async function clearSessionExpired() {
580597
* resumed session (both post `reset`, which empties the log) and a checkpoint restore (which
581598
* drops every node after the restored turn). Each takes the card with it, and the next message
582599
* would be stopped by an expiry nothing on screen mentions any more.
600+
*
601+
* Never rejects. It runs at the end of things that have already done their work — a resume, a
602+
* restore — and a store that will not read is no reason to cut those short.
583603
*/
584604
async function replaySessionExpired() {
585-
if (!sessionExpiredPending() || await ctx.secrets.get(ACCOUNT_TOKEN_KEY)) { return; }
586-
dbg('cloud.sessionExpired.replay', {});
587-
postSessionExpired();
605+
if (!sessionExpiredPending()) { return; }
606+
try {
607+
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
608+
// Asked AGAIN, because the answer can change while that read is out: "Use my own key instead"
609+
// clears the marker, a Settings change takes the session out of play. Posting on the earlier
610+
// answer would put back a card the user has just dismissed.
611+
if (token || !sessionExpiredPending()) { return; }
612+
dbg('cloud.sessionExpired.replay', {});
613+
postSessionExpired();
614+
} catch (e) { dbg('cloud.sessionExpired.replay', { error: String((e && e.message) || e) }); }
588615
}
589616

590617
/**
@@ -593,19 +620,32 @@ async function replaySessionExpired() {
593620
*
594621
* Cheap by design: the access token's own `exp` is read locally and the network is only touched
595622
* when it is expired or about to be. A session with hours left costs nothing here. Called on the
596-
* webview's `ready` and again when the window regains focus after a while away.
623+
* webview's `ready` and again every time the window regains focus.
624+
*
625+
* Two halves. Catching up with the other windows is one local read, so it happens on every call.
626+
* The half that can cost a request is rationed on focus. Never rejects: `ready` awaits this before
627+
* it restores the chat.
597628
*/
629+
const SESSION_CHECK_EVERY_MS = 10 * 60 * 1000;
598630
let lastSessionCheck = 0;
599631
async function checkCloudSession(reason) {
600632
if (!ctx || providerMode() !== 'gateway') { return; }
601-
const token = await ctx.secrets.get(ACCOUNT_TOKEN_KEY);
602-
// No token, so no session to check. One that already ENDED — found while no chat was open to hear
603-
// about it — is not announced from here: `ready` replays it last, via replaySessionExpired().
604-
if (!token) { return; }
605-
lastSessionCheck = Date.now();
606-
if (!session.accessNeedsRefresh(token)) { return; }
607-
dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) });
608-
await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside
633+
try {
634+
const token = await storedToken();
635+
// First, whether this window is still showing the session that is actually stored. On `ready`
636+
// there is nothing to catch up on — the handler has just read the same token — and an expiry
637+
// found while no chat was open is replayed LAST there, by replaySessionExpired(). On focus this
638+
// is what tells an open chat that another window has ended, left or renewed the session.
639+
if (!!token !== cloudSignedIn) { await catchUpWithStoredSession(token); }
640+
if (!token) { return; }
641+
// From here on it can cost a request, so focus gets a ration: a window clicked in and out of
642+
// while offline must not retry the refresh on every click.
643+
if (reason === 'focus' && Date.now() - lastSessionCheck <= SESSION_CHECK_EVERY_MS) { return; }
644+
lastSessionCheck = Date.now();
645+
if (!session.accessNeedsRefresh(token)) { return; }
646+
dbg('cloud.sessionCheck', { reason, expiresAt: session.jwtExpiresAt(token) });
647+
await refreshCloudToken(); // an expired refresh token lands in sessionExpired() from inside
648+
} catch (e) { dbg('cloud.sessionCheck', { reason, error: String((e && e.message) || e) }); }
609649
}
610650

611651
/** Gateway-mode token refresh (the streaming 401 retry path). Delegates to refreshCloudToken. */
@@ -1455,7 +1495,7 @@ function newChat() {
14551495
post({ type: 'reset' });
14561496
postContextFiles();
14571497
postMemoryDigest(); // the fresh empty state shows the welcome-back strip
1458-
replaySessionExpired().catch(() => { }); // `reset` emptied the log, and an unanswered expiry's card with it
1498+
replaySessionExpired(); // `reset` emptied the log, and an unanswered expiry's card with it
14591499
}
14601500

14611501
/** The currently open file as a context block (capped), or null. */
@@ -3218,13 +3258,25 @@ async function webHandoffUrl() {
32183258
return (data && data.url) || null;
32193259
} catch (e) { dbg('account.handoff', { error: String((e && e.message) || e) }); return null; }
32203260
}
3221-
/** Persist an editor session: access token (required), optional refresh token, and display profile. */
3261+
/**
3262+
* Persist an editor session: access token (required), the refresh token if the sign-in brought one,
3263+
* and the display profile.
3264+
*
3265+
* A sign-in REPLACES the session; it does not top one up. A refresh token left over from whatever
3266+
* was here before is what the new session's first renewal would be made with: refused, it ends the
3267+
* session that replaced it; still good, it hands this editor the previous account's access token
3268+
* under the new account's name. So the refresh token is settled first — stored, or forgotten when
3269+
* there is none — and only then the access token. A sign-in cut short between the two (the editor
3270+
* closing, a keychain that will not write) must not leave the new access token over the old
3271+
* refresh token either.
3272+
*/
32223273
async function storeSession(access, refresh, profile) {
32233274
if (!ctx || !access) { return; }
32243275
await withSessionLock(async () => {
32253276
sessionGeneration++; // a refresh still out for the session this replaces must not touch the new one
3226-
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
32273277
if (refresh) { await ctx.secrets.store(ACCOUNT_REFRESH_KEY, refresh); }
3278+
else { await ctx.secrets.delete(ACCOUNT_REFRESH_KEY); }
3279+
await ctx.secrets.store(ACCOUNT_TOKEN_KEY, access);
32283280
cloudSignedIn = true;
32293281
await ctx.globalState.update(ACCOUNT_PROFILE_KEY, {
32303282
name: (profile && profile.name) || '', email: (profile && profile.email) || '', plan: (profile && profile.plan) || ''
@@ -3318,15 +3370,17 @@ function onConfigChanged(e) {
33183370
if (e.affectsConfiguration('levelcode.ai')) { sendConfigToWebview(); }
33193371
if (e.affectsConfiguration('levelcode.ai.providerMode') || e.affectsConfiguration('levelcode.cloud')) {
33203372
postAccount();
3321-
replaySessionExpired().catch(() => { });
3373+
replaySessionExpired();
33223374
}
33233375
}
33243376

33253377
function activate(context) {
3326-
// A window that comes back after a while away may have outlived its access token (8 h). Re-check
3327-
// on focus, throttled, so the expiry is found before the next message rather than by it.
3378+
// A window that comes back after a while away may have outlived its access token (8 h) — or
3379+
// another window may have ended, left or renewed the session they share. Re-check on every focus,
3380+
// so either is found before the next message rather than by it. The look is one local read;
3381+
// checkCloudSession rations the part that can cost a request.
33283382
context.subscriptions.push(vscode.window.onDidChangeWindowState((st) => {
3329-
if (st.focused && Date.now() - lastSessionCheck > 10 * 60 * 1000) { checkCloudSession('focus').catch(() => {}); }
3383+
if (st.focused) { checkCloudSession('focus'); }
33303384
}));
33313385
ctx = context;
33323386
// Constructed directly rather than by registerWebviewViewProvider: the chat is no longer a

‎extensions/levelcode-ai/media/chat.html‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1645,6 +1645,12 @@
16451645
'sync': { vb: '0 0 16 16', p: '<path d="M2.006 8.267 0.78 9.5 0 8.73l2.09-2.07.76.01 2.09 2.12-.76.76-1.167-1.18a5 5 0 0 0 9.4 1.983l.813.597A6 6 0 0 1 2.006 8.267Zm11.99-.518L15.22 6.51 16 7.29l-2.09 2.07-.76-.01-2.09-2.12.76-.76 1.167 1.18a5 5 0 0 0-9.4-1.983l-.813-.597A6 6 0 0 1 13.996 7.75Z"/>' },
16461646
'chevron-down': { vb: '0 0 16 16', p: '<path d="M3.22 5.97a.75.75 0 0 1 1.06 0L8 9.69l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L3.22 7.03a.75.75 0 0 1 0-1.06z"/>' },
16471647
'circle': { vb: '0 0 16 16', p: '<path fill-rule="evenodd" clip-rule="evenodd" d="M8 1C11.86 1 15 4.14 15 8C15 11.86 11.86 15 8 15C4.14 15 1 11.86 1 8C1 4.14 4.14 1 8 1ZM8 2C4.691 2 2 4.691 2 8C2 11.309 4.691 14 8 14C11.309 14 14 11.309 14 8C14 4.691 11.309 2 8 2Z"/>' },
1648+
// Named only by the HOST, as the `icon:` on a message — layout: the "Moved to the editor" banner;
1649+
// globe: the preview chip; history: the recall and project-memory chips. Nothing in this file spells
1650+
// them, and a name the set lacks is PRINTED as a word in place of the glyph (test/chatIcons.test.js).
1651+
'layout': { vb: '0 0 16 16', p: '<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 1C6.327 1 7 1.673 7 2.5V13.5C7 14.327 6.327 15 5.5 15H2.5C1.673 15 1 14.327 1 13.5V2.5C1 1.673 1.673 1 2.5 1H5.5ZM2.5 2C2.225 2 2 2.225 2 2.5V13.5C2 13.775 2.225 14 2.5 14H5.5C5.775 14 6 13.775 6 13.5V2.5C6 2.225 5.775 2 5.5 2H2.5Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 9C14.327 9 15 9.673 15 10.5V13.5C15 14.327 14.327 15 13.5 15H10.5C9.673 15 9 14.327 9 13.5V10.5C9 9.673 9.673 9 10.5 9H13.5ZM10.5 10C10.225 10 10 10.225 10 10.5V13.5C10 13.775 10.225 14 10.5 14H13.5C13.775 14 14 13.775 14 13.5V10.5C14 10.225 13.775 10 13.5 10H10.5Z"/><path fill-rule="evenodd" clip-rule="evenodd" d="M13.5 1C14.327 1 15 1.673 15 2.5V5.5C15 6.327 14.327 7 13.5 7H10.5C9.673 7 9 6.327 9 5.5V2.5C9 1.673 9.673 1 10.5 1H13.5ZM10.5 2C10.225 2 10 2.225 10 2.5V5.5C10 5.775 10.225 6 10.5 6H13.5C13.775 6 14 5.775 14 5.5V2.5C14 2.225 13.775 2 13.5 2H10.5Z"/>' },
1652+
'globe': { vb: '0 0 16 16', p: '<path d="M8 1C4.141 1 1 4.141 1 8C1 11.859 4.141 15 8 15C11.859 15 15 11.859 15 8C15 4.141 11.859 1 8 1ZM8 14C7.422 14 6.686 12.906 6.288 11H9.713C9.315 12.906 8.579 14 8.001 14H8ZM6.121 10C6.044 9.392 6 8.723 6 8C6 7.277 6.044 6.608 6.121 6H9.878C9.955 6.608 9.999 7.277 9.999 8C9.999 8.723 9.955 9.392 9.878 10H6.121ZM2 8C2 7.299 2.121 6.626 2.343 6H5.121C5.041 6.656 5 7.332 5 8C5 8.668 5.041 9.344 5.121 10H2.343C2.121 9.374 2 8.701 2 8ZM8 2C8.578 2 9.314 3.094 9.712 5H6.287C6.685 3.094 7.422 2 8 2ZM10.879 6H13.657C13.879 6.626 14 7.299 14 8C14 8.701 13.879 9.374 13.657 10H10.879C10.959 9.344 11 8.668 11 8C11 7.332 10.959 6.656 10.879 6ZM13.195 5H10.722C10.516 3.938 10.199 2.98 9.775 2.268C11.228 2.719 12.446 3.707 13.195 5ZM6.226 2.268C5.802 2.98 5.484 3.938 5.279 5H2.806C3.556 3.707 4.774 2.718 6.226 2.268ZM2.805 11H5.278C5.484 12.062 5.801 13.02 6.225 13.732C4.772 13.281 3.554 12.293 2.805 11ZM9.774 13.732C10.198 13.02 10.516 12.062 10.721 11H13.194C12.444 12.293 11.226 13.282 9.774 13.732Z"/>' },
1653+
'history': { vb: '0 0 16 16', p: '<path d="M7.99909 3C10.7605 3 12.9991 5.23858 12.9991 8C12.9991 10.7614 10.7605 13 7.99909 13C5.39117 13 3.2491 11.003 3.0195 8.45512C2.99471 8.1801 2.75167 7.97723 2.47664 8.00202C2.20161 8.0268 1.99875 8.26985 2.02353 8.54488C2.29916 11.6035 4.86898 14 7.99909 14C11.3128 14 13.9991 11.3137 13.9991 8C13.9991 4.68629 11.3128 2 7.99909 2C6.20656 2 4.59815 2.78613 3.49909 4.03138V2.5C3.49909 2.22386 3.27524 2 2.99909 2C2.72295 2 2.49909 2.22386 2.49909 2.5V5.5C2.49909 5.77614 2.72295 6 2.99909 6H3.08812C3.09498 6.00014 3.10184 6.00014 3.10868 6H5.99909C6.27524 6 6.49909 5.77614 6.49909 5.5C6.49909 5.22386 6.27524 5 5.99909 5H3.99863C4.91128 3.78495 6.36382 3 7.99909 3ZM7.99909 5.5C7.99909 5.22386 7.77524 5 7.49909 5C7.22295 5 6.99909 5.22386 6.99909 5.5V8.5C6.99909 8.77614 7.22295 9 7.49909 9H9.49909C9.77524 9 9.99909 8.77614 9.99909 8.5C9.99909 8.22386 9.77524 8 9.49909 8H7.99909V5.5Z"/>' },
16481654
};
16491655
function codicon(name, cls){ const i = IC[name]; if (!i) { return esc(name || ''); } return '<svg class="ci' + (cls ? ' ' + cls : '') + '" viewBox="' + i.vb + '" fill="currentColor" aria-hidden="true">' + i.p + '</svg>'; }
16501656
// Falling-dots progress (Copilot-style) shown while a command runs; styled by .lcdots CSS.

‎extensions/levelcode-ai/providers/catalog.js‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,19 @@ const CAPS = {
2929
'claude-opus-4-8': { context: 200000, tools: true, vision: true, caching: true },
3030
'claude-sonnet-4-6': { context: 200000, tools: true, vision: true, caching: true },
3131
'claude-haiku-4-5-20251001': { context: 200000, tools: true, vision: true, fast: true },
32+
// Fable reaches the composer two ways: through the gateway as the OpenRouter basename (dotted
33+
// 5.1), and BYOK-native as the dashed id. Neither had a row, so both fell to the claude-*
34+
// heuristic's 200k — a fifth of the real window. Fable 5 / 5.1 are Pro-tier from 2026-09-08.
35+
'claude-fable-5': { context: 1000000, tools: true, vision: true, caching: true },
36+
'claude-fable-5.1': { context: 1000000, tools: true, vision: true, caching: true },
37+
'claude-fable-5-1': { context: 1000000, tools: true, vision: true, caching: true },
3238
// OpenAI
3339
'gpt-4o': { context: 128000, tools: true, vision: true },
3440
'gpt-4o-mini': { context: 128000, tools: true, vision: true, fast: true },
41+
// Not covered by the gpt-4/gpt-5 heuristic below, which would also give it a 128k window; the
42+
// real one is 1.05M (OpenRouter models API, 2026-09-06) and it reads images. Without this row the
43+
// gateway picker offers it while the composer refuses attachments and the meter sizes it wrong.
44+
'gpt-6-astra': { context: 1050000, tools: true, vision: true },
3545
'o3-mini': { context: 200000, tools: true, reasoning: true },
3646
'o1': { context: 200000, tools: true, reasoning: true },
3747
'o1-mini': { context: 128000, tools: false, reasoning: true }, // o1-mini has no function calling

0 commit comments

Comments
 (0)