@vincemakes/kiso-web 0.0.0-stage → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +50 -2
  3. package/dist/main/agent.js +80 -0
  4. package/dist/main/approvals.js +68 -0
  5. package/dist/main/artifacts.js +166 -0
  6. package/dist/main/auth.js +109 -0
  7. package/dist/main/context.js +20 -0
  8. package/dist/main/desk-api.js +249 -0
  9. package/dist/main/env.js +104 -0
  10. package/dist/main/faux.js +12 -0
  11. package/dist/main/files.js +156 -0
  12. package/dist/main/folder-info.js +36 -0
  13. package/dist/main/folders.js +102 -0
  14. package/dist/main/fork.js +25 -0
  15. package/dist/main/fs-routes.js +69 -0
  16. package/dist/main/git.js +117 -0
  17. package/dist/main/headless.js +128 -0
  18. package/dist/main/history-worker.js +18 -0
  19. package/dist/main/history.js +151 -0
  20. package/dist/main/host.js +210 -0
  21. package/dist/main/login.js +27 -0
  22. package/dist/main/names.js +15 -0
  23. package/dist/main/observed-revision.js +69 -0
  24. package/dist/main/preview.js +141 -0
  25. package/dist/main/sanitize.js +28 -0
  26. package/dist/main/settings.js +242 -0
  27. package/dist/main/space.js +198 -0
  28. package/dist/main/tasks.js +92 -0
  29. package/dist/main/times.js +28 -0
  30. package/dist/main/usage.js +102 -0
  31. package/dist/main/works.js +21 -0
  32. package/dist/shared/files.js +2 -0
  33. package/dist/shared/frames.js +13 -0
  34. package/dist/shared/git.js +2 -0
  35. package/dist/shared/history.js +5 -0
  36. package/dist/shared/settings.js +2 -0
  37. package/out/renderer/assets/index-C3fCoBdq.css +1 -0
  38. package/out/renderer/assets/index-DiDTmq9b.js +45 -0
  39. package/out/renderer/favicon-32.png +0 -0
  40. package/out/renderer/favicon.svg +1 -0
  41. package/out/renderer/icon.png +0 -0
  42. package/out/renderer/index.html +18 -0
  43. package/out/renderer/manifest.webmanifest +10 -0
  44. package/package.json +64 -5
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 kiso contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,51 @@
1
- # Temporary Holding Version
1
+ # kiso web
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A local web app on the kiso hosted-session service: `kiso-web` runs the host in its own process and serves the page at
4
+ `http://127.0.0.1:4317`.
5
+
6
+ ```bash
7
+ npm install -g @vincemakes/kiso-web
8
+ kiso web # or kiso-web; the terminal's `kiso` runs this package when it is installed
9
+ ```
10
+
11
+ The host must be running for the page to work: it runs while the command runs. Node 22 or later, macOS or Linux (Windows is not supported yet); the
12
+ page runs in Safari 17, Chrome or Edge. Built into the root build; typecheck, gates and proofs run here and in the Web workflow.
13
+
14
+ Development:
15
+
16
+ ```
17
+ npm run build -w @vincemakes/kiso-web # tsc (host, shared, state) + vite (the page into out/)
18
+ npm run start -w @vincemakes/kiso-web # kiso-web: the host, and the default browser opened on the page
19
+ npm run gates -w @vincemakes/kiso-web # size, deps, imports, names, paths, CJK
20
+ npm run proofs:fast -w @vincemakes/kiso-web # daily, before each commit: no browser, no kill -9
21
+ npm run proofs:full -w @vincemakes/kiso-web # end of a milestone, before a push, before a release
22
+ npm run check -w @vincemakes/kiso-web # build + typecheck + gates + proofs:fast
23
+ npm run check:full -w @vincemakes/kiso-web # the same with proofs:full
24
+ ```
25
+
26
+ `kiso-web [--port 4317] [--no-open] [--revoke-sessions]`. The port is fixed, so a page added to the Dock (Safari: File > Add to Dock;
27
+ Chrome or Edge: Install page as app) keeps its address. Running it again while a host holds the port opens the browser on that host;
28
+ anything else on the port is an error that names the process. The host runs while the command runs: with no host, the page cannot load.
29
+
30
+ Sign-in: the command opens the browser on a one-time connect code in the address (good for two minutes); the page trades it for a
31
+ session cookie (`HttpOnly`, `SameSite=Strict`, named after the port) and strips it from the address, so the page holds no secret of its
32
+ own. The host keeps the sessions hashed in `$KISO_HOME/web.json` (mode 0600); `--revoke-sessions` forgets them all, and every page signs in
33
+ again from the command. Every request still passes the Host and Origin checks: a cookie on 127.0.0.1 is sent to every port of it. A session
34
+ is a key to this machine's shell: do not run a page you do not trust on 127.0.0.1.
35
+
36
+ Each proof is a script; its exit code is the verdict. `fast` is the reducer, markdown, usage, guard, sign-in, policy, control and task
37
+ proofs (seconds to under a minute). `full` adds the ones that kill the host with `kill -9` and the page proofs, which drive a headless
38
+ Chrome through the DevTools protocol (Chrome is found on the machine, or named by `KISO_WEB_CHROME`), including a tab closed and reopened
39
+ mid-run and one approval answered from two tabs. Rule: a change that only touches page styles or components needs `fast`; a change to
40
+ `src/main`, `src/shared`, the state reducer, the transport or recovery needs `full`.
41
+
42
+ The model, the effort and the sign-in come from the terminal's own configuration (`~/.kiso/config.json`, `auth.json`), read through
43
+ the `./config` door of `@vincemakes/kiso-code`: one login serves both products, and no key is read from the web app's own environment.
44
+ Works live where the terminal keeps sessions (`~/.kiso/projects/<enc>/`), so the history lists the Works of every folder. The folder
45
+ a new task starts in is `KISO_WEB_WORKSPACE`, else the one used last while it still exists, else the home folder; another is chosen in
46
+ the page's folder browser (folders only; from home, or from a full path typed in). Notifications are off until turned on in Settings,
47
+ and only while the page is open. See `src/main/env.ts` for the rest (`KISO_WEB_FAUX` runs a scripted model, for the proofs).
48
+
49
+ The proofs budget (`scripts/proofs`) is 2,100 lines from W1c on, by an owner ruling of 2026-11-01 (it was 1,800 from W1b by a ruling of 2026-10-31, 1,600 from S5 by a ruling of 2026-10-26, 1,300 from S4 by a ruling of 2026-10-25, 1,000 from S2 by a ruling of 2026-10-23, and 700 before); the other budgets are unchanged.
50
+
51
+ Plans, mocks and screenshots live outside this repository.
@@ -0,0 +1,80 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+ import { Agent, createAgent, kisoHome, runsACheck } from "@vincemakes/kiso-runtime";
4
+ import { adapterOptionsFor } from "@vincemakes/kiso-code/config";
5
+ import { createCodingTools } from "@vincemakes/kiso-tools-node";
6
+ import { CODING_TOOL_RULES, breakerExtension, composeSystemPrompt, floorExtension, modeExtensions, modeSystemPrompt, protectedShellExtension, readOnlyShellExtension, setMode, } from "@vincemakes/kiso-code/policy";
7
+ import { observedRevisionFrom } from "./observed-revision.js";
8
+ /** The adapter identity a model is routed by (what pricing is looked up on). */
9
+ export const routeOf = (model) => (model.kind === "adapter" ? model.route : model.profile.kind);
10
+ const DEFAULT_WINDOW = 200_000;
11
+ /** The credential store is never served to a model: file tools and shell checks share this list. */
12
+ export function protectedFiles() {
13
+ return [join(kisoHome(), "auth.json")];
14
+ }
15
+ /** The agent subclass the service opens per session: it binds the session's
16
+ * task delivery after the runtime creates the session — the service gives
17
+ * a host no other hook on the Session object. */
18
+ class WebAgent extends Agent {
19
+ #options;
20
+ constructor(definition, options) {
21
+ super(definition);
22
+ this.#options = options;
23
+ }
24
+ async session(options) {
25
+ const session = await super.session(options);
26
+ this.#options.onSession?.(options.id, session);
27
+ return session;
28
+ }
29
+ }
30
+ /** The system prompt a Work's agent is built with: the terminal's prompt for the folder (its project instructions included), and the tier's note. */
31
+ export function promptOf(workspace, tier) {
32
+ return [composeSystemPrompt(workspace, protectedFiles()), modeSystemPrompt(tier)].join("\n\n");
33
+ }
34
+ export function createWebAgent(options) {
35
+ const { store, workspace, model } = options;
36
+ const root = () => workspace;
37
+ const toolOptions = {
38
+ workspaceRoot: workspace,
39
+ excludeRoots: [kisoHome()],
40
+ secretEnvNames: options.secretEnvNames ?? [],
41
+ protectedFiles: protectedFiles(),
42
+ tasks: options.tasks.shellTasks,
43
+ observedRevision: observedRevisionFrom(store, workspace),
44
+ ...(options.toolLimits !== undefined ? { limits: options.toolLimits } : {}),
45
+ };
46
+ const window = model.kind === "profile" ? model.contextWindow : undefined;
47
+ const definition = {
48
+ model: model.kind === "adapter" ? model.model : model.profile.model,
49
+ store,
50
+ workspace,
51
+ systemPrompt: promptOf(workspace, options.tier()),
52
+ toolRules: CODING_TOOL_RULES,
53
+ tools: [...createCodingTools(toolOptions)],
54
+ // the terminal's chain, in its order: the guards that deny speak first
55
+ extensions: [
56
+ protectedShellExtension({ files: protectedFiles, workspaceRoot: root, env: () => ({ home: homedir(), kisoHome: kisoHome() }) }),
57
+ floorExtension(() => true, root),
58
+ breakerExtension(),
59
+ ...modeExtensions(root, options.tier),
60
+ readOnlyShellExtension(() => ({ workspaceRoot: workspace, excludeRoots: toolOptions.excludeRoots })),
61
+ ],
62
+ // in-run compaction, as the terminal turns it on (ADR-0055): only a window someone stated arms the overflow belt
63
+ contextPolicy: {
64
+ tiers: {
65
+ windowTokens: window ?? DEFAULT_WINDOW,
66
+ isCheck: (command) => runsACheck(command, options.checkRunners ?? []),
67
+ statedWindow: () => window ?? null,
68
+ },
69
+ },
70
+ ...(model.kind === "adapter"
71
+ ? { adapter: model.adapter }
72
+ : {
73
+ provider: model.profile.kind,
74
+ ...adapterOptionsFor(model.profile, model.auth, options.sessionId),
75
+ ...(model.profile.streamIdleMs !== undefined ? { streamIdleMs: model.profile.streamIdleMs } : {}),
76
+ ...(model.reasoning !== undefined ? { reasoning: model.reasoning } : {}),
77
+ }),
78
+ };
79
+ return options.onSession === undefined ? createAgent(definition) : new WebAgent(definition, options);
80
+ }
@@ -0,0 +1,68 @@
1
+ const FRESH_MS = 15_000;
2
+ const KEEP = 50;
3
+ export class ApprovalWatch {
4
+ #spaces;
5
+ #notify;
6
+ #presence;
7
+ /** decisions already announced, per `key/session`; forgotten once answered */
8
+ #told = new Map();
9
+ /** Works seen running at the last look */
10
+ #running = new Set();
11
+ #notices = [];
12
+ #seq = 0;
13
+ #timer;
14
+ constructor(spaces, notify, presence) {
15
+ this.#spaces = spaces;
16
+ this.#notify = notify;
17
+ this.#presence = presence;
18
+ }
19
+ start(everyMs = 1_000) {
20
+ this.#timer = setInterval(() => void this.poll(), everyMs);
21
+ this.#timer.unref();
22
+ }
23
+ stop() {
24
+ clearInterval(this.#timer);
25
+ }
26
+ /** The notices after `seq` (the page keeps the last number it saw), and the newest number. */
27
+ since(seq) {
28
+ return { seq: this.#seq, notices: this.#notices.filter((n) => n.seq > seq) };
29
+ }
30
+ #announce(notice) {
31
+ const full = { ...notice, seq: ++this.#seq };
32
+ this.#notices.push(full);
33
+ if (this.#notices.length > KEEP)
34
+ this.#notices.shift();
35
+ this.#notify?.(full);
36
+ }
37
+ async poll() {
38
+ const p = this.#presence();
39
+ const here = p !== null && p.focused && Date.now() - p.at < FRESH_MS ? p : null;
40
+ for (const space of this.#spaces()) {
41
+ for (const sessionId of space.opened()) {
42
+ const id = `${space.key}/${sessionId}`;
43
+ const away = here === null || here.key !== space.key || here.session !== sessionId;
44
+ const folder = space.workspace.split("/").pop() ?? "";
45
+ const running = space.service.isRunning(sessionId);
46
+ if (running)
47
+ this.#running.add(id);
48
+ else if (this.#running.delete(id) && away)
49
+ this.#announce({ kind: "done", key: space.key, sessionId, folder });
50
+ const pending = running ? await space.service.pendingApprovals(sessionId).catch(() => []) : [];
51
+ const told = this.#told.get(id) ?? new Set();
52
+ const still = new Set();
53
+ for (const approval of pending) {
54
+ if (told.has(approval.decisionId))
55
+ still.add(approval.decisionId);
56
+ else if (away) {
57
+ still.add(approval.decisionId);
58
+ this.#announce({ kind: "approval", key: space.key, sessionId, decisionId: approval.decisionId, tool: approval.name, folder });
59
+ }
60
+ }
61
+ if (still.size === 0)
62
+ this.#told.delete(id);
63
+ else
64
+ this.#told.set(id, still);
65
+ }
66
+ }
67
+ }
68
+ }
@@ -0,0 +1,166 @@
1
+ import { closeSync, copyFileSync, existsSync, fstatSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, statSync } from "node:fs";
2
+ import { basename, extname, isAbsolute, join, relative, resolve } from "node:path";
3
+ import { resolveInside } from "./files.js";
4
+ /**
5
+ * What a tool call made that the page can show, each "best effort, never a guess": an image block of its result; an artifact
6
+ * file a successful write or edit touched; or, for a command, a path its text or its output head names that is inside the
7
+ * folder, of an artifact type, exists, and changed at or after the step began. No folder scan, no watcher.
8
+ *
9
+ * A picture a command made OUTSIDE the folder (a browser tool saving a screenshot under /tmp: owner 2026-11-03, the screenshot
10
+ * must show in the stream) is kept as a copy in the Work's own `<id>.files/`, under the result's `log:` address: the page only
11
+ * ever reads the copy, never the outside path. Only a raster picture (no svg: it can carry script), named by the command or its
12
+ * output, changed during the step, at most KEEP_MAX bytes; it is copied once, so a later file of the same name does not replace
13
+ * it. This widens the 2026-10-31 rule "a file named outside the folder is not taken" for pictures only.
14
+ */
15
+ const KINDS = {
16
+ ".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
17
+ ".html": "page", ".htm": "page", ".md": "doc", ".pdf": "doc", ".csv": "doc",
18
+ ".mp4": "media", ".webm": "media", ".mov": "media", ".mp3": "media", ".wav": "media",
19
+ };
20
+ const NAMED = /[\w@+./-]+\.(?:png|jpe?g|gif|webp|svg|html?|md|pdf|csv|mp4|webm|mov|mp3|wav)\b/gi;
21
+ /** a file's clock and the log's may differ by the file system's granularity */
22
+ const SLACK_MS = 50;
23
+ const KEPT = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp" };
24
+ const KEEP_MAX = 10 * 1024 * 1024;
25
+ /** where a kept copy lives: the Work's own folder of files beside its log */
26
+ const keptDir = (dir, workId) => join(dir, `${workId}.files`);
27
+ export const artifactKind = (path) => KINDS[extname(path).toLowerCase()];
28
+ /** `root`: the folder's real path; `dir`: the sessions directory, where a picture made outside the folder is kept. */
29
+ export function artifactsOf(event, workId, call, root, dir) {
30
+ const out = [];
31
+ const ref = (n, kind, uri, mediaType, name) => {
32
+ out.push({ id: `${event.callId}:${n}`, workId, callId: event.callId, kind, uri, ...(mediaType !== undefined ? { mediaType } : {}), ...(name !== undefined ? { name } : {}) });
33
+ };
34
+ if (Array.isArray(event.content)) {
35
+ const blocks = event.content.filter((b) => b.type === "image").length;
36
+ let k = 0;
37
+ event.content.forEach((b, n) => {
38
+ if (b.type !== "image" || !((b.sourceType === "base64" && b.data !== undefined) || b.url?.startsWith("https:") === true))
39
+ return;
40
+ // an image block has no file name: the tool, the result's number and, of several, its place
41
+ const ext = (b.mediaType ?? "image/png").split("/")[1]?.replace("jpeg", "jpg") ?? "png";
42
+ ref(n, "image", `log:${event.seq}/${n}`, b.mediaType, `${call?.name ?? "image"}-${event.seq}${blocks > 1 ? `-${(k += 1)}` : ""}.${ext}`);
43
+ });
44
+ }
45
+ if (event.isError || call === null)
46
+ return out;
47
+ let kept = 0;
48
+ const outside = (full, since) => {
49
+ const ext = extname(full).toLowerCase();
50
+ if (KEPT[ext] === undefined || out.some((a) => a.id === `${event.callId}:${full}`))
51
+ return;
52
+ const name = `${event.seq}-k${kept}${ext}`;
53
+ const copy = join(keptDir(dir, workId), name);
54
+ try {
55
+ if (!existsSync(copy)) {
56
+ const stat = statSync(full);
57
+ if (!stat.isFile() || stat.mtimeMs < since - SLACK_MS || stat.size > KEEP_MAX)
58
+ return;
59
+ mkdirSync(keptDir(dir, workId), { recursive: true });
60
+ copyFileSync(full, copy);
61
+ }
62
+ }
63
+ catch {
64
+ return;
65
+ }
66
+ ref(full, "image", `log:${event.seq}/k${kept}`, KEPT[ext], basename(full));
67
+ kept += 1;
68
+ };
69
+ const file = (raw, since) => {
70
+ const rel = relative(root, isAbsolute(raw) ? raw : resolve(root, raw));
71
+ const kind = artifactKind(rel);
72
+ if (rel.startsWith("..") && isAbsolute(raw) && since !== null)
73
+ return outside(raw, since);
74
+ if (kind === undefined || rel.startsWith("..") || out.some((a) => a.uri === `file:${rel}`))
75
+ return;
76
+ try {
77
+ const stat = statSync(resolveInside(root, rel));
78
+ if (stat.isFile() && (since === null || stat.mtimeMs >= since - SLACK_MS))
79
+ ref(rel, kind, `file:${rel}`);
80
+ }
81
+ catch {
82
+ // gone, outside, protected: not shown
83
+ }
84
+ };
85
+ const path = call.input["path"];
86
+ if ((call.name === "write_file" || call.name === "edit_file") && typeof path === "string")
87
+ file(path, null);
88
+ if (call.name === "shell" && call.startedAt !== null) {
89
+ const text = `${String(call.input["command"] ?? "")}\n${typeof event.content === "string" ? event.content.slice(0, 16_384) : ""}`;
90
+ for (const m of text.matchAll(NAMED))
91
+ file(m[0], call.startedAt);
92
+ }
93
+ return out;
94
+ }
95
+ /**
96
+ * Image blocks of results and of the person's own lines, read from the session's log: an index of seq to the byte range of its line, built once per log and
97
+ * extended as it grows, so a request reads one event, never the whole log.
98
+ */
99
+ export class LogImages {
100
+ #dir;
101
+ #logs = new Map();
102
+ constructor(dir) {
103
+ this.#dir = dir;
104
+ }
105
+ /** The n-th block of the result or the line at `seq`: its bytes, an https address to pass on, or null; `k<n>`: the n-th
106
+ * picture of that result kept from outside the folder. */
107
+ block(sessionId, seq, n) {
108
+ if (typeof n === "string") {
109
+ const name = (() => {
110
+ try {
111
+ return readdirSync(keptDir(this.#dir, sessionId)).find((f) => f.startsWith(`${seq}-${n}.`));
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ })();
117
+ const type = name === undefined ? undefined : KEPT[extname(name)];
118
+ return name === undefined || type === undefined ? null : { type, bytes: readFileSync(join(keptDir(this.#dir, sessionId), name)) };
119
+ }
120
+ const file = join(this.#dir, `${sessionId}.jsonl`);
121
+ const at = this.#index(file).get(seq);
122
+ if (at === undefined)
123
+ return null;
124
+ // a tool's result, or the person's own line (a picture pasted into the composer)
125
+ const event = JSON.parse(read(file, at[0], at[1]).toString("utf8")).event;
126
+ const b = (event.type === "tool_result" || event.type === "user_input") && Array.isArray(event.content) ? event.content[n] : undefined;
127
+ if (b?.type !== "image")
128
+ return null;
129
+ if (b.sourceType === "base64" && b.data !== undefined)
130
+ return { type: b.mediaType ?? "image/png", bytes: Buffer.from(b.data, "base64") };
131
+ return b.url?.startsWith("https:") === true ? { url: b.url } : null;
132
+ }
133
+ #index(file) {
134
+ let log = this.#logs.get(file);
135
+ const size = statSync(file).size;
136
+ if (log === undefined || size < log.size)
137
+ this.#logs.set(file, (log = { size: 0, lines: new Map() }));
138
+ if (size > log.size) {
139
+ const chunk = read(file, log.size, size - log.size);
140
+ let from = 0;
141
+ for (let nl = chunk.indexOf(10); nl !== -1; from = nl + 1, nl = chunk.indexOf(10, from)) {
142
+ try {
143
+ const seq = JSON.parse(chunk.subarray(from, nl).toString("utf8")).event.seq;
144
+ log.lines.set(seq, [log.size + from, nl - from]);
145
+ }
146
+ catch {
147
+ // a line that is not a record: skipped
148
+ }
149
+ }
150
+ // a line still being written is read next time
151
+ log.size += from;
152
+ }
153
+ return log.lines;
154
+ }
155
+ }
156
+ function read(file, start, length) {
157
+ const fd = openSync(file, "r");
158
+ try {
159
+ const buf = Buffer.alloc(Math.max(0, Math.min(length, fstatSync(fd).size - start)));
160
+ readSync(fd, buf, 0, buf.length, start);
161
+ return buf;
162
+ }
163
+ finally {
164
+ closeSync(fd);
165
+ }
166
+ }
@@ -0,0 +1,109 @@
1
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { chmodSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ /**
5
+ * The page's sign-in. The command that starts the host opens the browser on `#c=<code>`: a one-time connect code that lives two
6
+ * minutes. The page trades it once (`POST /auth/connect`) for a session cookie, `HttpOnly` and `SameSite=Strict`, named after the
7
+ * port, and strips the fragment: the page never holds a long-lived secret, and a cookie (unlike page storage) is what Safari copies
8
+ * into a web app added to the Dock. The host keeps the sessions in `$KISO_HOME/web.json` (mode 0600), as SHA-256 hashes only, and
9
+ * reads the file again when it changes, so `kiso-web --revoke-sessions` reaches a host that is running. A cookie on 127.0.0.1 is
10
+ * sent to every port of that host, so the cookie is never the whole guard: the Host and Origin checks stay on every request.
11
+ */
12
+ const CODE_TTL_MS = 120_000;
13
+ /** a year: the cookie outlives a browser restart and a Dock launch (browsers cap it near 400 days anyway) */
14
+ const COOKIE_AGE_S = 365 * 86_400;
15
+ const MAX_SESSIONS = 50;
16
+ const sha256 = (text) => createHash("sha256").update(text).digest("hex");
17
+ export const cookieName = (port) => `kiso_web_${port}`;
18
+ export const webFile = (home) => join(home, "web.json");
19
+ function readWeb(file) {
20
+ try {
21
+ return JSON.parse(readFileSync(file, "utf8"));
22
+ }
23
+ catch {
24
+ return {};
25
+ }
26
+ }
27
+ function writeWeb(file, data) {
28
+ mkdirSync(dirname(file), { recursive: true });
29
+ const tmp = `${file}.${process.pid}.tmp`;
30
+ writeFileSync(tmp, `${JSON.stringify(data, null, "\t")}\n`, { mode: 0o600 });
31
+ chmodSync(tmp, 0o600);
32
+ renameSync(tmp, file);
33
+ }
34
+ /** Forget every session: each page signed in before must be opened again from the command. Returns how many were forgotten. */
35
+ export function revokeSessions(home) {
36
+ const file = webFile(home);
37
+ const count = readWeb(file).sessions?.length ?? 0;
38
+ writeWeb(file, { ...readWeb(file), sessions: [] });
39
+ return count;
40
+ }
41
+ export class Auth {
42
+ #file;
43
+ #cookie;
44
+ #codes = new Map();
45
+ #hashes = new Set();
46
+ #stamp = "";
47
+ constructor(home, port) {
48
+ this.#file = webFile(home);
49
+ this.#cookie = cookieName(port);
50
+ }
51
+ /** A fresh one-time code for the browser's address. */
52
+ code() {
53
+ const now = Date.now();
54
+ for (const [c, at] of this.#codes)
55
+ if (now - at > CODE_TTL_MS)
56
+ this.#codes.delete(c);
57
+ const code = randomBytes(24).toString("base64url");
58
+ this.#codes.set(sha256(code), now);
59
+ return code;
60
+ }
61
+ /** Trade a code for a new session: the Set-Cookie value, or null (unknown, used or expired). */
62
+ connect(code) {
63
+ if (typeof code !== "string" || code.length > 128)
64
+ return null;
65
+ const key = sha256(code);
66
+ const at = this.#codes.get(key);
67
+ this.#codes.delete(key);
68
+ if (at === undefined || Date.now() - at > CODE_TTL_MS)
69
+ return null;
70
+ const session = randomBytes(32).toString("base64url");
71
+ const kept = (readWeb(this.#file).sessions ?? []).slice(-(MAX_SESSIONS - 1));
72
+ writeWeb(this.#file, { ...readWeb(this.#file), sessions: [...kept, { hash: sha256(session), createdAt: Date.now() }] });
73
+ return `${this.#cookie}=${session}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${COOKIE_AGE_S}`;
74
+ }
75
+ /** Whether the request carries a session cookie the host still keeps. */
76
+ signedIn(req) {
77
+ const given = (req.headers.cookie ?? "")
78
+ .split(";")
79
+ .map((part) => part.trim())
80
+ .find((part) => part.startsWith(`${this.#cookie}=`))
81
+ ?.slice(this.#cookie.length + 1);
82
+ if (given === undefined || given === "")
83
+ return false;
84
+ this.#reload();
85
+ const hash = Buffer.from(sha256(given));
86
+ for (const known of this.#hashes)
87
+ if (timingSafeEqual(Buffer.from(known), hash))
88
+ return true;
89
+ return false;
90
+ }
91
+ /** The cookie that ends this browser's session (a sign-out). */
92
+ cleared() {
93
+ return `${this.#cookie}=; HttpOnly; SameSite=Strict; Path=/; Max-Age=0`;
94
+ }
95
+ #reload() {
96
+ let stamp = "none";
97
+ try {
98
+ const s = statSync(this.#file);
99
+ stamp = `${s.mtimeMs}:${s.size}`;
100
+ }
101
+ catch {
102
+ // no file: no session
103
+ }
104
+ if (stamp === this.#stamp)
105
+ return;
106
+ this.#stamp = stamp;
107
+ this.#hashes = new Set((readWeb(this.#file).sessions ?? []).map((s) => s.hash));
108
+ }
109
+ }
@@ -0,0 +1,20 @@
1
+ import { kisoHome } from "@vincemakes/kiso-runtime";
2
+ import { tiersFor } from "@vincemakes/kiso-runtime/internal";
3
+ import { CODING_TOOL_RULES, composeSystemPrompt, SYSTEM_PROMPT } from "@vincemakes/kiso-code/policy";
4
+ import { createCodingTools } from "@vincemakes/kiso-tools-node";
5
+ import { promptOf, protectedFiles } from "./agent.js";
6
+ /** What the Work's requests always carry, in bytes, for the rail's Context breakdown (an estimate the page labels as one), and where
7
+ * compaction starts and must happen for the stated window (the runtime's own tiers; null without a stated window). */
8
+ export function contextParts(workspace, tier, window) {
9
+ const bytes = (s) => Buffer.byteLength(s, "utf8");
10
+ const prompt = promptOf(workspace, tier);
11
+ const instructions = Math.max(0, bytes(composeSystemPrompt(workspace, protectedFiles())) - bytes(SYSTEM_PROMPT));
12
+ const tools = createCodingTools({ workspaceRoot: workspace, excludeRoots: [kisoHome()], secretEnvNames: [], protectedFiles: protectedFiles() });
13
+ const tiers = window === null ? null : tiersFor(window, 0);
14
+ return {
15
+ system: bytes(prompt) - instructions + CODING_TOOL_RULES.reduce((n, r) => n + bytes(r.line) + 1, 0),
16
+ instructions,
17
+ tools: { count: tools.length, bytes: tools.reduce((n, t) => n + bytes(JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters })), 0) },
18
+ compaction: tiers === null ? null : { starts: tiers.soft, must: tiers.hard },
19
+ };
20
+ }