@vincemakes/kiso-web 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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;
@@ -40,13 +44,11 @@ export function createWebAgent(options) {
40
44
  ...(options.toolLimits !== undefined ? { limits: options.toolLimits } : {}),
41
45
  };
42
46
  const window = model.kind === "profile" ? model.contextWindow : undefined;
43
- const prompt = composeSystemPrompt(workspace, protectedFiles());
44
- const modeNote = modeSystemPrompt(options.tier());
45
47
  const definition = {
46
48
  model: model.kind === "adapter" ? model.model : model.profile.model,
47
49
  store,
48
50
  workspace,
49
- systemPrompt: modeNote === undefined ? prompt : `${prompt}\n\n${modeNote}`,
51
+ systemPrompt: promptOf(workspace, options.tier()),
50
52
  toolRules: CODING_TOOL_RULES,
51
53
  tools: [...createCodingTools(toolOptions)],
52
54
  // the terminal's chain, in its order: the guards that deny speak first
@@ -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,23 +20,57 @@ 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 === "image" && ((b.sourceType === "base64" && b.data !== undefined) || b.url?.startsWith("https:") === true))
27
- ref(n, "image", `log:${event.seq}/${n}`, b.mediaType);
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;
36
76
  try {
@@ -53,7 +93,7 @@ export function artifactsOf(event, workId, call, root) {
53
93
  return out;
54
94
  }
55
95
  /**
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
96
+ * Image blocks of results and of the person's own lines, read from the session's log: an index of seq to the byte range of its line, built once per log and
57
97
  * extended as it grows, so a request reads one event, never the whole log.
58
98
  */
59
99
  export class LogImages {
@@ -62,14 +102,28 @@ export class LogImages {
62
102
  constructor(dir) {
63
103
  this.#dir = dir;
64
104
  }
65
- /** The n-th block of the result at `seq`: its bytes, an https address to pass on, or null. */
105
+ /** The n-th block of the result or the line at `seq`: its bytes, an https address to pass on, or null; `k<n>`: the n-th
106
+ * picture of that result kept from outside the folder. */
66
107
  block(sessionId, seq, n) {
108
+ if (typeof n === "string") {
109
+ const name = (() => {
110
+ try {
111
+ return readdirSync(keptDir(this.#dir, sessionId)).find((f) => f.startsWith(`${seq}-${n}.`));
112
+ }
113
+ catch {
114
+ return undefined;
115
+ }
116
+ })();
117
+ const type = name === undefined ? undefined : KEPT[extname(name)];
118
+ return name === undefined || type === undefined ? null : { type, bytes: readFileSync(join(keptDir(this.#dir, sessionId), name)) };
119
+ }
67
120
  const file = join(this.#dir, `${sessionId}.jsonl`);
68
121
  const at = this.#index(file).get(seq);
69
122
  if (at === undefined)
70
123
  return null;
124
+ // a tool's result, or the person's own line (a picture pasted into the composer)
71
125
  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;
126
+ const b = (event.type === "tool_result" || event.type === "user_input") && Array.isArray(event.content) ? event.content[n] : undefined;
73
127
  if (b?.type !== "image")
74
128
  return null;
75
129
  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
+ }
@@ -1,7 +1,12 @@
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 } from "./git.js";
5
10
  import { existing, Refusal, resolveInside } from "./files.js";
6
11
  import { artifactKind } from "./artifacts.js";
7
12
  import { listDirs, validateFolder } from "./folders.js";
@@ -32,6 +37,88 @@ export async function deskApi(ctx, req, url) {
32
37
  const space = where === null || where === "" ? ctx.current : ctx.spaceFor(where);
33
38
  const post = req.method === "POST";
34
39
  try {
40
+ if (path === "/desk/at-files") {
41
+ if (space === null)
42
+ return json(404, {});
43
+ return req.method === "GET" ? json(200, atFiles(space)) : json(405, {});
44
+ }
45
+ if (path === "/desk/context") {
46
+ if (space === null)
47
+ return json(404, {});
48
+ const session = url.searchParams.get("session") ?? undefined;
49
+ let window = null;
50
+ try {
51
+ const m = ctx.settings.model(session === undefined ? undefined : { sessionId: session, sessionsDir: space.dir });
52
+ window = m.kind === "profile" ? (m.contextWindow ?? null) : null;
53
+ }
54
+ catch {
55
+ // no model: the parts are still known, the compaction sizes are not
56
+ }
57
+ return req.method === "GET" ? json(200, contextParts(space.workspace, ctx.settings.tierOf(session), window)) : json(405, {});
58
+ }
59
+ if (path === "/desk/git") {
60
+ if (space === null)
61
+ return json(404, {});
62
+ return req.method === "GET" ? json(200, url.searchParams.has("draft") ? await prDraft(space.workspace) : await gitOf(space.workspace)) : json(405, {});
63
+ }
64
+ if (path === "/desk/commit" || path === "/desk/pr") {
65
+ if (!post)
66
+ return json(405, {});
67
+ if (space === null)
68
+ return json(404, {});
69
+ const body = await readJson(req);
70
+ if (path === "/desk/commit")
71
+ return json(200, await commit(space.workspace, str(body["message"]) ?? "", Array.isArray(body["paths"]) ? body["paths"].filter((p) => typeof p === "string") : []));
72
+ return json(200, await openPr(space.workspace, str(body["title"]) ?? "", str(body["body"]) ?? ""));
73
+ }
74
+ if (path === "/desk/rename") {
75
+ if (!post)
76
+ return json(405, {});
77
+ if (space === null)
78
+ return json(404, {});
79
+ const body = await readJson(req);
80
+ const session = str(body["session"]) ?? "";
81
+ if (!SAFE_ID.test(session) || !space.store.ids().includes(session))
82
+ return json(404, {});
83
+ return json(200, { name: rename(space.dir, session, str(body["name"]) ?? "") });
84
+ }
85
+ if (path === "/desk/fork") {
86
+ if (!post)
87
+ return json(405, {});
88
+ if (space === null)
89
+ return json(404, {});
90
+ const body = await readJson(req);
91
+ const session = str(body["session"]) ?? "";
92
+ if (!SAFE_ID.test(session))
93
+ return json(404, {});
94
+ const atSeq = typeof body["atSeq"] === "number" ? body["atSeq"] : undefined;
95
+ return json(200, { id: forkWork(space, ctx.settings, session, atSeq).id });
96
+ }
97
+ if (path === "/desk/work") {
98
+ if (!post)
99
+ return json(405, {});
100
+ if (space === null)
101
+ return json(404, {});
102
+ const body = await readJson(req);
103
+ const session = str(body["session"]) ?? "";
104
+ const act = str(body["act"]) ?? "";
105
+ if (!SAFE_ID.test(session) || !WORK_ACTS.includes(act))
106
+ return json(404, {});
107
+ workAct(space, ctx.settings, session, act);
108
+ return json(200, { ok: true });
109
+ }
110
+ if (path === "/desk/task-stop") {
111
+ if (!post)
112
+ return json(405, {});
113
+ if (space === null)
114
+ return json(404, {});
115
+ const body = await readJson(req);
116
+ const session = str(body["session"]) ?? "";
117
+ const task = str(body["task"]) ?? "";
118
+ if (!SAFE_ID.test(session) || !SAFE_ID.test(task) || !space.store.ids().includes(session))
119
+ return json(404, {});
120
+ return json(200, { stopped: space.tasks.managerFor(session).stop(task, "person") });
121
+ }
35
122
  if (path === "/desk/fs/tree" || path === "/desk/fs/file" || path === "/desk/task-log") {
36
123
  if (space === null)
37
124
  return json(404, {});
@@ -43,11 +130,11 @@ export async function deskApi(ctx, req, url) {
43
130
  return post ? json(200, { found: existing(realpathSync(space.workspace), (await readJson(req))["paths"]) }) : json(405, {});
44
131
  }
45
132
  // 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);
133
+ const image = /^\/desk\/image\/([A-Za-z0-9._-]+)\/(\d+)\/(\d+|k\d+)$/.exec(path);
47
134
  if (image !== null) {
48
135
  if (space === null || req.method !== "GET" || !space.store.ids().includes(image[1]))
49
136
  return json(404, {});
50
- const b = space.images.block(image[1], Number(image[2]), Number(image[3]));
137
+ const b = space.images.block(image[1], Number(image[2]), image[3].startsWith("k") ? image[3] : Number(image[3]));
51
138
  if (b === null)
52
139
  return json(404, {});
53
140
  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 +153,51 @@ export async function deskApi(ctx, req, url) {
66
153
  ctx.previews.close(String((await readJson(req))["url"] ?? ""));
67
154
  return json(200, { ok: true });
68
155
  }
156
+ // the line the person may send next after the run that ended at `seq` (asked once, after that run; "" for none)
157
+ if (path === "/desk/followup") {
158
+ if (space === null)
159
+ return json(404, {});
160
+ if (req.method !== "GET")
161
+ return json(405, {});
162
+ const session = url.searchParams.get("session") ?? "";
163
+ const seq = Number(url.searchParams.get("seq"));
164
+ if (!space.store.ids().includes(session) || !Number.isInteger(seq))
165
+ return json(404, {});
166
+ return json(200, { line: await space.followUp(session, seq) });
167
+ }
69
168
  if (path === "/desk/env") {
70
169
  if (space === null)
71
170
  return json(404, {});
72
- return req.method === "GET" ? json(200, folderInfo(space.workspace, ctx.settings.tierOf(url.searchParams.get("session") ?? undefined))) : json(405, {});
171
+ if (req.method !== "GET")
172
+ return json(405, {});
173
+ const session = url.searchParams.get("session") ?? undefined;
174
+ const fork = session === undefined ? undefined : ctx.settings.forkOf(session);
175
+ return json(200, { ...folderInfo(space.workspace, ctx.settings.tierOf(session)), ...(fork === undefined ? {} : { fork: { parent: fork.parent, title: fork.title, at: fork.at } }) });
73
176
  }
74
177
  if (path === "/desk/history")
75
178
  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
179
  if (path === "/desk/settings") {
77
- if (post)
78
- ctx.settings.apply((await readJson(req)));
180
+ let session = url.searchParams.get("session") ?? undefined;
181
+ if (post) {
182
+ const patch = (await readJson(req));
183
+ // a patch for one Work is answered with that Work's settings
184
+ session ??= patch.session;
185
+ const before = patch.session === undefined ? undefined : ctx.settings.workOf(patch.session);
186
+ const work = ctx.settings.apply(patch);
187
+ // 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
188
+ if (work !== null && space !== null && space.opened().has(work)) {
189
+ try {
190
+ await space.service.reopen(work);
191
+ }
192
+ catch {
193
+ ctx.settings.restoreWork(work, before);
194
+ return json(409, { message: "a run is going: switch the model when it ends" });
195
+ }
196
+ }
197
+ }
79
198
  else if (req.method !== "GET")
80
199
  return json(405, {});
81
- return json(200, ctx.settings.snapshot(url.searchParams.get("session") ?? undefined));
200
+ return json(200, ctx.settings.snapshot(session));
82
201
  }
83
202
  if (path === "/desk/folder" && post) {
84
203
  const workspace = validateFolder((await readJson(req))["path"]);
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,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
+ }
@@ -1,12 +1,13 @@
1
- import { realpathSync } from "node:fs";
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) and a
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,34 @@ 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) {
43
+ let files;
44
+ try {
45
+ files = execFileSync("git", ["ls-files", "-c", "-o", "--exclude-standard"], { cwd: space.workspace, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"], maxBuffer: 1 << 24, timeout: 3000 }).split("\n").filter((p) => p !== "");
46
+ }
47
+ catch {
48
+ files = [];
49
+ const walk = (dir, prefix) => {
50
+ try {
51
+ for (const e of readdirSync(dir, { withFileTypes: true })) {
52
+ if (files.length > AT_CAP)
53
+ return;
54
+ if (AT_SKIP.has(e.name))
55
+ continue;
56
+ if (e.isDirectory())
57
+ walk(`${dir}/${e.name}`, `${prefix}${e.name}/`);
58
+ else if (e.isFile())
59
+ files.push(`${prefix}${e.name}`);
60
+ }
61
+ }
62
+ catch {
63
+ // an unreadable directory adds nothing
64
+ }
65
+ };
66
+ walk(space.workspace, "");
67
+ }
68
+ return { files: files.slice(0, AT_CAP), truncated: files.length > AT_CAP };
69
+ }
@@ -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
+ 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
+ }
@@ -13,16 +13,33 @@ 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
+ /** 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
+ /** 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
+ * runtime's titles read it) is written by a rename. */
17
29
  function printOf(dir) {
18
30
  let count = 0;
19
31
  let bytes = 0;
20
32
  let newest = 0;
21
33
  for (const name of readdirSync(dir)) {
22
- if (!name.endsWith(".jsonl"))
34
+ const meta = name.endsWith(".meta.json");
35
+ if (!name.endsWith(".jsonl") && !meta)
23
36
  continue;
24
37
  try {
25
38
  const s = statSync(join(dir, name));
39
+ if (meta) {
40
+ newest = Math.max(newest, s.mtimeMs);
41
+ continue;
42
+ }
26
43
  if (s.size === 0)
27
44
  continue;
28
45
  count += 1;
@@ -38,13 +55,15 @@ function printOf(dir) {
38
55
  export class History {
39
56
  #home;
40
57
  #live;
58
+ #marksOf;
41
59
  #reads = new Map();
42
60
  #queue = [];
43
61
  #worker = null;
44
62
  #inflight = null;
45
- constructor(home, live) {
63
+ constructor(home, live, marksOf = () => ({})) {
46
64
  this.#home = home;
47
65
  this.#live = live;
66
+ this.#marksOf = marksOf;
48
67
  }
49
68
  /** `fresh` names a folder whose listing is read again now, whatever its age. */
50
69
  async snapshot(current, fresh) {
@@ -67,8 +86,13 @@ export class History {
67
86
  if (read === undefined)
68
87
  continue;
69
88
  const key = basename(folder.dir);
89
+ const ids = read.metas.map((m) => m.id);
70
90
  for (const meta of read.metas) {
71
91
  rows.push({
92
+ // a fork's parent: the store's own record (any app made it), else the host's (forks it made before)
93
+ ...(meta.forkedFrom !== undefined ? { parent: meta.forkedFrom } : {}),
94
+ ...this.#marksOf(meta.id),
95
+ ...delegated(meta.id, ids),
72
96
  id: meta.id,
73
97
  key,
74
98
  folder: basename(folder.workspace),
@@ -103,18 +127,21 @@ export class History {
103
127
  this.#inflight = dir;
104
128
  const print = printOf(dir);
105
129
  const worker = (this.#worker ??= new Worker(new URL("./history-worker.js", import.meta.url)));
130
+ // both listeners go when either answers: one read, one pair (the worker lives across reads, so a kept listener piles up)
106
131
  const done = (message) => {
107
132
  worker.off("message", done);
133
+ worker.off("error", failed);
108
134
  // an unreadable folder is remembered empty: it is tried again when its files change
109
135
  this.#reads.set(dir, { print, metas: message.metas ?? [], at: Date.now() });
110
136
  this.#inflight = null;
111
137
  this.#pump();
112
138
  };
113
- worker.on("message", done);
114
- worker.once("error", (error) => {
139
+ const failed = (error) => {
115
140
  this.#worker = null;
116
141
  done({ dir, error: error.message });
117
- });
142
+ };
143
+ worker.on("message", done);
144
+ worker.on("error", failed);
118
145
  worker.postMessage({ dir });
119
146
  }
120
147
  close() {