@vincemakes/kiso-web 0.53.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 +6 -3
- package/dist/main/approvals.js +15 -3
- package/dist/main/artifacts.js +3 -0
- package/dist/main/desk-api.js +59 -4
- package/dist/main/environment.js +102 -0
- package/dist/main/folder-info.js +18 -1
- package/dist/main/fs-routes.js +22 -5
- package/dist/main/git.js +1 -1
- package/dist/main/history.js +0 -13
- package/dist/main/host.js +19 -2
- package/dist/main/pick-folder.js +41 -0
- package/dist/main/prs.js +37 -0
- package/dist/main/settings.js +14 -7
- package/dist/main/space.js +66 -15
- package/dist/main/times.js +19 -0
- package/dist/main/usage.js +15 -4
- package/dist/main/work-folders.js +28 -0
- package/dist/main/works.js +1 -1
- package/out/renderer/assets/index-CSaqocLC.css +1 -0
- package/out/renderer/assets/index-Dt9YIp7q.js +46 -0
- package/out/renderer/icon.png +0 -0
- package/out/renderer/index.html +2 -2
- package/package.json +10 -10
- package/out/renderer/assets/index-C3fCoBdq.css +0 -1
- package/out/renderer/assets/index-DiDTmq9b.js +0 -45
package/dist/main/agent.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { homedir } from "node:os";
|
|
2
2
|
import { join } from "node:path";
|
|
3
|
-
import { Agent, createAgent, kisoHome, runsACheck } from "@vincemakes/kiso-runtime";
|
|
3
|
+
import { Agent, createAgent, kisoHome, readEnvironment, runsACheck } from "@vincemakes/kiso-runtime";
|
|
4
4
|
import { adapterOptionsFor } from "@vincemakes/kiso-code/config";
|
|
5
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";
|
|
6
|
+
import { CODING_TOOL_RULES, breakerExtension, composeSystemPrompt, fileStopRoots, floorExtension, modeExtensions, modeSystemPrompt, protectedShellExtension, readOnlyShellExtension, setMode, } from "@vincemakes/kiso-code/policy";
|
|
7
7
|
import { observedRevisionFrom } from "./observed-revision.js";
|
|
8
8
|
/** The adapter identity a model is routed by (what pricing is looked up on). */
|
|
9
9
|
export const routeOf = (model) => (model.kind === "adapter" ? model.route : model.profile.kind);
|
|
@@ -39,6 +39,9 @@ export function createWebAgent(options) {
|
|
|
39
39
|
excludeRoots: [kisoHome()],
|
|
40
40
|
secretEnvNames: options.secretEnvNames ?? [],
|
|
41
41
|
protectedFiles: protectedFiles(),
|
|
42
|
+
// ADR-0058 Amendment 19: read on every call, so an add, a removal or a mode change applies at the next one
|
|
43
|
+
access: (sessionId) => ({ added: readEnvironment(store.root, sessionId ?? options.sessionId).added, outside: options.tier() === "full-access" ? "allow" : "deny" }),
|
|
44
|
+
stopRoots: fileStopRoots(homedir(), kisoHome()),
|
|
42
45
|
tasks: options.tasks.shellTasks,
|
|
43
46
|
observedRevision: observedRevisionFrom(store, workspace),
|
|
44
47
|
...(options.toolLimits !== undefined ? { limits: options.toolLimits } : {}),
|
|
@@ -54,7 +57,7 @@ export function createWebAgent(options) {
|
|
|
54
57
|
// the terminal's chain, in its order: the guards that deny speak first
|
|
55
58
|
extensions: [
|
|
56
59
|
protectedShellExtension({ files: protectedFiles, workspaceRoot: root, env: () => ({ home: homedir(), kisoHome: kisoHome() }) }),
|
|
57
|
-
floorExtension(() => true, root),
|
|
60
|
+
floorExtension(() => true, root, undefined, () => readEnvironment(store.root, options.sessionId).added),
|
|
58
61
|
breakerExtension(),
|
|
59
62
|
...modeExtensions(root, options.tier),
|
|
60
63
|
readOnlyShellExtension(() => ({ workspaceRoot: workspace, excludeRoots: toolOptions.excludeRoots })),
|
package/dist/main/approvals.js
CHANGED
|
@@ -4,6 +4,7 @@ export class ApprovalWatch {
|
|
|
4
4
|
#spaces;
|
|
5
5
|
#notify;
|
|
6
6
|
#presence;
|
|
7
|
+
#unread;
|
|
7
8
|
/** decisions already announced, per `key/session`; forgotten once answered */
|
|
8
9
|
#told = new Map();
|
|
9
10
|
/** Works seen running at the last look */
|
|
@@ -11,10 +12,11 @@ export class ApprovalWatch {
|
|
|
11
12
|
#notices = [];
|
|
12
13
|
#seq = 0;
|
|
13
14
|
#timer;
|
|
14
|
-
constructor(spaces, notify, presence) {
|
|
15
|
+
constructor(spaces, notify, presence, unread = () => undefined) {
|
|
15
16
|
this.#spaces = spaces;
|
|
16
17
|
this.#notify = notify;
|
|
17
18
|
this.#presence = presence;
|
|
19
|
+
this.#unread = unread;
|
|
18
20
|
}
|
|
19
21
|
start(everyMs = 1_000) {
|
|
20
22
|
this.#timer = setInterval(() => void this.poll(), everyMs);
|
|
@@ -34,9 +36,19 @@ export class ApprovalWatch {
|
|
|
34
36
|
this.#notices.shift();
|
|
35
37
|
this.#notify?.(full);
|
|
36
38
|
}
|
|
37
|
-
|
|
39
|
+
#here() {
|
|
38
40
|
const p = this.#presence();
|
|
39
|
-
|
|
41
|
+
return p !== null && p.focused && Date.now() - p.at < FRESH_MS ? p : null;
|
|
42
|
+
}
|
|
43
|
+
/** Whether the person is looking at something other than this Work. */
|
|
44
|
+
away(key, sessionId) {
|
|
45
|
+
const here = this.#here();
|
|
46
|
+
return here === null || here.key !== key || here.session !== sessionId;
|
|
47
|
+
}
|
|
48
|
+
async poll() {
|
|
49
|
+
const here = this.#here();
|
|
50
|
+
if (here !== null)
|
|
51
|
+
this.#unread(here.session, false);
|
|
40
52
|
for (const space of this.#spaces()) {
|
|
41
53
|
for (const sessionId of space.opened()) {
|
|
42
54
|
const id = `${space.key}/${sessionId}`;
|
package/dist/main/artifacts.js
CHANGED
|
@@ -73,6 +73,9 @@ export function artifactsOf(event, workId, call, root, dir) {
|
|
|
73
73
|
return outside(raw, since);
|
|
74
74
|
if (kind === undefined || rel.startsWith("..") || out.some((a) => a.uri === `file:${rel}`))
|
|
75
75
|
return;
|
|
76
|
+
// a file that is not there is the common case in a replay: no refusal is built for it
|
|
77
|
+
if (!existsSync(resolve(root, rel)))
|
|
78
|
+
return;
|
|
76
79
|
try {
|
|
77
80
|
const stat = statSync(resolveInside(root, rel));
|
|
78
81
|
if (stat.isFile() && (since === null || stat.mtimeMs >= since - SLACK_MS))
|
package/dist/main/desk-api.js
CHANGED
|
@@ -6,11 +6,15 @@ import { contextParts } from "./context.js";
|
|
|
6
6
|
import { rename } from "./names.js";
|
|
7
7
|
import { forkWork } from "./fork.js";
|
|
8
8
|
import { WORK_ACTS, workAct } from "./works.js";
|
|
9
|
-
import { commit, gitOf, openPr, prDraft } from "./git.js";
|
|
9
|
+
import { commit, gitOf, openPr, prDraft, tryRun } from "./git.js";
|
|
10
|
+
import { prOfUrl } from "./prs.js";
|
|
10
11
|
import { existing, Refusal, resolveInside } from "./files.js";
|
|
11
12
|
import { artifactKind } from "./artifacts.js";
|
|
12
13
|
import { listDirs, validateFolder } from "./folders.js";
|
|
14
|
+
import { pickFolder } from "./pick-folder.js";
|
|
13
15
|
import { folderInfo } from "./folder-info.js";
|
|
16
|
+
import { changeFolders, foldersOf, stopRoots } from "./work-folders.js";
|
|
17
|
+
import { environmentRows, openRow } from "./environment.js";
|
|
14
18
|
const json = (status, body) => ({ status, body });
|
|
15
19
|
const BODY_CAP = 16 * 1024;
|
|
16
20
|
async function readJson(req) {
|
|
@@ -40,7 +44,8 @@ export async function deskApi(ctx, req, url) {
|
|
|
40
44
|
if (path === "/desk/at-files") {
|
|
41
45
|
if (space === null)
|
|
42
46
|
return json(404, {});
|
|
43
|
-
|
|
47
|
+
const session = url.searchParams.get("session") ?? "";
|
|
48
|
+
return req.method === "GET" ? json(200, atFiles(space, SAFE_ID.test(session) && space.store.ids().includes(session) ? foldersOf(space, session).added.filter((a) => !a.missing).map((a) => a.path) : [], stopRoots())) : json(405, {});
|
|
44
49
|
}
|
|
45
50
|
if (path === "/desk/context") {
|
|
46
51
|
if (space === null)
|
|
@@ -69,7 +74,42 @@ export async function deskApi(ctx, req, url) {
|
|
|
69
74
|
const body = await readJson(req);
|
|
70
75
|
if (path === "/desk/commit")
|
|
71
76
|
return json(200, await commit(space.workspace, str(body["message"]) ?? "", Array.isArray(body["paths"]) ? body["paths"].filter((p) => typeof p === "string") : []));
|
|
72
|
-
|
|
77
|
+
const pr = await openPr(space.workspace, str(body["title"]) ?? "", str(body["body"]) ?? "");
|
|
78
|
+
const session = str(body["session"]) ?? "";
|
|
79
|
+
if (SAFE_ID.test(session) && space.store.ids().includes(session))
|
|
80
|
+
ctx.settings.setPr(session, pr);
|
|
81
|
+
return json(200, pr);
|
|
82
|
+
}
|
|
83
|
+
if (path === "/desk/work-pr") {
|
|
84
|
+
// link a Work to a pull request by its URL or its number in the folder's repository, or (pr: null) unlink it
|
|
85
|
+
if (!post)
|
|
86
|
+
return json(405, {});
|
|
87
|
+
if (space === null)
|
|
88
|
+
return json(404, {});
|
|
89
|
+
const body = await readJson(req);
|
|
90
|
+
const session = str(body["session"]) ?? "";
|
|
91
|
+
if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
|
|
92
|
+
return json(404, {});
|
|
93
|
+
if (body["pr"] === null)
|
|
94
|
+
return (ctx.settings.setPr(session, null), json(200, { pr: null }));
|
|
95
|
+
const asked = (str(body["pr"]) ?? "").trim().replace(/^#/, "");
|
|
96
|
+
const pr = prOfUrl(asked) ?? (/^\d+$/.test(asked) ? prOfUrl((await tryRun(space.workspace, "gh", ["pr", "view", asked, "--json", "url", "--jq", ".url"], 8000)) ?? "") : null);
|
|
97
|
+
if (pr === null)
|
|
98
|
+
throw new Refusal(400, "give a pull request's GitHub URL, or its number in this folder's repository");
|
|
99
|
+
ctx.settings.setPr(session, pr);
|
|
100
|
+
return json(200, { pr });
|
|
101
|
+
}
|
|
102
|
+
if (path === "/desk/work-folders") {
|
|
103
|
+
// a Work's added folders: asked, or changed by path; refused in words (ADR-0058 Amendment 19)
|
|
104
|
+
if (space === null)
|
|
105
|
+
return json(404, {});
|
|
106
|
+
const body = post ? await readJson(req) : {};
|
|
107
|
+
const session = post ? (str(body["session"]) ?? "") : (url.searchParams.get("session") ?? "");
|
|
108
|
+
if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
|
|
109
|
+
return json(404, {});
|
|
110
|
+
if (post)
|
|
111
|
+
await changeFolders(space, session, str(body["add"]), str(body["remove"]));
|
|
112
|
+
return json(200, foldersOf(space, session));
|
|
73
113
|
}
|
|
74
114
|
if (path === "/desk/rename") {
|
|
75
115
|
if (!post)
|
|
@@ -172,7 +212,18 @@ export async function deskApi(ctx, req, url) {
|
|
|
172
212
|
return json(405, {});
|
|
173
213
|
const session = url.searchParams.get("session") ?? undefined;
|
|
174
214
|
const fork = session === undefined ? undefined : ctx.settings.forkOf(session);
|
|
175
|
-
|
|
215
|
+
const rows = session !== undefined && SAFE_ID.test(session) && space.store.ids().includes(session) ? { rows: environmentRows(space, session, ctx.settings.marksOf(session).pr) } : {};
|
|
216
|
+
return json(200, { ...folderInfo(space.workspace, ctx.settings.tierOf(session)), ...rows, ...(fork === undefined ? {} : { fork: { parent: fork.parent, title: fork.title, at: fork.at } }) });
|
|
217
|
+
}
|
|
218
|
+
if (path === "/desk/env-open" && post) {
|
|
219
|
+
if (space === null)
|
|
220
|
+
return json(404, {});
|
|
221
|
+
const body = await readJson(req);
|
|
222
|
+
const session = str(body["session"]) ?? "";
|
|
223
|
+
if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
|
|
224
|
+
return json(404, {});
|
|
225
|
+
openRow(space, session, ctx.settings.marksOf(session).pr, Number(body["index"]), str(body["door"]) ?? "", str(body["path"]) ?? "");
|
|
226
|
+
return json(200, {});
|
|
176
227
|
}
|
|
177
228
|
if (path === "/desk/history")
|
|
178
229
|
return req.method === "GET" ? json(200, await ctx.history.snapshot({ key: ctx.current.key, dir: ctx.current.dir }, url.searchParams.get("fresh") ?? undefined)) : json(405, {});
|
|
@@ -208,6 +259,10 @@ export async function deskApi(ctx, req, url) {
|
|
|
208
259
|
const opened = ctx.spaceFor(basename(dir));
|
|
209
260
|
return opened === null ? json(500, {}) : json(200, { key: opened.key, folder: basename(workspace) });
|
|
210
261
|
}
|
|
262
|
+
if (path === "/desk/pick-folder" && post) {
|
|
263
|
+
const picked = await pickFolder();
|
|
264
|
+
return json(200, picked === null ? { cancelled: true } : { path: picked });
|
|
265
|
+
}
|
|
211
266
|
if (path === "/desk/recent" && req.method === "GET") {
|
|
212
267
|
const rows = ctx.settings.recents().flatMap((p) => {
|
|
213
268
|
try {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { basename, dirname } from "node:path";
|
|
5
|
+
import { cdTargets, isGhPrCreate } from "@vincemakes/kiso-code/policy";
|
|
6
|
+
import { Refusal } from "./files.js";
|
|
7
|
+
import { branchOf, githubOf } from "./folder-info.js";
|
|
8
|
+
import { tryRun } from "./git.js";
|
|
9
|
+
import { lastPrOfText } from "./prs.js";
|
|
10
|
+
import { foldersOf } from "./work-folders.js";
|
|
11
|
+
/**
|
|
12
|
+
* A Work's Environment rows. Recent folders and Repositories are DERIVED from the log and granted nothing (plan rev 4.1, 4 and 5):
|
|
13
|
+
* a folder this Work's shell moved into (a literal `cd` in a call that did not fail), a repository it has a pull request in (a parsed
|
|
14
|
+
* `gh pr create` whose result is not an error, else the Work's linked one). Cached by the log's last seq; nothing is stored.
|
|
15
|
+
*/
|
|
16
|
+
const RECENT_MAX = 8;
|
|
17
|
+
const cache = new Map();
|
|
18
|
+
/** A pull request's branch from `gh`, asked once off the request (retried after a minute when it could not be read); the row shows it once known. */
|
|
19
|
+
const branches = new Map();
|
|
20
|
+
function branchOfPr(url) {
|
|
21
|
+
const held = branches.get(url);
|
|
22
|
+
if (held === undefined || (held.branch === undefined && Date.now() - held.at > 60_000)) {
|
|
23
|
+
branches.set(url, { at: Date.now() });
|
|
24
|
+
void tryRun(process.cwd(), "gh", ["pr", "view", url, "--json", "headRefName", "--jq", ".headRefName"], 8000).then((b) => b !== null && b !== "" && branches.set(url, { branch: b, at: Date.now() }));
|
|
25
|
+
}
|
|
26
|
+
return held?.branch;
|
|
27
|
+
}
|
|
28
|
+
const isDir = (p) => {
|
|
29
|
+
try {
|
|
30
|
+
return statSync(p).isDirectory();
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
return false;
|
|
34
|
+
}
|
|
35
|
+
};
|
|
36
|
+
const repoOf = (pr) => ({ repo: /github\.com\/([\w.-]+\/[\w.-]+)\//.exec(pr.url)[1], number: pr.number, url: pr.url });
|
|
37
|
+
/** What a log says: the folders a shell moved to and the repositories a `gh pr create` printed, newest first. A call whose arguments did not parse (`input` null) reads as no command. */
|
|
38
|
+
export function scanLog(events, base, home, dir = isDir) {
|
|
39
|
+
const calls = new Map();
|
|
40
|
+
const recent = [];
|
|
41
|
+
const repos = [];
|
|
42
|
+
for (const e of events) {
|
|
43
|
+
const input = e.type === "tool_call_end" ? e.input : null;
|
|
44
|
+
if (e.type === "tool_call_end" && e.name === "shell" && typeof input === "object" && input !== null && typeof input.command === "string")
|
|
45
|
+
calls.set(e.callId, input.command);
|
|
46
|
+
if (e.type !== "tool_result" || e.isError || !calls.has(e.callId))
|
|
47
|
+
continue;
|
|
48
|
+
const line = calls.get(e.callId);
|
|
49
|
+
for (const d of cdTargets(line, base, home))
|
|
50
|
+
if (dir(d))
|
|
51
|
+
recent.unshift(d);
|
|
52
|
+
const text = typeof e.content === "string" ? e.content : e.content.map((b) => (b.type === "text" ? b.text : "")).join("\n");
|
|
53
|
+
const pr = isGhPrCreate(line) ? lastPrOfText(text) : null; // the last URL printed: the one the command made comes after any it quoted
|
|
54
|
+
if (pr !== null)
|
|
55
|
+
repos.unshift(repoOf(pr));
|
|
56
|
+
}
|
|
57
|
+
return { recent, repos };
|
|
58
|
+
}
|
|
59
|
+
function derive(space, session, linked) {
|
|
60
|
+
const records = space.store.load(session);
|
|
61
|
+
const seq = records.at(-1)?.event.seq ?? -1;
|
|
62
|
+
const slot = `${space.key}\0${session}`; // two folders can hold a Work of the same id and the same last seq
|
|
63
|
+
const held = cache.get(slot);
|
|
64
|
+
// both sources, one row per repository: the Work's own link (the person's latest word) first, then what the log printed
|
|
65
|
+
const both = (log) => [...(linked === undefined ? [] : [repoOf(linked)]), ...log].filter((r, i, all) => all.findIndex((x) => x.repo === r.repo) === i);
|
|
66
|
+
if (held !== undefined && held.seq === seq)
|
|
67
|
+
return { recent: held.recent, repos: both(held.repos) };
|
|
68
|
+
const { recent, repos } = scanLog(records.map((r) => r.event), space.workspace, homedir());
|
|
69
|
+
const kept = [...new Set(recent)].filter((d, _, all) => !all.some((o) => o !== d && d.startsWith(`${o}/`))).slice(0, RECENT_MAX);
|
|
70
|
+
const out = { seq, recent: kept, repos: repos.filter((r, i) => repos.findIndex((x) => x.repo === r.repo) === i) };
|
|
71
|
+
cache.set(slot, out);
|
|
72
|
+
return { recent: out.recent, repos: both(out.repos) };
|
|
73
|
+
}
|
|
74
|
+
const shown = (p) => (p.startsWith(`${homedir()}/`) ? `~${p.slice(homedir().length)}` : p);
|
|
75
|
+
/** The rows, in the card's order: the primary, the added folders, then what only records. */
|
|
76
|
+
export function environmentRows(space, session, linked) {
|
|
77
|
+
const { added, damaged } = foldersOf(space, session);
|
|
78
|
+
const { recent, repos } = derive(space, session, linked);
|
|
79
|
+
const row = (kind, path) => ({ kind, path, shown: shown(path), name: basename(path) || path, parent: shown(dirname(path)), branch: branchOf(path), github: githubOf(path), missing: !existsSync(path) });
|
|
80
|
+
const roots = [space.workspace, ...added.map((a) => a.path)];
|
|
81
|
+
const inside = (d, root) => d === root || d.startsWith(`${root}/`);
|
|
82
|
+
// a recent folder in an added folder is not shown; one in the primary, or around any root, is shown but can never be added (nested)
|
|
83
|
+
const folders = [row("primary", space.workspace), ...added.map((a) => row("added", a.path)), ...recent.filter((d) => !added.some((a) => inside(d, a.path)) && d !== space.workspace).map((d) => ({ ...row("recent", d), addable: !roots.some((r) => inside(d, r) || inside(r, d)) }))];
|
|
84
|
+
const withBranch = repos.map((r) => ({ ...r, ...(branchOfPr(r.url) === undefined ? {} : { branch: branchOfPr(r.url) }) }));
|
|
85
|
+
return { folders, repos: withBranch, doors: process.platform === "darwin" || process.env["KISO_WEB_OPENER"] !== undefined ? ["reveal", "terminal"] : [], damaged };
|
|
86
|
+
}
|
|
87
|
+
/** Show in Finder / Open in terminal: by Work and row index, the path recomputed here, never taken from the page. */
|
|
88
|
+
export function openRow(space, session, linked, index, door, path) {
|
|
89
|
+
const folder = environmentRows(space, session, linked).folders[index];
|
|
90
|
+
if (folder === undefined || !existsSync(folder.path))
|
|
91
|
+
throw new Refusal(404, "that folder is not there any more");
|
|
92
|
+
// the page sends the row's path only to be compared: the rows moved since it drew them when they differ
|
|
93
|
+
if (folder.path !== path)
|
|
94
|
+
throw new Refusal(409, "the folders changed; try again");
|
|
95
|
+
if ((process.platform !== "darwin" && process.env["KISO_WEB_OPENER"] === undefined) || (door !== "reveal" && door !== "terminal"))
|
|
96
|
+
throw new Refusal(501, "this host cannot open it");
|
|
97
|
+
const custom = process.env["KISO_WEB_OPENER"]; // the proofs: a program run as `opener <door> <path>` instead of the system's
|
|
98
|
+
if (custom !== undefined && custom !== "")
|
|
99
|
+
execFile(custom, [door, folder.path], () => undefined);
|
|
100
|
+
else
|
|
101
|
+
execFile("open", door === "reveal" ? ["-R", folder.path] : ["-a", "Terminal", folder.path], () => undefined);
|
|
102
|
+
}
|
package/dist/main/folder-info.js
CHANGED
|
@@ -16,7 +16,7 @@ function gitDir(from) {
|
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
/** The branch a folder is on (a short commit when detached); null outside a git folder. */
|
|
19
|
-
function branchOf(workspace) {
|
|
19
|
+
export function branchOf(workspace) {
|
|
20
20
|
try {
|
|
21
21
|
const git = gitDir(workspace);
|
|
22
22
|
if (git === null)
|
|
@@ -34,3 +34,20 @@ export function folderInfo(workspace, mode) {
|
|
|
34
34
|
const home = homedir();
|
|
35
35
|
return { folder: basename(workspace), path: workspace.startsWith(`${home}/`) ? `~${workspace.slice(home.length)}` : workspace, root: workspace, branch: branchOf(workspace), mode };
|
|
36
36
|
}
|
|
37
|
+
/** `owner/repo` of a folder's GitHub remote: `origin`'s, else any other remote that is on GitHub. */
|
|
38
|
+
export function githubOf(folder) {
|
|
39
|
+
try {
|
|
40
|
+
const git = gitDir(folder);
|
|
41
|
+
const config = git === null ? "" : readFileSync(join(git.replace(/\/worktrees\/[^/]+$/, ""), "config"), "utf8");
|
|
42
|
+
const urls = [...config.matchAll(/\[remote "([^"]+)"\][^\[]*?url\s*=\s*(\S+)/g)].map((m) => [m[1], m[2]]);
|
|
43
|
+
for (const [, url] of [...urls.filter(([n]) => n === "origin"), ...urls.filter(([n]) => n !== "origin")]) {
|
|
44
|
+
const m = /^(?:https:\/\/|git@|ssh:\/\/git@)github\.com[:/]([\w.-]+\/[\w.-]+?)(?:\.git)?$/.exec(url);
|
|
45
|
+
if (m !== null)
|
|
46
|
+
return m[1];
|
|
47
|
+
}
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
}
|
package/dist/main/fs-routes.js
CHANGED
|
@@ -39,10 +39,27 @@ export function deskRead(url, space) {
|
|
|
39
39
|
const AT_CAP = 2000;
|
|
40
40
|
const AT_SKIP = new Set([".git", "node_modules", ".kiso", "dist", "build", ".next", ".venv", "__pycache__"]);
|
|
41
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) {
|
|
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 = []) {
|
|
43
59
|
let files;
|
|
60
|
+
const out = stops.filter((s) => s.startsWith(`${cwd}/`)).map((s) => `:(exclude)${s.slice(cwd.length + 1)}`);
|
|
44
61
|
try {
|
|
45
|
-
files = execFileSync("git", ["ls-files", "-c", "-o", "--exclude-standard"], { cwd
|
|
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}`);
|
|
46
63
|
}
|
|
47
64
|
catch {
|
|
48
65
|
files = [];
|
|
@@ -51,7 +68,7 @@ export function atFiles(space) {
|
|
|
51
68
|
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
52
69
|
if (files.length > AT_CAP)
|
|
53
70
|
return;
|
|
54
|
-
if (AT_SKIP.has(e.name))
|
|
71
|
+
if (AT_SKIP.has(e.name) || stops.includes(`${dir}/${e.name}`))
|
|
55
72
|
continue;
|
|
56
73
|
if (e.isDirectory())
|
|
57
74
|
walk(`${dir}/${e.name}`, `${prefix}${e.name}/`);
|
|
@@ -63,7 +80,7 @@ export function atFiles(space) {
|
|
|
63
80
|
// an unreadable directory adds nothing
|
|
64
81
|
}
|
|
65
82
|
};
|
|
66
|
-
walk(
|
|
83
|
+
walk(cwd, prefix);
|
|
67
84
|
}
|
|
68
|
-
return
|
|
85
|
+
return files;
|
|
69
86
|
}
|
package/dist/main/git.js
CHANGED
|
@@ -6,7 +6,7 @@ import { Refusal } from "./files.js";
|
|
|
6
6
|
* from the dialog's confirm (POST), never by themselves, and refuse what they cannot do in words.
|
|
7
7
|
*/
|
|
8
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
|
-
const tryRun = (cwd, cmd, args, timeout) => run(cwd, cmd, args, timeout).then((o) => o.trim(), () => null);
|
|
9
|
+
export const tryRun = (cwd, cmd, args, timeout) => run(cwd, cmd, args, timeout).then((o) => o.trim(), () => null);
|
|
10
10
|
let ghSignedIn = null;
|
|
11
11
|
/** `gh auth status`, asked once per host process: a sign-in does not come and go between two clicks. */
|
|
12
12
|
const ghReady = (cwd) => (ghSignedIn ??= tryRun(cwd, "gh", ["auth", "status"], 5000).then((o) => o !== null));
|
package/dist/main/history.js
CHANGED
|
@@ -13,17 +13,6 @@ import { sessionFolders } from "@vincemakes/kiso-code/config";
|
|
|
13
13
|
*/
|
|
14
14
|
const MIN_AGE_MS = 3_000;
|
|
15
15
|
const MAX_ROWS = 400;
|
|
16
|
-
/** What the host knows about a Work it holds open: a run live, or waiting for an answer. */
|
|
17
|
-
/** A delegated child's parent (the longest id of its folder that `sub-<id>-` starts with: a parent id may have any shape) and its
|
|
18
|
-
* role (what follows the task's number; a role starts with a letter, a hex delegation id may be all digits); nothing for any
|
|
19
|
-
* other session. */
|
|
20
|
-
export function delegated(id, ids) {
|
|
21
|
-
if (!id.startsWith("sub-"))
|
|
22
|
-
return {};
|
|
23
|
-
const parent = ids.filter((p) => p !== id && id.startsWith(`sub-${p}-`)).sort((a, b) => b.length - a.length)[0];
|
|
24
|
-
const role = /-\d+-(\D.*)$/.exec(id.slice(4 + (parent?.length ?? 0)))?.[1];
|
|
25
|
-
return { ...(parent === undefined ? {} : { parent }), ...(role === undefined ? {} : { role }) };
|
|
26
|
-
}
|
|
27
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
|
|
28
17
|
* runtime's titles read it) is written by a rename. */
|
|
29
18
|
function printOf(dir) {
|
|
@@ -86,13 +75,11 @@ export class History {
|
|
|
86
75
|
if (read === undefined)
|
|
87
76
|
continue;
|
|
88
77
|
const key = basename(folder.dir);
|
|
89
|
-
const ids = read.metas.map((m) => m.id);
|
|
90
78
|
for (const meta of read.metas) {
|
|
91
79
|
rows.push({
|
|
92
80
|
// a fork's parent: the store's own record (any app made it), else the host's (forks it made before)
|
|
93
81
|
...(meta.forkedFrom !== undefined ? { parent: meta.forkedFrom } : {}),
|
|
94
82
|
...this.#marksOf(meta.id),
|
|
95
|
-
...delegated(meta.id, ids),
|
|
96
83
|
id: meta.id,
|
|
97
84
|
key,
|
|
98
85
|
folder: basename(folder.workspace),
|
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");
|
|
@@ -30,6 +31,15 @@ export async function startHost(options) {
|
|
|
30
31
|
...(options.toolLimits !== undefined ? { toolLimits: options.toolLimits } : {}),
|
|
31
32
|
followUps: options.followUps ?? true,
|
|
32
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
|
+
},
|
|
33
43
|
});
|
|
34
44
|
// the folder in use: its directory is claimed (named after its path) the way the terminal claims it
|
|
35
45
|
const workspace = canonicalPath(options.workspace);
|
|
@@ -49,19 +59,26 @@ export async function startHost(options) {
|
|
|
49
59
|
spaces.set(key, space);
|
|
50
60
|
return space;
|
|
51
61
|
};
|
|
62
|
+
const prStates = new PrStates();
|
|
52
63
|
const history = new History(home, async (key, sessionId) => {
|
|
53
64
|
const space = spaces.get(key);
|
|
54
65
|
if (space === undefined || !space.service.isRunning(sessionId))
|
|
55
66
|
return "idle";
|
|
56
67
|
return (await space.service.pendingApprovals(sessionId)).length > 0 ? "waiting" : "running";
|
|
57
|
-
}, (id) =>
|
|
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 } };
|
|
72
|
+
});
|
|
58
73
|
const rendererDir = fileURLToPath(new URL("../../out/renderer/", import.meta.url));
|
|
59
74
|
const tokenBytes = Buffer.from(token);
|
|
60
75
|
let auth;
|
|
61
76
|
let presence = null;
|
|
62
77
|
const previews = new Previews(options.previewLog);
|
|
63
78
|
const requestLog = options.requestLog;
|
|
64
|
-
|
|
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);
|
|
65
82
|
watch.start();
|
|
66
83
|
const desk = { home, settings: options.settings, current, history, spaceFor, setPresence: (p) => (presence = p), notices: (seq) => watch.since(seq), previews };
|
|
67
84
|
let origins = new Set();
|
|
@@ -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,13 +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 {
|
|
5
|
+
import { lookupInputModalities } from "@vincemakes/kiso-runtime/internal";
|
|
6
6
|
import { fauxSkip } from "./faux.js";
|
|
7
7
|
import { storeKey } from "./login.js";
|
|
8
8
|
const MAX_RECENT = 10;
|
|
9
9
|
const SAFE_SESSION = /^[A-Za-z0-9._-]+$/;
|
|
10
|
-
/**
|
|
11
|
-
const
|
|
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));
|
|
12
13
|
const isTier = (value) => OFFERED_MODES.some((m) => m === value);
|
|
13
14
|
export class Settings {
|
|
14
15
|
#file;
|
|
@@ -123,23 +124,29 @@ export class Settings {
|
|
|
123
124
|
forkOf(session) {
|
|
124
125
|
return this.#read().forks?.[session];
|
|
125
126
|
}
|
|
126
|
-
/** Pin or
|
|
127
|
+
/** Pin, archive or mark unread a Work (a pin goes first), or take it off. */
|
|
127
128
|
mark(session, list, on) {
|
|
128
129
|
const rest = (this.#read()[list] ?? []).filter((id) => id !== session);
|
|
129
130
|
this.write({ [list]: on ? [session, ...rest] : rest });
|
|
130
131
|
}
|
|
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. */
|
|
132
138
|
marksOf(session) {
|
|
133
139
|
const own = this.#read();
|
|
134
140
|
const parent = own.forks?.[session]?.parent;
|
|
135
141
|
const pinned = own.pins?.indexOf(session) ?? -1;
|
|
136
|
-
|
|
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 } : {}) };
|
|
137
144
|
}
|
|
138
145
|
/** A deleted Work leaves nothing here; a fork's record of it stays (its notice still names the parent). */
|
|
139
146
|
forget(session) {
|
|
140
147
|
const own = this.#read();
|
|
141
148
|
const drop = (m) => Object.fromEntries(Object.entries(m ?? {}).filter(([k]) => k !== session));
|
|
142
|
-
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) });
|
|
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) });
|
|
143
150
|
}
|
|
144
151
|
restoreWork(session, work) {
|
|
145
152
|
const { [session]: _, ...rest } = this.#read().works ?? {};
|