@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
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);
|
|
@@ -27,6 +27,10 @@ class WebAgent extends Agent {
|
|
|
27
27
|
return session;
|
|
28
28
|
}
|
|
29
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
|
+
}
|
|
30
34
|
export function createWebAgent(options) {
|
|
31
35
|
const { store, workspace, model } = options;
|
|
32
36
|
const root = () => workspace;
|
|
@@ -35,24 +39,25 @@ export function createWebAgent(options) {
|
|
|
35
39
|
excludeRoots: [kisoHome()],
|
|
36
40
|
secretEnvNames: options.secretEnvNames ?? [],
|
|
37
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()),
|
|
38
45
|
tasks: options.tasks.shellTasks,
|
|
39
46
|
observedRevision: observedRevisionFrom(store, workspace),
|
|
40
47
|
...(options.toolLimits !== undefined ? { limits: options.toolLimits } : {}),
|
|
41
48
|
};
|
|
42
49
|
const window = model.kind === "profile" ? model.contextWindow : undefined;
|
|
43
|
-
const prompt = composeSystemPrompt(workspace, protectedFiles());
|
|
44
|
-
const modeNote = modeSystemPrompt(options.tier());
|
|
45
50
|
const definition = {
|
|
46
51
|
model: model.kind === "adapter" ? model.model : model.profile.model,
|
|
47
52
|
store,
|
|
48
53
|
workspace,
|
|
49
|
-
systemPrompt:
|
|
54
|
+
systemPrompt: promptOf(workspace, options.tier()),
|
|
50
55
|
toolRules: CODING_TOOL_RULES,
|
|
51
56
|
tools: [...createCodingTools(toolOptions)],
|
|
52
57
|
// the terminal's chain, in its order: the guards that deny speak first
|
|
53
58
|
extensions: [
|
|
54
59
|
protectedShellExtension({ files: protectedFiles, workspaceRoot: root, env: () => ({ home: homedir(), kisoHome: kisoHome() }) }),
|
|
55
|
-
floorExtension(() => true, root),
|
|
60
|
+
floorExtension(() => true, root, undefined, () => readEnvironment(store.root, options.sessionId).added),
|
|
56
61
|
breakerExtension(),
|
|
57
62
|
...modeExtensions(root, options.tier),
|
|
58
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
|
@@ -1,10 +1,16 @@
|
|
|
1
|
-
import { closeSync, fstatSync, openSync, readSync, statSync } from "node:fs";
|
|
2
|
-
import { extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
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
3
|
import { resolveInside } from "./files.js";
|
|
4
4
|
/**
|
|
5
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
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
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.
|
|
8
14
|
*/
|
|
9
15
|
const KINDS = {
|
|
10
16
|
".png": "image", ".jpg": "image", ".jpeg": "image", ".gif": "image", ".webp": "image", ".svg": "image",
|
|
@@ -14,25 +20,62 @@ const KINDS = {
|
|
|
14
20
|
const NAMED = /[\w@+./-]+\.(?:png|jpe?g|gif|webp|svg|html?|md|pdf|csv|mp4|webm|mov|mp3|wav)\b/gi;
|
|
15
21
|
/** a file's clock and the log's may differ by the file system's granularity */
|
|
16
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`);
|
|
17
27
|
export const artifactKind = (path) => KINDS[extname(path).toLowerCase()];
|
|
18
|
-
/** `root`: the folder's real path. */
|
|
19
|
-
export function artifactsOf(event, workId, call, root) {
|
|
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) {
|
|
20
30
|
const out = [];
|
|
21
|
-
const ref = (n, kind, uri, mediaType) => {
|
|
22
|
-
out.push({ id: `${event.callId}:${n}`, workId, callId: event.callId, kind, uri, ...(mediaType !== undefined ? { mediaType } : {}) });
|
|
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 } : {}) });
|
|
23
33
|
};
|
|
24
|
-
if (Array.isArray(event.content))
|
|
34
|
+
if (Array.isArray(event.content)) {
|
|
35
|
+
const blocks = event.content.filter((b) => b.type === "image").length;
|
|
36
|
+
let k = 0;
|
|
25
37
|
event.content.forEach((b, n) => {
|
|
26
|
-
if (b.type
|
|
27
|
-
|
|
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}`);
|
|
28
43
|
});
|
|
44
|
+
}
|
|
29
45
|
if (event.isError || call === null)
|
|
30
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
|
+
};
|
|
31
69
|
const file = (raw, since) => {
|
|
32
70
|
const rel = relative(root, isAbsolute(raw) ? raw : resolve(root, raw));
|
|
33
71
|
const kind = artifactKind(rel);
|
|
72
|
+
if (rel.startsWith("..") && isAbsolute(raw) && since !== null)
|
|
73
|
+
return outside(raw, since);
|
|
34
74
|
if (kind === undefined || rel.startsWith("..") || out.some((a) => a.uri === `file:${rel}`))
|
|
35
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;
|
|
36
79
|
try {
|
|
37
80
|
const stat = statSync(resolveInside(root, rel));
|
|
38
81
|
if (stat.isFile() && (since === null || stat.mtimeMs >= since - SLACK_MS))
|
|
@@ -53,7 +96,7 @@ export function artifactsOf(event, workId, call, root) {
|
|
|
53
96
|
return out;
|
|
54
97
|
}
|
|
55
98
|
/**
|
|
56
|
-
* Image blocks of results, read from the session's log: an index of seq to the byte range of its line, built once per log and
|
|
99
|
+
* 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
|
|
57
100
|
* extended as it grows, so a request reads one event, never the whole log.
|
|
58
101
|
*/
|
|
59
102
|
export class LogImages {
|
|
@@ -62,14 +105,28 @@ export class LogImages {
|
|
|
62
105
|
constructor(dir) {
|
|
63
106
|
this.#dir = dir;
|
|
64
107
|
}
|
|
65
|
-
/** The n-th block of the result at `seq`: its bytes, an https address to pass on, or null
|
|
108
|
+
/** 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
|
|
109
|
+
* picture of that result kept from outside the folder. */
|
|
66
110
|
block(sessionId, seq, n) {
|
|
111
|
+
if (typeof n === "string") {
|
|
112
|
+
const name = (() => {
|
|
113
|
+
try {
|
|
114
|
+
return readdirSync(keptDir(this.#dir, sessionId)).find((f) => f.startsWith(`${seq}-${n}.`));
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
})();
|
|
120
|
+
const type = name === undefined ? undefined : KEPT[extname(name)];
|
|
121
|
+
return name === undefined || type === undefined ? null : { type, bytes: readFileSync(join(keptDir(this.#dir, sessionId), name)) };
|
|
122
|
+
}
|
|
67
123
|
const file = join(this.#dir, `${sessionId}.jsonl`);
|
|
68
124
|
const at = this.#index(file).get(seq);
|
|
69
125
|
if (at === undefined)
|
|
70
126
|
return null;
|
|
127
|
+
// a tool's result, or the person's own line (a picture pasted into the composer)
|
|
71
128
|
const event = JSON.parse(read(file, at[0], at[1]).toString("utf8")).event;
|
|
72
|
-
const b = event.type === "tool_result" && Array.isArray(event.content) ? event.content[n] : undefined;
|
|
129
|
+
const b = (event.type === "tool_result" || event.type === "user_input") && Array.isArray(event.content) ? event.content[n] : undefined;
|
|
73
130
|
if (b?.type !== "image")
|
|
74
131
|
return null;
|
|
75
132
|
if (b.sourceType === "base64" && b.data !== undefined)
|
|
@@ -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
|
+
}
|
package/dist/main/desk-api.js
CHANGED
|
@@ -1,11 +1,20 @@
|
|
|
1
1
|
import { realpathSync, statSync } from "node:fs";
|
|
2
2
|
import { basename } from "node:path";
|
|
3
3
|
import { canonicalPath, claimProjectDir, projectDirFor } from "@vincemakes/kiso-code/config";
|
|
4
|
-
import { deskRead } from "./fs-routes.js";
|
|
4
|
+
import { atFiles, deskRead, SAFE_ID } from "./fs-routes.js";
|
|
5
|
+
import { contextParts } from "./context.js";
|
|
6
|
+
import { rename } from "./names.js";
|
|
7
|
+
import { forkWork } from "./fork.js";
|
|
8
|
+
import { WORK_ACTS, workAct } from "./works.js";
|
|
9
|
+
import { commit, gitOf, openPr, prDraft, tryRun } from "./git.js";
|
|
10
|
+
import { prOfUrl } from "./prs.js";
|
|
5
11
|
import { existing, Refusal, resolveInside } from "./files.js";
|
|
6
12
|
import { artifactKind } from "./artifacts.js";
|
|
7
13
|
import { listDirs, validateFolder } from "./folders.js";
|
|
14
|
+
import { pickFolder } from "./pick-folder.js";
|
|
8
15
|
import { folderInfo } from "./folder-info.js";
|
|
16
|
+
import { changeFolders, foldersOf, stopRoots } from "./work-folders.js";
|
|
17
|
+
import { environmentRows, openRow } from "./environment.js";
|
|
9
18
|
const json = (status, body) => ({ status, body });
|
|
10
19
|
const BODY_CAP = 16 * 1024;
|
|
11
20
|
async function readJson(req) {
|
|
@@ -32,6 +41,124 @@ export async function deskApi(ctx, req, url) {
|
|
|
32
41
|
const space = where === null || where === "" ? ctx.current : ctx.spaceFor(where);
|
|
33
42
|
const post = req.method === "POST";
|
|
34
43
|
try {
|
|
44
|
+
if (path === "/desk/at-files") {
|
|
45
|
+
if (space === null)
|
|
46
|
+
return json(404, {});
|
|
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, {});
|
|
49
|
+
}
|
|
50
|
+
if (path === "/desk/context") {
|
|
51
|
+
if (space === null)
|
|
52
|
+
return json(404, {});
|
|
53
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
54
|
+
let window = null;
|
|
55
|
+
try {
|
|
56
|
+
const m = ctx.settings.model(session === undefined ? undefined : { sessionId: session, sessionsDir: space.dir });
|
|
57
|
+
window = m.kind === "profile" ? (m.contextWindow ?? null) : null;
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
// no model: the parts are still known, the compaction sizes are not
|
|
61
|
+
}
|
|
62
|
+
return req.method === "GET" ? json(200, contextParts(space.workspace, ctx.settings.tierOf(session), window)) : json(405, {});
|
|
63
|
+
}
|
|
64
|
+
if (path === "/desk/git") {
|
|
65
|
+
if (space === null)
|
|
66
|
+
return json(404, {});
|
|
67
|
+
return req.method === "GET" ? json(200, url.searchParams.has("draft") ? await prDraft(space.workspace) : await gitOf(space.workspace)) : json(405, {});
|
|
68
|
+
}
|
|
69
|
+
if (path === "/desk/commit" || path === "/desk/pr") {
|
|
70
|
+
if (!post)
|
|
71
|
+
return json(405, {});
|
|
72
|
+
if (space === null)
|
|
73
|
+
return json(404, {});
|
|
74
|
+
const body = await readJson(req);
|
|
75
|
+
if (path === "/desk/commit")
|
|
76
|
+
return json(200, await commit(space.workspace, str(body["message"]) ?? "", Array.isArray(body["paths"]) ? body["paths"].filter((p) => typeof p === "string") : []));
|
|
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));
|
|
113
|
+
}
|
|
114
|
+
if (path === "/desk/rename") {
|
|
115
|
+
if (!post)
|
|
116
|
+
return json(405, {});
|
|
117
|
+
if (space === null)
|
|
118
|
+
return json(404, {});
|
|
119
|
+
const body = await readJson(req);
|
|
120
|
+
const session = str(body["session"]) ?? "";
|
|
121
|
+
if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
|
|
122
|
+
return json(404, {});
|
|
123
|
+
return json(200, { name: rename(space.dir, session, str(body["name"]) ?? "") });
|
|
124
|
+
}
|
|
125
|
+
if (path === "/desk/fork") {
|
|
126
|
+
if (!post)
|
|
127
|
+
return json(405, {});
|
|
128
|
+
if (space === null)
|
|
129
|
+
return json(404, {});
|
|
130
|
+
const body = await readJson(req);
|
|
131
|
+
const session = str(body["session"]) ?? "";
|
|
132
|
+
if (!SAFE_ID.test(session))
|
|
133
|
+
return json(404, {});
|
|
134
|
+
const atSeq = typeof body["atSeq"] === "number" ? body["atSeq"] : undefined;
|
|
135
|
+
return json(200, { id: forkWork(space, ctx.settings, session, atSeq).id });
|
|
136
|
+
}
|
|
137
|
+
if (path === "/desk/work") {
|
|
138
|
+
if (!post)
|
|
139
|
+
return json(405, {});
|
|
140
|
+
if (space === null)
|
|
141
|
+
return json(404, {});
|
|
142
|
+
const body = await readJson(req);
|
|
143
|
+
const session = str(body["session"]) ?? "";
|
|
144
|
+
const act = str(body["act"]) ?? "";
|
|
145
|
+
if (!SAFE_ID.test(session) || !WORK_ACTS.includes(act))
|
|
146
|
+
return json(404, {});
|
|
147
|
+
workAct(space, ctx.settings, session, act);
|
|
148
|
+
return json(200, { ok: true });
|
|
149
|
+
}
|
|
150
|
+
if (path === "/desk/task-stop") {
|
|
151
|
+
if (!post)
|
|
152
|
+
return json(405, {});
|
|
153
|
+
if (space === null)
|
|
154
|
+
return json(404, {});
|
|
155
|
+
const body = await readJson(req);
|
|
156
|
+
const session = str(body["session"]) ?? "";
|
|
157
|
+
const task = str(body["task"]) ?? "";
|
|
158
|
+
if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
|
|
159
|
+
return json(404, {});
|
|
160
|
+
return json(200, { stopped: space.tasks.managerFor(session).stop(task, "person") });
|
|
161
|
+
}
|
|
35
162
|
if (path === "/desk/fs/tree" || path === "/desk/fs/file" || path === "/desk/task-log") {
|
|
36
163
|
if (space === null)
|
|
37
164
|
return json(404, {});
|
|
@@ -43,11 +170,11 @@ export async function deskApi(ctx, req, url) {
|
|
|
43
170
|
return post ? json(200, { found: existing(realpathSync(space.workspace), (await readJson(req))["paths"]) }) : json(405, {});
|
|
44
171
|
}
|
|
45
172
|
// an image block of a result, from the Work's log: its bytes, or an https address passed on
|
|
46
|
-
const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+)$/.exec(path);
|
|
173
|
+
const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+|k\d+)$/.exec(path);
|
|
47
174
|
if (image !== null) {
|
|
48
175
|
if (space === null || req.method !== "GET" || !space.store.ids().includes(image[1]))
|
|
49
176
|
return json(404, {});
|
|
50
|
-
const b = space.images.block(image[1], Number(image[2]), Number(image[3]));
|
|
177
|
+
const b = space.images.block(image[1], Number(image[2]), image[3].startsWith("k") ? image[3] : Number(image[3]));
|
|
51
178
|
if (b === null)
|
|
52
179
|
return json(404, {});
|
|
53
180
|
return "url" in b ? { status: 302, body: null, headers: { location: b.url } } : { status: 200, body: b.bytes, headers: { "content-type": b.type, "x-content-type-options": "nosniff", "cache-control": "private, max-age=86400" } };
|
|
@@ -66,19 +193,62 @@ export async function deskApi(ctx, req, url) {
|
|
|
66
193
|
ctx.previews.close(String((await readJson(req))["url"] ?? ""));
|
|
67
194
|
return json(200, { ok: true });
|
|
68
195
|
}
|
|
196
|
+
// the line the person may send next after the run that ended at `seq` (asked once, after that run; "" for none)
|
|
197
|
+
if (path === "/desk/followup") {
|
|
198
|
+
if (space === null)
|
|
199
|
+
return json(404, {});
|
|
200
|
+
if (req.method !== "GET")
|
|
201
|
+
return json(405, {});
|
|
202
|
+
const session = url.searchParams.get("session") ?? "";
|
|
203
|
+
const seq = Number(url.searchParams.get("seq"));
|
|
204
|
+
if (!space.store.ids().includes(session) || !Number.isInteger(seq))
|
|
205
|
+
return json(404, {});
|
|
206
|
+
return json(200, { line: await space.followUp(session, seq) });
|
|
207
|
+
}
|
|
69
208
|
if (path === "/desk/env") {
|
|
70
209
|
if (space === null)
|
|
71
210
|
return json(404, {});
|
|
72
|
-
|
|
211
|
+
if (req.method !== "GET")
|
|
212
|
+
return json(405, {});
|
|
213
|
+
const session = url.searchParams.get("session") ?? undefined;
|
|
214
|
+
const fork = session === undefined ? undefined : ctx.settings.forkOf(session);
|
|
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, {});
|
|
73
227
|
}
|
|
74
228
|
if (path === "/desk/history")
|
|
75
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, {});
|
|
76
230
|
if (path === "/desk/settings") {
|
|
77
|
-
|
|
78
|
-
|
|
231
|
+
let session = url.searchParams.get("session") ?? undefined;
|
|
232
|
+
if (post) {
|
|
233
|
+
const patch = (await readJson(req));
|
|
234
|
+
// a patch for one Work is answered with that Work's settings
|
|
235
|
+
session ??= patch.session;
|
|
236
|
+
const before = patch.session === undefined ? undefined : ctx.settings.workOf(patch.session);
|
|
237
|
+
const work = ctx.settings.apply(patch);
|
|
238
|
+
// a Work's model changed: rebuild it from its log so its next run uses it; a run in flight refuses, and the choice is put back
|
|
239
|
+
if (work !== null && space !== null && space.opened().has(work)) {
|
|
240
|
+
try {
|
|
241
|
+
await space.service.reopen(work);
|
|
242
|
+
}
|
|
243
|
+
catch {
|
|
244
|
+
ctx.settings.restoreWork(work, before);
|
|
245
|
+
return json(409, { message: "a run is going: switch the model when it ends" });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
79
249
|
else if (req.method !== "GET")
|
|
80
250
|
return json(405, {});
|
|
81
|
-
return json(200, ctx.settings.snapshot(
|
|
251
|
+
return json(200, ctx.settings.snapshot(session));
|
|
82
252
|
}
|
|
83
253
|
if (path === "/desk/folder" && post) {
|
|
84
254
|
const workspace = validateFolder((await readJson(req))["path"]);
|
|
@@ -89,6 +259,10 @@ export async function deskApi(ctx, req, url) {
|
|
|
89
259
|
const opened = ctx.spaceFor(basename(dir));
|
|
90
260
|
return opened === null ? json(500, {}) : json(200, { key: opened.key, folder: basename(workspace) });
|
|
91
261
|
}
|
|
262
|
+
if (path === "/desk/pick-folder" && post) {
|
|
263
|
+
const picked = await pickFolder();
|
|
264
|
+
return json(200, picked === null ? { cancelled: true } : { path: picked });
|
|
265
|
+
}
|
|
92
266
|
if (path === "/desk/recent" && req.method === "GET") {
|
|
93
267
|
const rows = ctx.settings.recents().flatMap((p) => {
|
|
94
268
|
try {
|
package/dist/main/env.js
CHANGED
|
@@ -96,6 +96,8 @@ export async function startFromEnv(options = {}) {
|
|
|
96
96
|
...(options.port !== undefined ? { port: options.port } : {}),
|
|
97
97
|
...(process.env["KISO_WEB_TOKEN"] !== undefined ? { token: process.env["KISO_WEB_TOKEN"] } : {}),
|
|
98
98
|
sanitizer: process.env["KISO_WEB_SANITIZER"] === "default" ? "default" : "web",
|
|
99
|
+
followUps: process.env["KISO_WEB_FOLLOWUP"] !== "off",
|
|
100
|
+
titles: process.env["KISO_AUTO_TITLE"] !== "off",
|
|
99
101
|
...(Number.isFinite(keepalive) && keepalive > 0 ? { keepaliveMs: keepalive } : {}),
|
|
100
102
|
...(process.env["KISO_WEB_MIN_FG_MS"] !== undefined && Number.isFinite(minFg) ? { toolLimits: { minForegroundMs: minFg } } : {}),
|
|
101
103
|
});
|
|
@@ -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
|
+
}
|