@vincemakes/kiso-web 0.52.0 → 0.54.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.
- package/dist/main/agent.js +11 -6
- package/dist/main/approvals.js +15 -3
- package/dist/main/artifacts.js +69 -12
- package/dist/main/context.js +20 -0
- package/dist/main/desk-api.js +181 -7
- package/dist/main/env.js +2 -0
- package/dist/main/environment.js +102 -0
- package/dist/main/folder-info.js +18 -1
- package/dist/main/fork.js +25 -0
- package/dist/main/fs-routes.js +53 -4
- package/dist/main/git.js +117 -0
- package/dist/main/history.js +20 -6
- package/dist/main/host.js +20 -1
- package/dist/main/names.js +15 -0
- package/dist/main/pick-folder.js +41 -0
- package/dist/main/prs.js +37 -0
- package/dist/main/settings.js +82 -8
- package/dist/main/space.js +105 -20
- package/dist/main/tasks.js +4 -0
- package/dist/main/times.js +19 -0
- package/dist/main/usage.js +32 -2
- package/dist/main/work-folders.js +28 -0
- package/dist/main/works.js +21 -0
- package/dist/shared/git.js +2 -0
- package/out/renderer/assets/index-CSaqocLC.css +1 -0
- package/out/renderer/assets/index-Dt9YIp7q.js +46 -0
- package/out/renderer/favicon-32.png +0 -0
- package/out/renderer/favicon.svg +1 -0
- package/out/renderer/icon.png +0 -0
- package/out/renderer/index.html +5 -3
- package/package.json +10 -10
- package/dist/shared/thinking.js +0 -65
- package/out/renderer/assets/index-CmrQEQ98.js +0 -44
- package/out/renderer/assets/index-D9KtogVK.css +0 -1
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { ForkRefusedError } from "@vincemakes/kiso-runtime";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* Fork a Work: the runtime's SessionStore.fork (one rule for the terminal, the web app and the products, plan fork rev 2),
|
|
5
|
+
* after the run that ended at `atSeq` (absent: the last). The runtime copies the log to that run's end, writes the fork's
|
|
6
|
+
* sidecar (the parent's profile, "… (fork)", its origin) and gives the fork note to the model with the fork's first input,
|
|
7
|
+
* after the copied history: never in the system prompt, where it cost the fork's first request the whole history's cache
|
|
8
|
+
* (0% against 88% measured on co). The host still remembers the lineage for the forks it made before.
|
|
9
|
+
*/
|
|
10
|
+
export function forkWork(space, settings, parent, atSeq) {
|
|
11
|
+
if (!space.store.ids().includes(parent))
|
|
12
|
+
throw new Refusal(404, "no such Work in this folder");
|
|
13
|
+
let made;
|
|
14
|
+
try {
|
|
15
|
+
made = space.store.fork(parent, atSeq === undefined ? {} : { afterSeq: atSeq });
|
|
16
|
+
}
|
|
17
|
+
catch (error) {
|
|
18
|
+
if (error instanceof ForkRefusedError)
|
|
19
|
+
throw new Refusal(409, error.message.replace(/session/g, "Work"));
|
|
20
|
+
throw error;
|
|
21
|
+
}
|
|
22
|
+
const fork = { parent, title: made.origin.title, atSeq: made.origin.afterSeq, at: made.origin.at };
|
|
23
|
+
settings.forked(parent, made.id, fork);
|
|
24
|
+
return { id: made.id, fork };
|
|
25
|
+
}
|
package/dist/main/fs-routes.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { readdirSync, realpathSync } from "node:fs";
|
|
2
3
|
import { listDir, readPreview, readTaskLog, Refusal } from "./files.js";
|
|
3
4
|
/**
|
|
4
|
-
* The read doors of the page: a Work's folder (a tree, a file
|
|
5
|
-
* background task's log. GET only, behind the host's guard; every path is
|
|
5
|
+
* The read doors of the page: a Work's folder (a tree, a file, the list the
|
|
6
|
+
* composer's @ picks from) and a background task's log. GET only, behind the host's guard; every path is
|
|
6
7
|
* judged by files.ts, a task id only by the task manager of a session that
|
|
7
8
|
* exists in the Work's folder.
|
|
8
9
|
*/
|
|
9
|
-
const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
10
|
+
export const SAFE_ID = /^[A-Za-z0-9._-]+$/;
|
|
10
11
|
export function deskRead(url, space) {
|
|
11
12
|
try {
|
|
12
13
|
const rel = url.searchParams.get("path") ?? "";
|
|
@@ -35,3 +36,51 @@ export function deskRead(url, space) {
|
|
|
35
36
|
return { status: 500, body: { code: "internal", message: "unreadable" } };
|
|
36
37
|
}
|
|
37
38
|
}
|
|
39
|
+
const AT_CAP = 2000;
|
|
40
|
+
const AT_SKIP = new Set([".git", "node_modules", ".kiso", "dist", "build", ".next", ".venv", "__pycache__"]);
|
|
41
|
+
/** The files `@` can name, as the terminal lists them: git's own list (tracked and untracked, ignores honoured), else a bounded walk; `truncated` when the cap cut it. */
|
|
42
|
+
export function atFiles(space, extra = [], stops = []) {
|
|
43
|
+
const own = rootFiles(space.workspace, "");
|
|
44
|
+
// only a root that is still its own real path (one replaced by a symlink is refused by the file tools, so it is not listed), and the walk
|
|
45
|
+
// itself leaves out the stop roots (`~/.ssh`, `~/.aws`, the kiso home ...): they are not listed and filtered out afterwards
|
|
46
|
+
const roots = extra.filter((r) => {
|
|
47
|
+
try {
|
|
48
|
+
return realpathSync(r) === r;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
const files = roots.reduce((all, root) => [...all, ...rootFiles(root, `${root}/`, stops)], own);
|
|
55
|
+
return { files: files.slice(0, AT_CAP), truncated: files.length > AT_CAP };
|
|
56
|
+
}
|
|
57
|
+
/** One root's files, each under `prefix` (the primary's are relative; an added folder's are absolute, as the model must name them). */
|
|
58
|
+
function rootFiles(cwd, prefix, stops = []) {
|
|
59
|
+
let files;
|
|
60
|
+
const out = stops.filter((s) => s.startsWith(`${cwd}/`)).map((s) => `:(exclude)${s.slice(cwd.length + 1)}`);
|
|
61
|
+
try {
|
|
62
|
+
files = execFileSync("git", ["ls-files", "-c", "-o", "--exclude-standard", ...(out.length > 0 ? ["--", ".", ...out] : [])], { cwd, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 24, timeout: 3000 }).split("\n").filter((p) => p !== "").map((p) => `${prefix}${p}`);
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
files = [];
|
|
66
|
+
const walk = (dir, prefix) => {
|
|
67
|
+
try {
|
|
68
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
69
|
+
if (files.length > AT_CAP)
|
|
70
|
+
return;
|
|
71
|
+
if (AT_SKIP.has(e.name) || stops.includes(`${dir}/${e.name}`))
|
|
72
|
+
continue;
|
|
73
|
+
if (e.isDirectory())
|
|
74
|
+
walk(`${dir}/${e.name}`, `${prefix}${e.name}/`);
|
|
75
|
+
else if (e.isFile())
|
|
76
|
+
files.push(`${prefix}${e.name}`);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// an unreadable directory adds nothing
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
walk(cwd, prefix);
|
|
84
|
+
}
|
|
85
|
+
return files;
|
|
86
|
+
}
|
package/dist/main/git.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* The rail's Changes from git, and the one write it can make on the person's word: commit the files they ticked, push the branch
|
|
5
|
+
* and open a pull request with `gh`. Reads are bounded (a timeout each, nothing on a folder outside git); the writes run only
|
|
6
|
+
* from the dialog's confirm (POST), never by themselves, and refuse what they cannot do in words.
|
|
7
|
+
*/
|
|
8
|
+
const run = (cwd, cmd, args, timeout = 3000) => new Promise((resolve, reject) => execFile(cmd, [...args], { cwd, timeout, maxBuffer: 1 << 23, env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GH_PROMPT_DISABLED: "1" } }, (err, out, errOut) => (err === null ? resolve(out) : reject(new Error((errOut || err.message).trim())))));
|
|
9
|
+
export const tryRun = (cwd, cmd, args, timeout) => run(cwd, cmd, args, timeout).then((o) => o.trim(), () => null);
|
|
10
|
+
let ghSignedIn = null;
|
|
11
|
+
/** `gh auth status`, asked once per host process: a sign-in does not come and go between two clicks. */
|
|
12
|
+
const ghReady = (cwd) => (ghSignedIn ??= tryRun(cwd, "gh", ["auth", "status"], 5000).then((o) => o !== null));
|
|
13
|
+
async function baseOf(cwd) {
|
|
14
|
+
const head = await tryRun(cwd, "git", ["symbolic-ref", "--short", "refs/remotes/origin/HEAD"]);
|
|
15
|
+
if (head !== null)
|
|
16
|
+
return head.replace(/^origin\//, "");
|
|
17
|
+
for (const name of ["main", "master"])
|
|
18
|
+
if ((await tryRun(cwd, "git", ["rev-parse", "--verify", "--quiet", `refs/heads/${name}`])) !== null)
|
|
19
|
+
return name;
|
|
20
|
+
return null;
|
|
21
|
+
}
|
|
22
|
+
/** The changed paths and their two-letter codes (`git status -z`: NUL-separated, so a path with a space or a quote is read whole; a rename gives its new path). */
|
|
23
|
+
async function statusOf(cwd) {
|
|
24
|
+
const out = (await run(cwd, "git", ["status", "--porcelain=v1", "-z", "--untracked-files=all"]).catch(() => "")).split("\0");
|
|
25
|
+
const map = new Map();
|
|
26
|
+
for (let i = 0; i < out.length; i++) {
|
|
27
|
+
const e = out[i];
|
|
28
|
+
if (e.length < 4)
|
|
29
|
+
continue;
|
|
30
|
+
map.set(e.slice(3), e.slice(0, 2));
|
|
31
|
+
if (e[0] === "R" || e[0] === "C")
|
|
32
|
+
i += 1; // the old path follows a rename or a copy
|
|
33
|
+
}
|
|
34
|
+
return map;
|
|
35
|
+
}
|
|
36
|
+
/** The base as a ref to compare with: origin's copy when there is one (what the PR is judged against), else the local branch. */
|
|
37
|
+
const baseRef = async (cwd, base) => ((await tryRun(cwd, "git", ["rev-parse", "--verify", "--quiet", `refs/remotes/origin/${base}`])) !== null ? `origin/${base}` : base);
|
|
38
|
+
export async function gitOf(cwd) {
|
|
39
|
+
const none = { repo: false, branch: null, base: null, ahead: 0, files: [], dirty: false, canPr: false, pr: null };
|
|
40
|
+
if ((await tryRun(cwd, "git", ["rev-parse", "--is-inside-work-tree"])) !== "true")
|
|
41
|
+
return none;
|
|
42
|
+
const branch = await tryRun(cwd, "git", ["symbolic-ref", "--short", "HEAD"]);
|
|
43
|
+
const base = await baseOf(cwd);
|
|
44
|
+
const ref = base === null ? null : await baseRef(cwd, base);
|
|
45
|
+
const fork = ref === null ? null : await tryRun(cwd, "git", ["merge-base", ref, "HEAD"]);
|
|
46
|
+
const ahead = ref === null ? 0 : Number((await tryRun(cwd, "git", ["rev-list", "--count", `${ref}..HEAD`])) ?? 0);
|
|
47
|
+
const dirty = await statusOf(cwd);
|
|
48
|
+
const numstat = fork === null ? "" : await run(cwd, "git", ["diff", "--numstat", "-z", "--no-renames", fork]).catch(() => "");
|
|
49
|
+
const files = new Map();
|
|
50
|
+
for (const entry of numstat.split("\0").filter((l) => l !== "")) {
|
|
51
|
+
const [a, r, path] = entry.split("\t");
|
|
52
|
+
files.set(path, { path, added: a === "-" ? null : Number(a), removed: r === "-" ? null : Number(r), state: "modified", dirty: dirty.has(path) });
|
|
53
|
+
}
|
|
54
|
+
for (const [path, code] of dirty) {
|
|
55
|
+
const state = code.includes("?") || code.includes("A") ? "new" : code.includes("D") ? "deleted" : "modified";
|
|
56
|
+
files.set(path, { ...(files.get(path) ?? { path, added: null, removed: null }), state, dirty: true });
|
|
57
|
+
}
|
|
58
|
+
const remote = await tryRun(cwd, "git", ["remote", "get-url", "origin"]);
|
|
59
|
+
const canPr = branch !== null && base !== null && branch !== base && remote !== null && (await ghReady(cwd));
|
|
60
|
+
let pr = null;
|
|
61
|
+
if (canPr) {
|
|
62
|
+
const json = await tryRun(cwd, "gh", ["pr", "view", "--json", "number,title,url,state,statusCheckRollup"], 6000);
|
|
63
|
+
if (json !== null) {
|
|
64
|
+
const p = JSON.parse(json);
|
|
65
|
+
const checks = p.statusCheckRollup ?? [];
|
|
66
|
+
pr = { number: p.number, title: p.title, url: p.url, state: p.state, checks: { total: checks.length, done: checks.filter((c) => c.status === "COMPLETED").length, failed: checks.filter((c) => c.conclusion === "FAILURE").length } };
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
return { repo: true, branch, base, ahead, files: [...files.values()], dirty: dirty.size > 0, canPr, pr };
|
|
70
|
+
}
|
|
71
|
+
/** Commit the files the person ticked, with their message: those paths only (never `git add -A`), so nothing they did not see is committed. */
|
|
72
|
+
export async function commit(cwd, message, paths) {
|
|
73
|
+
if (message.trim() === "")
|
|
74
|
+
throw new Refusal(400, "a commit needs a message");
|
|
75
|
+
if (paths.length === 0)
|
|
76
|
+
throw new Refusal(400, "no file was chosen");
|
|
77
|
+
const listed = await statusOf(cwd);
|
|
78
|
+
const strange = paths.filter((p) => !listed.has(p));
|
|
79
|
+
if (strange.length > 0)
|
|
80
|
+
throw new Refusal(400, `not a changed file of this folder: ${strange.join(", ")}`);
|
|
81
|
+
await run(cwd, "git", ["add", "--", ...paths]);
|
|
82
|
+
await run(cwd, "git", ["commit", "-m", message, "--", ...paths]).catch((e) => {
|
|
83
|
+
throw new Refusal(409, `git refused the commit: ${e.message}`);
|
|
84
|
+
});
|
|
85
|
+
return { sha: (await run(cwd, "git", ["rev-parse", "--short", "HEAD"])).trim() };
|
|
86
|
+
}
|
|
87
|
+
/** Push the branch and open its pull request into the base; refused with the reason when the folder cannot. */
|
|
88
|
+
export async function openPr(cwd, title, body) {
|
|
89
|
+
const g = await gitOf(cwd);
|
|
90
|
+
if (!g.canPr || g.branch === null || g.base === null)
|
|
91
|
+
throw new Refusal(409, "a pull request needs a branch other than the base, a remote named origin, and gh signed in");
|
|
92
|
+
if (g.dirty)
|
|
93
|
+
throw new Refusal(409, "commit the changes first: a pull request carries commits, not the working tree");
|
|
94
|
+
if (g.ahead === 0)
|
|
95
|
+
throw new Refusal(409, `${g.branch} has no commit that ${g.base} does not have`);
|
|
96
|
+
if (title.trim() === "")
|
|
97
|
+
throw new Refusal(400, "a pull request needs a title");
|
|
98
|
+
await run(cwd, "git", ["push", "-u", "origin", "HEAD"], 60_000).catch((e) => {
|
|
99
|
+
throw new Refusal(409, `git push failed: ${e.message}`);
|
|
100
|
+
});
|
|
101
|
+
const out = await run(cwd, "gh", ["pr", "create", "--base", g.base, "--head", g.branch, "--title", title, "--body", body], 60_000).catch((e) => {
|
|
102
|
+
throw new Refusal(409, `gh pr create failed: ${e.message}`);
|
|
103
|
+
});
|
|
104
|
+
const url = out.trim().split("\n").pop() ?? "";
|
|
105
|
+
return { number: Number(/\/pull\/(\d+)/.exec(url)?.[1] ?? 0), url };
|
|
106
|
+
}
|
|
107
|
+
/** The dialog's starting draft from the branch's own commits: one commit gives its subject; several, the branch's name and a list. */
|
|
108
|
+
export async function prDraft(cwd) {
|
|
109
|
+
const g = await gitOf(cwd);
|
|
110
|
+
if (g.base === null)
|
|
111
|
+
return { title: "", body: "" };
|
|
112
|
+
const ref = await baseRef(cwd, g.base);
|
|
113
|
+
const subjects = ((await tryRun(cwd, "git", ["log", "--format=%s", `${ref}..HEAD`])) ?? "").split("\n").filter((s) => s !== "").reverse();
|
|
114
|
+
if (subjects.length === 1)
|
|
115
|
+
return { title: subjects[0], body: "" };
|
|
116
|
+
return { title: (g.branch ?? "").replace(/^[^/]*\//, "").replace(/[-_]/g, " "), body: subjects.map((s) => `- ${s}`).join("\n") };
|
|
117
|
+
}
|
package/dist/main/history.js
CHANGED
|
@@ -13,16 +13,22 @@ import { sessionFolders } from "@vincemakes/kiso-code/config";
|
|
|
13
13
|
*/
|
|
14
14
|
const MIN_AGE_MS = 3_000;
|
|
15
15
|
const MAX_ROWS = 400;
|
|
16
|
-
/** The folder's files as one string: it changes when a log is added or written.
|
|
16
|
+
/** The folder's files as one string: it changes when a log is added or written, or a Work's name file (`<id>.meta.json`, the
|
|
17
|
+
* runtime's titles read it) is written by a rename. */
|
|
17
18
|
function printOf(dir) {
|
|
18
19
|
let count = 0;
|
|
19
20
|
let bytes = 0;
|
|
20
21
|
let newest = 0;
|
|
21
22
|
for (const name of readdirSync(dir)) {
|
|
22
|
-
|
|
23
|
+
const meta = name.endsWith(".meta.json");
|
|
24
|
+
if (!name.endsWith(".jsonl") && !meta)
|
|
23
25
|
continue;
|
|
24
26
|
try {
|
|
25
27
|
const s = statSync(join(dir, name));
|
|
28
|
+
if (meta) {
|
|
29
|
+
newest = Math.max(newest, s.mtimeMs);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
26
32
|
if (s.size === 0)
|
|
27
33
|
continue;
|
|
28
34
|
count += 1;
|
|
@@ -38,13 +44,15 @@ function printOf(dir) {
|
|
|
38
44
|
export class History {
|
|
39
45
|
#home;
|
|
40
46
|
#live;
|
|
47
|
+
#marksOf;
|
|
41
48
|
#reads = new Map();
|
|
42
49
|
#queue = [];
|
|
43
50
|
#worker = null;
|
|
44
51
|
#inflight = null;
|
|
45
|
-
constructor(home, live) {
|
|
52
|
+
constructor(home, live, marksOf = () => ({})) {
|
|
46
53
|
this.#home = home;
|
|
47
54
|
this.#live = live;
|
|
55
|
+
this.#marksOf = marksOf;
|
|
48
56
|
}
|
|
49
57
|
/** `fresh` names a folder whose listing is read again now, whatever its age. */
|
|
50
58
|
async snapshot(current, fresh) {
|
|
@@ -69,6 +77,9 @@ export class History {
|
|
|
69
77
|
const key = basename(folder.dir);
|
|
70
78
|
for (const meta of read.metas) {
|
|
71
79
|
rows.push({
|
|
80
|
+
// a fork's parent: the store's own record (any app made it), else the host's (forks it made before)
|
|
81
|
+
...(meta.forkedFrom !== undefined ? { parent: meta.forkedFrom } : {}),
|
|
82
|
+
...this.#marksOf(meta.id),
|
|
72
83
|
id: meta.id,
|
|
73
84
|
key,
|
|
74
85
|
folder: basename(folder.workspace),
|
|
@@ -103,18 +114,21 @@ export class History {
|
|
|
103
114
|
this.#inflight = dir;
|
|
104
115
|
const print = printOf(dir);
|
|
105
116
|
const worker = (this.#worker ??= new Worker(new URL("./history-worker.js", import.meta.url)));
|
|
117
|
+
// both listeners go when either answers: one read, one pair (the worker lives across reads, so a kept listener piles up)
|
|
106
118
|
const done = (message) => {
|
|
107
119
|
worker.off("message", done);
|
|
120
|
+
worker.off("error", failed);
|
|
108
121
|
// an unreadable folder is remembered empty: it is tried again when its files change
|
|
109
122
|
this.#reads.set(dir, { print, metas: message.metas ?? [], at: Date.now() });
|
|
110
123
|
this.#inflight = null;
|
|
111
124
|
this.#pump();
|
|
112
125
|
};
|
|
113
|
-
|
|
114
|
-
worker.once("error", (error) => {
|
|
126
|
+
const failed = (error) => {
|
|
115
127
|
this.#worker = null;
|
|
116
128
|
done({ dir, error: error.message });
|
|
117
|
-
}
|
|
129
|
+
};
|
|
130
|
+
worker.on("message", done);
|
|
131
|
+
worker.on("error", failed);
|
|
118
132
|
worker.postMessage({ dir });
|
|
119
133
|
}
|
|
120
134
|
close() {
|
package/dist/main/host.js
CHANGED
|
@@ -10,6 +10,7 @@ import { ApprovalWatch } from "./approvals.js";
|
|
|
10
10
|
import { deskApi } from "./desk-api.js";
|
|
11
11
|
import { History } from "./history.js";
|
|
12
12
|
import { Previews } from "./preview.js";
|
|
13
|
+
import { PrStates, prOfEvents } from "./prs.js";
|
|
13
14
|
import { createSpace } from "./space.js";
|
|
14
15
|
export async function startHost(options) {
|
|
15
16
|
const token = options.token ?? randomBytes(32).toString("hex");
|
|
@@ -28,6 +29,17 @@ export async function startHost(options) {
|
|
|
28
29
|
sanitizer: options.sanitizer ?? "web",
|
|
29
30
|
...(options.keepaliveMs !== undefined ? { keepaliveMs: options.keepaliveMs } : {}),
|
|
30
31
|
...(options.toolLimits !== undefined ? { toolLimits: options.toolLimits } : {}),
|
|
32
|
+
followUps: options.followUps ?? true,
|
|
33
|
+
titles: options.titles ?? true,
|
|
34
|
+
// a run settled (however short; the watch's one-second look can miss it): out of sight, its Work is unread; the PR its own
|
|
35
|
+
// `gh pr create` printed becomes the Work's (an earlier run's never replaces a later link)
|
|
36
|
+
onSettled: (sessionId, runId, store) => {
|
|
37
|
+
if (watch.away(key, sessionId))
|
|
38
|
+
unread(sessionId, true);
|
|
39
|
+
const pr = prOfEvents(store.load(sessionId).filter((r) => r.runId === runId).map((r) => r.event));
|
|
40
|
+
if (pr !== null && options.settings.marksOf(sessionId).pr?.url !== pr.url)
|
|
41
|
+
options.settings.setPr(sessionId, pr);
|
|
42
|
+
},
|
|
31
43
|
});
|
|
32
44
|
// the folder in use: its directory is claimed (named after its path) the way the terminal claims it
|
|
33
45
|
const workspace = canonicalPath(options.workspace);
|
|
@@ -47,11 +59,16 @@ export async function startHost(options) {
|
|
|
47
59
|
spaces.set(key, space);
|
|
48
60
|
return space;
|
|
49
61
|
};
|
|
62
|
+
const prStates = new PrStates();
|
|
50
63
|
const history = new History(home, async (key, sessionId) => {
|
|
51
64
|
const space = spaces.get(key);
|
|
52
65
|
if (space === undefined || !space.service.isRunning(sessionId))
|
|
53
66
|
return "idle";
|
|
54
67
|
return (await space.service.pendingApprovals(sessionId)).length > 0 ? "waiting" : "running";
|
|
68
|
+
}, (id) => {
|
|
69
|
+
const marks = options.settings.marksOf(id);
|
|
70
|
+
const state = marks.pr === undefined ? undefined : prStates.stateOf(marks.pr.url);
|
|
71
|
+
return marks.pr === undefined || state === undefined ? marks : { ...marks, pr: { ...marks.pr, state } };
|
|
55
72
|
});
|
|
56
73
|
const rendererDir = fileURLToPath(new URL("../../out/renderer/", import.meta.url));
|
|
57
74
|
const tokenBytes = Buffer.from(token);
|
|
@@ -59,7 +76,9 @@ export async function startHost(options) {
|
|
|
59
76
|
let presence = null;
|
|
60
77
|
const previews = new Previews(options.previewLog);
|
|
61
78
|
const requestLog = options.requestLog;
|
|
62
|
-
|
|
79
|
+
// written only when it changes: the person in front of a Work clears it every second
|
|
80
|
+
const unread = (sessionId, on) => void ((options.settings.marksOf(sessionId).unread === true) !== on && options.settings.mark(sessionId, "unread", on));
|
|
81
|
+
const watch = new ApprovalWatch(() => spaces.values(), options.notify, () => presence, unread);
|
|
63
82
|
watch.start();
|
|
64
83
|
const desk = { home, settings: options.settings, current, history, spaceFor, setPresence: (p) => (presence = p), notices: (seq) => watch.since(seq), previews };
|
|
65
84
|
let origins = new Set();
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { readSessionName, writeSessionName } from "@vincemakes/kiso-runtime/internal";
|
|
2
|
+
/** A Work's name: the runtime's own session-name store, the one the terminal's rename writes (a file beside the log, never the log).
|
|
3
|
+
* The runtime's titles read it (the listing re-reads a folder whose name file changed); empty clears it; at most 120 characters. */
|
|
4
|
+
export function rename(dir, sessionId, name) {
|
|
5
|
+
const clean = name.replace(/\s+/g, " ").trim().slice(0, 120);
|
|
6
|
+
writeSessionName(dir, sessionId, clean === "" ? null : clean);
|
|
7
|
+
return clean === "" ? null : clean;
|
|
8
|
+
}
|
|
9
|
+
/** Whether a Work has a name: the person's, or a title written before. */
|
|
10
|
+
export const named = (dir, sessionId) => readSessionName(dir, sessionId) !== null;
|
|
11
|
+
/** A title made from the Work's first message, kept only while the Work is still unnamed: the person's name wins. */
|
|
12
|
+
export function titleIfUnnamed(dir, sessionId, title) {
|
|
13
|
+
if (title !== "" && !named(dir, sessionId))
|
|
14
|
+
writeSessionName(dir, sessionId, title);
|
|
15
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { Refusal } from "./files.js";
|
|
3
|
+
/**
|
|
4
|
+
* The system's own folder picker, shown on the machine the host runs on (the host listens on this computer only, so that is the
|
|
5
|
+
* person's screen): macOS `osascript`, Linux `zenity`. The page asks and waits; a path comes back, or null when the person cancels.
|
|
6
|
+
* Elsewhere, or when the program is missing, it answers 501 and the page falls back to its own folder browser. The host judges the
|
|
7
|
+
* path again when it is opened, as for a typed one. KISO_WEB_PICKER names another program to run instead (the proofs): it prints
|
|
8
|
+
* the path and exits 0, or exits 1 for a cancel.
|
|
9
|
+
*/
|
|
10
|
+
function command() {
|
|
11
|
+
const custom = process.env["KISO_WEB_PICKER"];
|
|
12
|
+
if (custom !== undefined && custom !== "")
|
|
13
|
+
return { cmd: custom, args: [] };
|
|
14
|
+
if (process.platform === "darwin")
|
|
15
|
+
return { cmd: "osascript", args: ["-e", "tell me to activate", "-e", 'POSIX path of (choose folder with prompt "Choose a folder")'] };
|
|
16
|
+
if (process.platform === "linux")
|
|
17
|
+
return { cmd: "zenity", args: ["--file-selection", "--directory", "--title=Choose a folder"] };
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
/** One picker at a time: every request would otherwise put another system window on screen. */
|
|
21
|
+
let open = false;
|
|
22
|
+
export function pickFolder() {
|
|
23
|
+
const run = command();
|
|
24
|
+
if (run === null)
|
|
25
|
+
return Promise.reject(new Refusal(501, "this computer has no system folder picker the page can open"));
|
|
26
|
+
if (open)
|
|
27
|
+
return Promise.reject(new Refusal(409, "a folder picker is already open"));
|
|
28
|
+
open = true;
|
|
29
|
+
return new Promise((resolve, reject) => {
|
|
30
|
+
execFile(run.cmd, [...run.args], { timeout: 10 * 60_000, encoding: "utf8" }, (err, stdout, stderr) => {
|
|
31
|
+
open = false;
|
|
32
|
+
if (err === null)
|
|
33
|
+
return resolve(stdout.trim().replace(/(.)\/$/, "$1") || null);
|
|
34
|
+
if (err.code === "ENOENT")
|
|
35
|
+
return reject(new Refusal(501, `${run.cmd} is not installed`));
|
|
36
|
+
if (err.code === 1)
|
|
37
|
+
return /canceled|cancelled/i.test(stderr) || stderr.trim() === "" ? resolve(null) : reject(new Refusal(500, stderr.trim().slice(0, 200)));
|
|
38
|
+
reject(new Refusal(500, err.message.slice(0, 200)));
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
package/dist/main/prs.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { tryRun } from "./git.js";
|
|
2
|
+
const PR_URL = /https:\/\/github\.com\/[\w.-]+\/[\w.-]+\/pull\/(\d+)/;
|
|
3
|
+
/** The last pull request URL in a text (a command's output: what it quoted comes before what it made). */
|
|
4
|
+
export const lastPrOfText = (text) => {
|
|
5
|
+
const all = [...text.matchAll(new RegExp(PR_URL, "g"))];
|
|
6
|
+
const m = all.at(-1);
|
|
7
|
+
return m === undefined ? null : { number: Number(m[1]), url: m[0] };
|
|
8
|
+
};
|
|
9
|
+
export const prOfUrl = (text) => {
|
|
10
|
+
const m = PR_URL.exec(text);
|
|
11
|
+
return m === null ? null : { number: Number(m[1]), url: m[0] };
|
|
12
|
+
};
|
|
13
|
+
/** The last PR a `gh pr create` call of these events printed, if any. */
|
|
14
|
+
export function prOfEvents(events) {
|
|
15
|
+
const calls = new Set(events.filter((e) => e.type === "tool_call_end" && JSON.stringify(e.input ?? "").includes("gh pr create")).map((e) => e.callId));
|
|
16
|
+
let found = null;
|
|
17
|
+
for (const e of events)
|
|
18
|
+
if (e.type === "tool_result" && calls.has(e.callId))
|
|
19
|
+
found = prOfUrl(typeof e.content === "string" ? e.content : JSON.stringify(e.content ?? "")) ?? found;
|
|
20
|
+
return found;
|
|
21
|
+
}
|
|
22
|
+
const FRESH_MS = 60_000;
|
|
23
|
+
export class PrStates {
|
|
24
|
+
#known = new Map();
|
|
25
|
+
/** The last state known (none yet: asked now, in the background). */
|
|
26
|
+
stateOf(url) {
|
|
27
|
+
const held = this.#known.get(url);
|
|
28
|
+
if (held === undefined || Date.now() - held.at > FRESH_MS) {
|
|
29
|
+
this.#known.set(url, { ...(held?.state !== undefined ? { state: held.state } : {}), at: Date.now() });
|
|
30
|
+
void tryRun(process.cwd(), "gh", ["pr", "view", url, "--json", "state", "--jq", ".state"], 8000).then((s) => {
|
|
31
|
+
if (s === "OPEN" || s === "MERGED" || s === "CLOSED")
|
|
32
|
+
this.#known.set(url, { state: s, at: Date.now() });
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
return held?.state;
|
|
36
|
+
}
|
|
37
|
+
}
|
package/dist/main/settings.js
CHANGED
|
@@ -2,9 +2,14 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { applyEffortDeclarations, contextWindowOf, demoScript, authForProfile, effortOptions, loadUserConfig, mergeConfigs, preferences, profileAvailable, rememberEffort, startingEffort, } from "@vincemakes/kiso-code/config";
|
|
4
4
|
import { MODE_LABEL, OFFERED_MODES } from "@vincemakes/kiso-code/policy";
|
|
5
|
+
import { lookupInputModalities } from "@vincemakes/kiso-runtime/internal";
|
|
5
6
|
import { fauxSkip } from "./faux.js";
|
|
6
7
|
import { storeKey } from "./login.js";
|
|
7
8
|
const MAX_RECENT = 10;
|
|
9
|
+
const SAFE_SESSION = /^[A-Za-z0-9._-]+$/;
|
|
10
|
+
/** What the model table (its route's row, else the release snapshot of the model's weights) says of reading pictures: true, false (its input modalities are listed without "image": evidence), or null (unknown). */
|
|
11
|
+
export const imagesOf = (modalities) => (modalities === null || modalities === undefined ? null : modalities.includes("image"));
|
|
12
|
+
const readsImages = (p) => imagesOf(lookupInputModalities(p.model, p.baseUrl));
|
|
8
13
|
const isTier = (value) => OFFERED_MODES.some((m) => m === value);
|
|
9
14
|
export class Settings {
|
|
10
15
|
#file;
|
|
@@ -63,9 +68,12 @@ export class Settings {
|
|
|
63
68
|
applyEffortDeclarations(merged);
|
|
64
69
|
return merged;
|
|
65
70
|
}
|
|
66
|
-
/** The profile in use: the person's pick if it still exists, else the configuration's own default. */
|
|
67
|
-
#chosen(config) {
|
|
71
|
+
/** The profile in use: the Work's own pick, else the person's pick for new Works, if it still exists, else the configuration's own default. */
|
|
72
|
+
#chosen(config, session) {
|
|
68
73
|
const models = config.models ?? {};
|
|
74
|
+
const own = session === undefined ? undefined : this.#read().works?.[session]?.profile;
|
|
75
|
+
if (own !== undefined && models[own] !== undefined)
|
|
76
|
+
return own;
|
|
69
77
|
const picked = this.#read().profile;
|
|
70
78
|
if (picked !== undefined && models[picked] !== undefined)
|
|
71
79
|
return picked;
|
|
@@ -78,12 +86,12 @@ export class Settings {
|
|
|
78
86
|
if (this.#demo !== null)
|
|
79
87
|
return this.#demo(ctx === undefined ? 0 : fauxSkip(ctx.sessionsDir, ctx.sessionId));
|
|
80
88
|
const config = this.#config();
|
|
81
|
-
const name = this.#chosen(config);
|
|
89
|
+
const name = this.#chosen(config, ctx?.sessionId);
|
|
82
90
|
if (name === null)
|
|
83
91
|
throw new Error("no model is configured: add a profile to ~/.kiso/config.json (see the terminal's docs) and choose it in Settings");
|
|
84
92
|
const profile = config.models[name];
|
|
85
93
|
const auth = authForProfile(name, profile);
|
|
86
|
-
const effort = startingEffort(profile,
|
|
94
|
+
const effort = startingEffort(profile, this.#effortOf(name, ctx?.sessionId));
|
|
87
95
|
return {
|
|
88
96
|
kind: "profile",
|
|
89
97
|
name,
|
|
@@ -93,6 +101,57 @@ export class Settings {
|
|
|
93
101
|
...(effort !== null ? { reasoning: { thinking: "default", effort } } : {}),
|
|
94
102
|
};
|
|
95
103
|
}
|
|
104
|
+
/** The effort a Work asked for (when it is one of the profile's levels), else the terminal's remembered one for the profile. */
|
|
105
|
+
#effortOf(name, session) {
|
|
106
|
+
const own = session === undefined ? undefined : this.#read().works?.[session]?.effort;
|
|
107
|
+
return own ?? preferences().effort?.[name];
|
|
108
|
+
}
|
|
109
|
+
/** A Work's own model choice (to put back when its rebuild is refused). */
|
|
110
|
+
workOf(session) {
|
|
111
|
+
return this.#read().works?.[session];
|
|
112
|
+
}
|
|
113
|
+
/** A fork starts with its parent's own tier, model and effort, and remembers where it came from. */
|
|
114
|
+
forked(parent, child, fork) {
|
|
115
|
+
const own = this.#read();
|
|
116
|
+
const tier = own.tiers?.[parent];
|
|
117
|
+
const work = own.works?.[parent];
|
|
118
|
+
this.write({
|
|
119
|
+
...(tier !== undefined ? { tiers: { ...own.tiers, [child]: tier } } : {}),
|
|
120
|
+
...(work !== undefined ? { works: { ...own.works, [child]: work } } : {}),
|
|
121
|
+
forks: { ...own.forks, [child]: fork },
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
forkOf(session) {
|
|
125
|
+
return this.#read().forks?.[session];
|
|
126
|
+
}
|
|
127
|
+
/** Pin, archive or mark unread a Work (a pin goes first), or take it off. */
|
|
128
|
+
mark(session, list, on) {
|
|
129
|
+
const rest = (this.#read()[list] ?? []).filter((id) => id !== session);
|
|
130
|
+
this.write({ [list]: on ? [session, ...rest] : rest });
|
|
131
|
+
}
|
|
132
|
+
/** A Work's pull request, or none. */
|
|
133
|
+
setPr(session, pr) {
|
|
134
|
+
const { [session]: _, ...rest } = this.#read().prs ?? {};
|
|
135
|
+
this.write({ prs: pr === null ? rest : { ...rest, [session]: pr } });
|
|
136
|
+
}
|
|
137
|
+
/** What the sidebar shows of a Work beyond its log: the fork's parent, its pin's place, whether it is archived or unread, its PR. */
|
|
138
|
+
marksOf(session) {
|
|
139
|
+
const own = this.#read();
|
|
140
|
+
const parent = own.forks?.[session]?.parent;
|
|
141
|
+
const pinned = own.pins?.indexOf(session) ?? -1;
|
|
142
|
+
const pr = own.prs?.[session];
|
|
143
|
+
return { ...(parent !== undefined ? { parent } : {}), ...(pinned >= 0 ? { pinned } : {}), ...(own.archived?.includes(session) ? { archived: true } : {}), ...(own.unread?.includes(session) ? { unread: true } : {}), ...(pr !== undefined ? { pr } : {}) };
|
|
144
|
+
}
|
|
145
|
+
/** A deleted Work leaves nothing here; a fork's record of it stays (its notice still names the parent). */
|
|
146
|
+
forget(session) {
|
|
147
|
+
const own = this.#read();
|
|
148
|
+
const drop = (m) => Object.fromEntries(Object.entries(m ?? {}).filter(([k]) => k !== session));
|
|
149
|
+
this.write({ tiers: drop(own.tiers), works: drop(own.works), forks: drop(own.forks), pins: (own.pins ?? []).filter((id) => id !== session), archived: (own.archived ?? []).filter((id) => id !== session), unread: (own.unread ?? []).filter((id) => id !== session), prs: drop(own.prs) });
|
|
150
|
+
}
|
|
151
|
+
restoreWork(session, work) {
|
|
152
|
+
const { [session]: _, ...rest } = this.#read().works ?? {};
|
|
153
|
+
this.write({ works: work === undefined ? rest : { ...rest, [session]: work } });
|
|
154
|
+
}
|
|
96
155
|
/** The person's own test-runner commands (`checks` in the configuration), for the compaction phase rule. */
|
|
97
156
|
checks() {
|
|
98
157
|
return Object.values(this.#config().checks ?? {});
|
|
@@ -122,21 +181,35 @@ export class Settings {
|
|
|
122
181
|
snapshot(session) {
|
|
123
182
|
const config = this.#config();
|
|
124
183
|
const models = config.models ?? {};
|
|
125
|
-
const name = this.#chosen(config);
|
|
184
|
+
const name = this.#chosen(config, session);
|
|
126
185
|
const profile = name === null ? undefined : models[name];
|
|
127
186
|
const levels = profile === undefined ? null : effortOptions(profile);
|
|
128
187
|
return {
|
|
129
|
-
profiles: Object.entries(models).map(([n, p]) => ({ name: n, model: p.model, available: profileAvailable(p) })),
|
|
188
|
+
profiles: Object.entries(models).map(([n, p]) => ({ name: n, model: p.model, available: profileAvailable(p), images: readsImages(p) })),
|
|
130
189
|
profile: name,
|
|
131
|
-
effort: levels === null || name === null ? null : { ...levels, current: startingEffort(profile,
|
|
190
|
+
effort: levels === null || name === null ? null : { ...levels, current: startingEffort(profile, this.#effortOf(name, session)) },
|
|
132
191
|
mode: this.tierOf(session),
|
|
133
192
|
setup: this.#setup(config, name),
|
|
134
193
|
modes: OFFERED_MODES.map((value) => ({ value, label: MODE_LABEL[value] })),
|
|
135
194
|
signIn: name === null ? { ok: false, text: "No model is configured." } : signInOf(name, profile),
|
|
136
195
|
};
|
|
137
196
|
}
|
|
197
|
+
/** Apply a patch; returns the Work whose model binding it changed (the host rebuilds that Work), else null. */
|
|
138
198
|
apply(patch) {
|
|
139
199
|
const config = this.#config();
|
|
200
|
+
const session = patch.session !== undefined && SAFE_SESSION.test(patch.session) ? patch.session : undefined;
|
|
201
|
+
if (session !== undefined && (patch.profile !== undefined || patch.effort !== undefined)) {
|
|
202
|
+
// one Work's model: its own profile and effort, the new-Work default untouched
|
|
203
|
+
const was = this.workOf(session) ?? {};
|
|
204
|
+
const profileName = patch.profile !== undefined && config.models?.[patch.profile] !== undefined ? patch.profile : (was.profile ?? this.#chosen(config, session) ?? undefined);
|
|
205
|
+
const profile = profileName === undefined ? undefined : config.models?.[profileName];
|
|
206
|
+
const effort = patch.effort !== undefined && profile !== undefined && effortOptions(profile)?.levels.includes(patch.effort) === true ? patch.effort : patch.profile !== undefined ? undefined : was.effort;
|
|
207
|
+
const next = { ...(profileName !== undefined ? { profile: profileName } : {}), ...(effort !== undefined ? { effort } : {}) };
|
|
208
|
+
if (next.profile === was.profile && next.effort === was.effort)
|
|
209
|
+
return null;
|
|
210
|
+
this.write({ works: { ...this.#read().works, [session]: next } });
|
|
211
|
+
return session;
|
|
212
|
+
}
|
|
140
213
|
if (patch.profile !== undefined && config.models?.[patch.profile] !== undefined) {
|
|
141
214
|
this.write({ profile: patch.profile });
|
|
142
215
|
// choosing a profile that can run ends the tour
|
|
@@ -152,9 +225,10 @@ export class Settings {
|
|
|
152
225
|
this.#default = patch.mode;
|
|
153
226
|
this.write({ mode: patch.mode });
|
|
154
227
|
}
|
|
155
|
-
else if (
|
|
228
|
+
else if (SAFE_SESSION.test(patch.session))
|
|
156
229
|
this.write({ tiers: { ...this.#read().tiers, [patch.session]: patch.mode } });
|
|
157
230
|
}
|
|
231
|
+
return null;
|
|
158
232
|
}
|
|
159
233
|
}
|
|
160
234
|
/** Where a profile's sign-in comes from, in words; the secret is never part of it. */
|