@netnodeag/kraftwerk 0.49.0 → 0.50.1
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/README.md +7 -2
- package/dist/inspector/chat/acp.js +7 -1
- package/dist/inspector/chat/backend.d.ts +14 -3
- package/dist/inspector/chat/pi.js +2 -2
- package/dist/inspector/chat/sessions.js +13 -3
- package/dist/inspector/git.d.ts +51 -0
- package/dist/inspector/git.js +17 -9
- package/dist/inspector/repos.d.ts +47 -0
- package/dist/inspector/repos.js +121 -2
- package/dist/inspector/server.js +38 -1
- package/inspector/dist/assets/dist-BB5opUKX.js +1 -0
- package/inspector/dist/assets/{dist-CFvGR87v.js → dist-BV28M6sp.js} +1 -1
- package/inspector/dist/assets/{dist-QwwTJ2wk.js → dist-Bi4ZTD6W.js} +1 -1
- package/inspector/dist/assets/{dist-BgYnJawU.js → dist-BuqvesGh.js} +1 -1
- package/inspector/dist/assets/{dist-BrgwFqUR.js → dist-CLSTyUdp.js} +1 -1
- package/inspector/dist/assets/{dist-BIvcKBWo.js → dist-Ca8OxgWP.js} +1 -1
- package/inspector/dist/assets/{dist-BFJWVdBm.js → dist-DGB-RjHI.js} +1 -1
- package/inspector/dist/assets/{dist-CjtxxsZ2.js → dist-DHS6DxTY.js} +1 -1
- package/inspector/dist/assets/{dist-B-8ArbIm.js → dist-DVWyuKcp.js} +1 -1
- package/inspector/dist/assets/{dist-tfmgozDB.js → dist-DkEzjj-j.js} +1 -1
- package/inspector/dist/assets/{dist-Bz0bwjja.js → dist-Dv003C77.js} +1 -1
- package/inspector/dist/assets/{editor-C6m40u-h.js → editor-B5WUiag9.js} +3 -3
- package/inspector/dist/assets/{index-Cvq-RiV2.js → index-BfiWjmwA.js} +6 -6
- package/inspector/dist/assets/{index-DcQwGaV2.css → index-DCpGWY1g.css} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/dist-Bl1ogZZM.js +0 -1
package/README.md
CHANGED
|
@@ -587,8 +587,13 @@ repos:
|
|
|
587
587
|
The folder is the registry. Whatever has a `.git` directly under the root is
|
|
588
588
|
a repository, whether the "repositories" screen cloned it, `kraftwerk repos
|
|
589
589
|
add <url>` did, or an agent ran `git clone` there. Every entry is read live
|
|
590
|
-
from git: origin, branch, head, uncommitted changes, ahead/behind.
|
|
591
|
-
|
|
590
|
+
from git: origin, branch, head, uncommitted changes, ahead/behind. Each
|
|
591
|
+
clone has a page (`#/repos/<name>`, the "changes" button) that shows what
|
|
592
|
+
is happening inside: the changed files with their diff against HEAD, the
|
|
593
|
+
line counts, and the recent commits with the unpushed ones marked — click
|
|
594
|
+
a commit for its patch. It polls, so an agent working in the clone is
|
|
595
|
+
watched live. Secrets (`.env`, keys) are never shown, as on the git screen.
|
|
596
|
+
The list clones by url, fetches and fast-forwards clean clones, and removes
|
|
592
597
|
them (refusing while they hold unpushed or uncommitted work). Cloning uses
|
|
593
598
|
your own git credentials and never prompts, so a private remote has to work
|
|
594
599
|
from a terminal first.
|
|
@@ -476,7 +476,13 @@ export async function startAcpBackend(agent, cwd, hooks, tuning = {}) {
|
|
|
476
476
|
_meta: fileChangeReportMeta(randomUUID()),
|
|
477
477
|
});
|
|
478
478
|
turns++;
|
|
479
|
-
|
|
479
|
+
// A provider condition that ends the turn itself (usage limit, rate
|
|
480
|
+
// limit, provider error) is not a notification: the adapter settles
|
|
481
|
+
// the prompt with stopReason "end_turn" and the typed failure in the
|
|
482
|
+
// response `_meta`. Without reading it here the turn would end
|
|
483
|
+
// silently — no text, no error (see failureOf).
|
|
484
|
+
const failure = failureOf(res._meta);
|
|
485
|
+
return failure ? { stopReason: res.stopReason, failure } : { stopReason: res.stopReason };
|
|
480
486
|
}
|
|
481
487
|
catch (err) {
|
|
482
488
|
// A resumed session that cannot even take its first prompt (the
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { AgentCommand, Attachment, ChatEvent, ConfigOption, ElicitationAnswer, ElicitationField } from "./types.js";
|
|
1
|
+
import type { AgentCommand, Attachment, ChatEvent, ConfigOption, ElicitationAnswer, ElicitationField, SessionFailure } from "./types.js";
|
|
2
2
|
/** An attachment as the backend gets it: where the file is, and what it is. */
|
|
3
3
|
export interface PromptFile extends Attachment {
|
|
4
4
|
path: string;
|
|
@@ -46,6 +46,17 @@ export interface BackendTuning {
|
|
|
46
46
|
/** ACP agents: the session id to continue (from ChatMeta.sessions) instead of opening a new one. */
|
|
47
47
|
resume?: string;
|
|
48
48
|
}
|
|
49
|
+
/**
|
|
50
|
+
* How a turn ended. `failure` is the typed session failure the agent
|
|
51
|
+
* attached to the turn's own outcome (a usage limit, a rate limit, a
|
|
52
|
+
* provider error): ACP hands those back on the prompt response rather
|
|
53
|
+
* than as a mid-turn notification, and without it the turn would look
|
|
54
|
+
* like an ordinary empty `end_turn`.
|
|
55
|
+
*/
|
|
56
|
+
export interface TurnEnd {
|
|
57
|
+
stopReason: string;
|
|
58
|
+
failure?: SessionFailure;
|
|
59
|
+
}
|
|
49
60
|
export interface ChatBackend {
|
|
50
61
|
/** The agent's own session id, when it has one to resume later (ACP agents). */
|
|
51
62
|
sessionId?: string;
|
|
@@ -53,8 +64,8 @@ export interface ChatBackend {
|
|
|
53
64
|
resumed?: boolean;
|
|
54
65
|
/** The resumed session turned out unusable at its first prompt — its id must not be resumed again. */
|
|
55
66
|
resumeFailed?: boolean;
|
|
56
|
-
/** Send one user message (plus files dropped into it); resolves with the
|
|
57
|
-
prompt(text: string, files?: PromptFile[]): Promise<
|
|
67
|
+
/** Send one user message (plus files dropped into it); resolves with how the turn ended. */
|
|
68
|
+
prompt(text: string, files?: PromptFile[]): Promise<TurnEnd>;
|
|
58
69
|
/** Branch the conversation: a new session id with this session's history (ACP agents that offer session/fork). */
|
|
59
70
|
fork?(): Promise<string>;
|
|
60
71
|
/**
|
|
@@ -87,14 +87,14 @@ export function startPiBackend(cwd, hooks, tuning = {}) {
|
|
|
87
87
|
child = null;
|
|
88
88
|
handleLine(buffer);
|
|
89
89
|
if (cancelled)
|
|
90
|
-
return resolve("cancelled");
|
|
90
|
+
return resolve({ stopReason: "cancelled" });
|
|
91
91
|
if (failure || code !== 0 || !sawAssistant) {
|
|
92
92
|
reject(new Error(`pi failed (exit ${code})` +
|
|
93
93
|
(failure ? `: ${failure}` : "") +
|
|
94
94
|
(stderr.trim() ? `\nstderr: ${stderr.trim().slice(-1000)}` : "")));
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
|
-
resolve("end_turn");
|
|
97
|
+
resolve({ stopReason: "end_turn" });
|
|
98
98
|
});
|
|
99
99
|
});
|
|
100
100
|
},
|
|
@@ -67,6 +67,10 @@ function lastEventText(state, afterSeq, type) {
|
|
|
67
67
|
parts.push(e.text);
|
|
68
68
|
if (type === "error" && e.type === "error")
|
|
69
69
|
parts.push(e.message);
|
|
70
|
+
// A turn the agent ended on a provider failure (usage limit, rate
|
|
71
|
+
// limit) reports no error event — the failure card is the outcome.
|
|
72
|
+
if (type === "error" && e.type === "failure" && e.severity === "error")
|
|
73
|
+
parts.push(e.title);
|
|
70
74
|
}
|
|
71
75
|
const text = parts.join("").trim();
|
|
72
76
|
if (!text)
|
|
@@ -790,14 +794,18 @@ export async function postMessage(id, text, opts = {}) {
|
|
|
790
794
|
// the thread keeps the short form the user typed.
|
|
791
795
|
const body = await expandSkillInvocation(state.meta.scope, text);
|
|
792
796
|
const promptText = context ? `<context>\n${context}\n</context>\n\n${body}` : body;
|
|
793
|
-
const stopReason = await backend.prompt(promptText, promptFiles(id, attachments));
|
|
797
|
+
const { stopReason, failure } = await backend.prompt(promptText, promptFiles(id, attachments));
|
|
794
798
|
emit(state, { type: "turn_end", stopReason });
|
|
799
|
+
// After turn_end, so the card stays live (a turn_end settles the
|
|
800
|
+
// failures reported before it): this one is why the turn ended.
|
|
801
|
+
if (failure)
|
|
802
|
+
emit(state, { type: "failure", ...failure });
|
|
795
803
|
// Routine sessions are one-shot and unattended: release the agent
|
|
796
804
|
// process as soon as the turn ends instead of waiting for the reaper,
|
|
797
805
|
// and tell the bell how it went.
|
|
798
806
|
if (isUnattended(state.meta.scope)) {
|
|
799
807
|
dropBackend(state);
|
|
800
|
-
const failed = state.events.some((e) => e.seq > turnStart && e.type === "error");
|
|
808
|
+
const failed = state.events.some((e) => e.seq > turnStart && (e.type === "error" || (e.type === "failure" && e.severity === "error")));
|
|
801
809
|
void pushNotification({
|
|
802
810
|
kind: failed ? "routine_failed" : "routine_done",
|
|
803
811
|
title: `${chatLabel(state.meta)} ${failed ? "ended with an error" : "finished"}`,
|
|
@@ -923,8 +931,10 @@ async function runSeatTurn(state, channel, seat, hops) {
|
|
|
923
931
|
}
|
|
924
932
|
const prompt = (context ? `<context>\n${context}\n</context>\n\n` : "") +
|
|
925
933
|
(delta ? `New messages in #${channel.slug}:\n\n${delta}` : `You joined #${channel.slug}. Nothing addressed to you yet — reply with one short line saying you are here.`);
|
|
926
|
-
const stopReason = await backend.prompt(prompt);
|
|
934
|
+
const { stopReason, failure } = await backend.prompt(prompt);
|
|
927
935
|
emit(state, { type: "turn_end", stopReason }, me);
|
|
936
|
+
if (failure)
|
|
937
|
+
emit(state, { type: "failure", ...failure }, me);
|
|
928
938
|
// Handover: agents this one @mentioned get its message, within the hop budget.
|
|
929
939
|
const said = seatTextSince(state, start, seat.key);
|
|
930
940
|
const next = mentionTargets(said, channel, seat.key);
|
package/dist/inspector/git.d.ts
CHANGED
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Workspace git sync. Several people run kraftwerk against one git repo, so
|
|
3
|
+
* knowledge, agents, skills, channels and workflows travel between them like
|
|
4
|
+
* any other tracked file. The inspector shows what changed, lets a human pick files and
|
|
5
|
+
* commit them, and pushes on request.
|
|
6
|
+
*
|
|
7
|
+
* Deliberate limits, because this runs git on someone's repo from a web UI
|
|
8
|
+
* that has no authentication of its own:
|
|
9
|
+
*
|
|
10
|
+
* - Only the roots kraftwerk.yml already declares are ever staged, never the
|
|
11
|
+
* whole repo, and never the output directory (runs and chats are noise).
|
|
12
|
+
* - DENY matches (.env, keys) are never staged, and `git add -f` is never
|
|
13
|
+
* called, so .gitignore keeps its meaning.
|
|
14
|
+
* - Reads are scoped too: the diff endpoint only shows a path the status
|
|
15
|
+
* screen lists as syncable, and status never lists what .gitignore hides,
|
|
16
|
+
* so an untracked .env cannot be read through it.
|
|
17
|
+
* - Commit and push are manual. The background timer only fetches, and pulls
|
|
18
|
+
* fast-forward when `autosync: pull` is set, the configured branch is the
|
|
19
|
+
* one checked out, and no tracked file is modified.
|
|
20
|
+
* - A diverged branch is reported, never merged or rebased from here.
|
|
21
|
+
*
|
|
22
|
+
* Every git call passes an argument array, so nothing reaches a shell, and
|
|
23
|
+
* every call is async with a timeout: git runs inside the single-threaded
|
|
24
|
+
* inspector, so a network operation must never block the event loop, and a
|
|
25
|
+
* remote asking for credentials must fail instead of waiting forever on a
|
|
26
|
+
* terminal nobody is watching.
|
|
27
|
+
*/
|
|
28
|
+
/** Files that must never be staged, whatever the scope says. */
|
|
29
|
+
/** Paths whose content is never shown or synced, whichever repo they sit in. */
|
|
30
|
+
export declare const DENY: RegExp[];
|
|
1
31
|
export interface GitFile {
|
|
2
32
|
/** Path relative to the repo root, which is what git speaks. */
|
|
3
33
|
path: string;
|
|
@@ -52,8 +82,20 @@ export declare function git(args: string[], cwd: string, timeoutMs?: number, env
|
|
|
52
82
|
export declare function sshCommandFor(repoRoot: string): Promise<string>;
|
|
53
83
|
/** A git call that may touch the network: longer timeout, non-interactive ssh. */
|
|
54
84
|
export declare function gitNet(args: string[], repoRoot: string): Promise<GitResult>;
|
|
85
|
+
/**
|
|
86
|
+
* Pathspecs are globs by default, so a file called `note[1].md` would not
|
|
87
|
+
* match itself. `:(literal)` turns the pattern off.
|
|
88
|
+
*/
|
|
89
|
+
export declare const literal: (p: string) => string;
|
|
55
90
|
/** Repo root for a directory, or null when it is not inside a git repo. */
|
|
56
91
|
export declare function repoRootFor(dir: string): Promise<string | null>;
|
|
92
|
+
/** Label for a porcelain code, using whichever half is set. */
|
|
93
|
+
export declare function label(code: string): string;
|
|
94
|
+
/** Parse `git status --porcelain=v1 -z`, which NUL-separates and doubles up on renames and copies. */
|
|
95
|
+
export declare function parseStatus(out: string): {
|
|
96
|
+
path: string;
|
|
97
|
+
code: string;
|
|
98
|
+
}[];
|
|
57
99
|
/**
|
|
58
100
|
* Everything the git screen renders. Each call shells out to git a handful
|
|
59
101
|
* of times, and the nav badge polls it from every open tab, so results are
|
|
@@ -68,6 +110,15 @@ export declare function gitStatus(fresh?: boolean): Promise<GitStatus>;
|
|
|
68
110
|
* Status never lists ignored files, which covers .gitignore too.
|
|
69
111
|
*/
|
|
70
112
|
export declare function gitDiff(file: string): Promise<GitDiff>;
|
|
113
|
+
/**
|
|
114
|
+
* Unified diff of one path in a repo against HEAD, capped. The caller has
|
|
115
|
+
* checked that the path is a listed, showable change. A repo without
|
|
116
|
+
* commits yet has nothing for `diff HEAD` to compare with; there, as for an
|
|
117
|
+
* untracked file, the whole file is the change. Shared by the workspace
|
|
118
|
+
* diff and the repositories screen so the two never drift.
|
|
119
|
+
*/
|
|
120
|
+
export declare function diffAgainstHead(repoRoot: string, file: string, code: string): Promise<GitDiff>;
|
|
121
|
+
export declare function capDiff(diff: string): GitDiff;
|
|
71
122
|
/** Stage the given repo-relative paths and commit just those. */
|
|
72
123
|
export declare function gitCommit(paths: string[], message: string): Promise<{
|
|
73
124
|
ok: boolean;
|
package/dist/inspector/git.js
CHANGED
|
@@ -32,7 +32,8 @@ import { getOutputDir, getProjectRoot } from "./context.js";
|
|
|
32
32
|
* terminal nobody is watching.
|
|
33
33
|
*/
|
|
34
34
|
/** Files that must never be staged, whatever the scope says. */
|
|
35
|
-
|
|
35
|
+
/** Paths whose content is never shown or synced, whichever repo they sit in. */
|
|
36
|
+
export const DENY = [
|
|
36
37
|
/(^|\/)\.env($|\.)/,
|
|
37
38
|
/(^|\/)\.envrc$/,
|
|
38
39
|
new RegExp(`(^|/)${ENV_FILE.replace(/\./g, "\\.")}$`),
|
|
@@ -147,7 +148,7 @@ function netArgs(cmd, ...names) {
|
|
|
147
148
|
* Pathspecs are globs by default, so a file called `note[1].md` would not
|
|
148
149
|
* match itself. `:(literal)` turns the pattern off.
|
|
149
150
|
*/
|
|
150
|
-
const literal = (p) => `:(literal)${p}`;
|
|
151
|
+
export const literal = (p) => `:(literal)${p}`;
|
|
151
152
|
/** Repo root for a directory, or null when it is not inside a git repo. */
|
|
152
153
|
export async function repoRootFor(dir) {
|
|
153
154
|
const r = await git(["rev-parse", "--show-toplevel"], dir);
|
|
@@ -286,7 +287,7 @@ async function hasLocalChanges(repoRoot) {
|
|
|
286
287
|
return !(await git(["diff", "--cached", "--quiet"], repoRoot)).ok;
|
|
287
288
|
}
|
|
288
289
|
/** Label for a porcelain code, using whichever half is set. */
|
|
289
|
-
function label(code) {
|
|
290
|
+
export function label(code) {
|
|
290
291
|
if (code === "??")
|
|
291
292
|
return "untracked";
|
|
292
293
|
const c = code.replace(/ /g, "");
|
|
@@ -307,7 +308,7 @@ function label(code) {
|
|
|
307
308
|
return c || "changed";
|
|
308
309
|
}
|
|
309
310
|
/** Parse `git status --porcelain=v1 -z`, which NUL-separates and doubles up on renames and copies. */
|
|
310
|
-
function parseStatus(out) {
|
|
311
|
+
export function parseStatus(out) {
|
|
311
312
|
const parts = out.split("\0").filter((p) => p.length > 0);
|
|
312
313
|
const files = [];
|
|
313
314
|
for (let i = 0; i < parts.length; i++) {
|
|
@@ -450,11 +451,18 @@ export async function gitDiff(file) {
|
|
|
450
451
|
return { diff: "", error: "not shown: not a changed file under the workspace paths" };
|
|
451
452
|
if (!entry.syncable)
|
|
452
453
|
return { diff: "", error: `not shown: ${entry.reason}` };
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
454
|
+
return diffAgainstHead(st.repoRoot, file, entry.code);
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Unified diff of one path in a repo against HEAD, capped. The caller has
|
|
458
|
+
* checked that the path is a listed, showable change. A repo without
|
|
459
|
+
* commits yet has nothing for `diff HEAD` to compare with; there, as for an
|
|
460
|
+
* untracked file, the whole file is the change. Shared by the workspace
|
|
461
|
+
* diff and the repositories screen so the two never drift.
|
|
462
|
+
*/
|
|
463
|
+
export async function diffAgainstHead(repoRoot, file, code) {
|
|
456
464
|
const hasHead = (await git(["rev-parse", "--verify", "-q", "HEAD"], repoRoot)).ok;
|
|
457
|
-
if (
|
|
465
|
+
if (code === "??" || !hasHead) {
|
|
458
466
|
// --no-index exits 1 when the files differ, which is the normal case here.
|
|
459
467
|
const r = await git(["diff", "--no-index", "--", "/dev/null", file], repoRoot);
|
|
460
468
|
return r.stdout ? capDiff(r.stdout) : { diff: "", error: r.stderr || "no diff available" };
|
|
@@ -462,7 +470,7 @@ export async function gitDiff(file) {
|
|
|
462
470
|
const r = await git(["diff", "HEAD", "--", literal(file)], repoRoot);
|
|
463
471
|
return r.ok ? capDiff(r.stdout) : { diff: "", error: r.stderr || "diff failed" };
|
|
464
472
|
}
|
|
465
|
-
function capDiff(diff) {
|
|
473
|
+
export function capDiff(diff) {
|
|
466
474
|
if (diff.length <= MAX_DIFF)
|
|
467
475
|
return { diff };
|
|
468
476
|
const cut = diff.lastIndexOf("\n", MAX_DIFF);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type Project } from "../config.js";
|
|
2
|
+
import { type GitDiff } from "./git.js";
|
|
2
3
|
/**
|
|
3
4
|
* Repositories: the git clones an agent works on, kept under one root
|
|
4
5
|
* inside the project (`repos.root` in kraftwerk.yml, default repos/). The
|
|
@@ -42,6 +43,36 @@ export interface RepoInfo {
|
|
|
42
43
|
/** Set when git could not be read for this folder. */
|
|
43
44
|
error?: string;
|
|
44
45
|
}
|
|
46
|
+
/** One changed path in a clone's working tree. */
|
|
47
|
+
export interface RepoFile {
|
|
48
|
+
path: string;
|
|
49
|
+
/** Two-letter porcelain code. */
|
|
50
|
+
code: string;
|
|
51
|
+
/** modified, added, deleted, renamed, untracked, conflicted. */
|
|
52
|
+
status: string;
|
|
53
|
+
}
|
|
54
|
+
export interface RepoCommit {
|
|
55
|
+
hash: string;
|
|
56
|
+
short: string;
|
|
57
|
+
subject: string;
|
|
58
|
+
author: string;
|
|
59
|
+
committedAt: string;
|
|
60
|
+
/** On no remote yet: the clone's own work. */
|
|
61
|
+
local: boolean;
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* The detail page's view of a clone: the listing's summary plus what is
|
|
65
|
+
* happening inside — changed files, line counts against HEAD, the recent
|
|
66
|
+
* commits with the unpushed ones marked, and the branch's upstream.
|
|
67
|
+
*/
|
|
68
|
+
export interface RepoDetail extends RepoInfo {
|
|
69
|
+
upstream?: string;
|
|
70
|
+
files: RepoFile[];
|
|
71
|
+
commits: RepoCommit[];
|
|
72
|
+
/** Working tree against HEAD, tracked files only. */
|
|
73
|
+
insertions: number;
|
|
74
|
+
deletions: number;
|
|
75
|
+
}
|
|
45
76
|
export interface ReposView {
|
|
46
77
|
enabled: boolean;
|
|
47
78
|
root?: string;
|
|
@@ -79,6 +110,22 @@ export declare function openRepos(): Promise<{
|
|
|
79
110
|
off?: boolean;
|
|
80
111
|
error?: string;
|
|
81
112
|
}>;
|
|
113
|
+
/** What is happening in one clone; null when there is no such clone. */
|
|
114
|
+
export declare function repoDetail(slug: string): Promise<RepoDetail | null>;
|
|
115
|
+
/**
|
|
116
|
+
* Unified diff of one changed file in a clone, against HEAD (the whole
|
|
117
|
+
* file for an untracked one). Same rule as the workspace diff: only a path
|
|
118
|
+
* the status lists is shown, and never a secret — a clone's untracked .env
|
|
119
|
+
* is the file itself.
|
|
120
|
+
*/
|
|
121
|
+
export declare function repoDiff(slug: string, file: string): Promise<GitDiff>;
|
|
122
|
+
/**
|
|
123
|
+
* One commit of a clone as a patch: header, stat, then the diff. The
|
|
124
|
+
* commit's own path list decides what is shown: a denied path (a committed
|
|
125
|
+
* key, a renamed-in .env) is left out of both the stat and the patch by
|
|
126
|
+
* pathspec, so no header shape git emits can smuggle it through.
|
|
127
|
+
*/
|
|
128
|
+
export declare function repoCommitDiff(slug: string, hash: string): Promise<GitDiff>;
|
|
82
129
|
/** Every clone under the root, alphabetically. Folders without .git are skipped. */
|
|
83
130
|
export declare function listRepos(): Promise<ReposView>;
|
|
84
131
|
/**
|
package/dist/inspector/repos.js
CHANGED
|
@@ -3,7 +3,7 @@ import path from "node:path";
|
|
|
3
3
|
import { gitignoreHas, ignoreEntryFor, reposRootFor, resolveProject } from "../config.js";
|
|
4
4
|
import { getProjectRoot } from "./context.js";
|
|
5
5
|
import { newestMtime } from "./mtime.js";
|
|
6
|
-
import { git, gitNet, sshCommandFor } from "./git.js";
|
|
6
|
+
import { capDiff, DENY, diffAgainstHead, git, gitNet, label, literal, parseStatus, sshCommandFor } from "./git.js";
|
|
7
7
|
/**
|
|
8
8
|
* A clone is the one call here that can legitimately take long: a large
|
|
9
9
|
* repository on a slow link is minutes, not seconds. Prompts are already
|
|
@@ -63,7 +63,8 @@ async function inspect(root, slug) {
|
|
|
63
63
|
const dir = path.join(root, slug);
|
|
64
64
|
const info = { slug, path: dir, dirty: 0 };
|
|
65
65
|
const [status, log, origin, newest] = await Promise.all([
|
|
66
|
-
|
|
66
|
+
// Every untracked file counts, so the listing's "N changed" and the page's file list agree.
|
|
67
|
+
git(["status", "--porcelain=v2", "--branch", "--untracked-files=all"], dir),
|
|
67
68
|
git(["log", "-1", "--format=%h%x00%s%x00%cI"], dir),
|
|
68
69
|
git(["remote", "get-url", "origin"], dir),
|
|
69
70
|
newestMtime(dir),
|
|
@@ -111,6 +112,124 @@ async function inspect(root, slug) {
|
|
|
111
112
|
}
|
|
112
113
|
return info;
|
|
113
114
|
}
|
|
115
|
+
const COMMITS = 40;
|
|
116
|
+
async function cloneDir(slug) {
|
|
117
|
+
safeRepoSlug(slug);
|
|
118
|
+
const opened = await openRepos();
|
|
119
|
+
if (!opened.root)
|
|
120
|
+
throw new Error(opened.error ?? "repositories are off");
|
|
121
|
+
const dir = path.join(opened.root, slug);
|
|
122
|
+
if (!(await fs.stat(path.join(dir, ".git")).catch(() => null)))
|
|
123
|
+
throw new Error(`no repository "${slug}"`);
|
|
124
|
+
return { root: opened.root, dir };
|
|
125
|
+
}
|
|
126
|
+
/** What is happening in one clone; null when there is no such clone. */
|
|
127
|
+
export async function repoDetail(slug) {
|
|
128
|
+
let root;
|
|
129
|
+
let dir;
|
|
130
|
+
try {
|
|
131
|
+
({ root, dir } = await cloneDir(slug));
|
|
132
|
+
}
|
|
133
|
+
catch (err) {
|
|
134
|
+
if (/^no repository/.test(err.message))
|
|
135
|
+
return null;
|
|
136
|
+
throw err;
|
|
137
|
+
}
|
|
138
|
+
const info = await inspect(root, slug);
|
|
139
|
+
const detail = { ...info, files: [], commits: [], insertions: 0, deletions: 0 };
|
|
140
|
+
if (info.error)
|
|
141
|
+
return detail;
|
|
142
|
+
const [status, log, local, stat, upstream] = await Promise.all([
|
|
143
|
+
git(["status", "--porcelain=v1", "-z", "--untracked-files=all"], dir),
|
|
144
|
+
git(["log", `-${COMMITS}`, "--format=%H%x00%h%x00%s%x00%an%x00%cI"], dir),
|
|
145
|
+
// Bounded like the log: the local commits among the newest N are a prefix of the first N non-remote ones.
|
|
146
|
+
git(["rev-list", `-n`, String(COMMITS), "HEAD", "--not", "--remotes"], dir),
|
|
147
|
+
git(["diff", "HEAD", "--shortstat"], dir),
|
|
148
|
+
git(["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], dir),
|
|
149
|
+
]);
|
|
150
|
+
if (status.ok)
|
|
151
|
+
detail.files = parseStatus(status.stdout).map((f) => ({ path: f.path, code: f.code, status: label(f.code) }));
|
|
152
|
+
if (log.ok) {
|
|
153
|
+
const localSet = new Set(local.ok ? local.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : []);
|
|
154
|
+
detail.commits = log.stdout
|
|
155
|
+
.split("\n")
|
|
156
|
+
.filter((l) => l.trim())
|
|
157
|
+
.map((l) => {
|
|
158
|
+
const [hash, short, subject, author, committedAt] = l.split("\0");
|
|
159
|
+
return { hash, short, subject, author, committedAt, local: localSet.has(hash) };
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (stat.ok) {
|
|
163
|
+
const ins = /(\d+) insertion/.exec(stat.stdout);
|
|
164
|
+
const del = /(\d+) deletion/.exec(stat.stdout);
|
|
165
|
+
detail.insertions = ins ? Number(ins[1]) : 0;
|
|
166
|
+
detail.deletions = del ? Number(del[1]) : 0;
|
|
167
|
+
}
|
|
168
|
+
if (upstream.ok && upstream.stdout.trim())
|
|
169
|
+
detail.upstream = upstream.stdout.trim();
|
|
170
|
+
return detail;
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Unified diff of one changed file in a clone, against HEAD (the whole
|
|
174
|
+
* file for an untracked one). Same rule as the workspace diff: only a path
|
|
175
|
+
* the status lists is shown, and never a secret — a clone's untracked .env
|
|
176
|
+
* is the file itself.
|
|
177
|
+
*/
|
|
178
|
+
export async function repoDiff(slug, file) {
|
|
179
|
+
if (!file || file.includes("\0") || path.isAbsolute(file) || file.split("/").includes(".."))
|
|
180
|
+
return { diff: "", error: "invalid path" };
|
|
181
|
+
const { dir } = await cloneDir(slug);
|
|
182
|
+
if (DENY.some((re) => re.test(file)))
|
|
183
|
+
return { diff: "", error: "not shown: secret or key" };
|
|
184
|
+
const status = await git(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", literal(file)], dir);
|
|
185
|
+
const entry = status.ok ? parseStatus(status.stdout).find((f) => f.path === file) : undefined;
|
|
186
|
+
if (!entry)
|
|
187
|
+
return { diff: "", error: "not shown: not a changed file in this repository" };
|
|
188
|
+
return diffAgainstHead(dir, file, entry.code);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* One commit of a clone as a patch: header, stat, then the diff. The
|
|
192
|
+
* commit's own path list decides what is shown: a denied path (a committed
|
|
193
|
+
* key, a renamed-in .env) is left out of both the stat and the patch by
|
|
194
|
+
* pathspec, so no header shape git emits can smuggle it through.
|
|
195
|
+
*/
|
|
196
|
+
export async function repoCommitDiff(slug, hash) {
|
|
197
|
+
if (!/^[0-9a-f]{7,40}$/.test(hash))
|
|
198
|
+
return { diff: "", error: "invalid commit" };
|
|
199
|
+
const { dir } = await cloneDir(slug);
|
|
200
|
+
if (!(await git(["rev-parse", "--verify", "-q", `${hash}^{commit}`], dir)).ok)
|
|
201
|
+
return { diff: "", error: "no such commit" };
|
|
202
|
+
// Post-image paths of the commit: "M\0path\0", renames and copies as "R100\0old\0new\0".
|
|
203
|
+
const tree = await git(["diff-tree", "-r", "-M", "-z", "--root", "--name-status", hash], dir);
|
|
204
|
+
if (!tree.ok)
|
|
205
|
+
return { diff: "", error: tree.stderr || "git diff-tree failed" };
|
|
206
|
+
const parts = tree.stdout.split("\0");
|
|
207
|
+
const paths = [];
|
|
208
|
+
for (let i = 0; i < parts.length; i++) {
|
|
209
|
+
const status = parts[i];
|
|
210
|
+
if (!status || /^[0-9a-f]{40}$/.test(status))
|
|
211
|
+
continue; // the leading hash line of --root
|
|
212
|
+
const two = /^[RC]/.test(status);
|
|
213
|
+
const file = two ? parts[i + 2] : parts[i + 1];
|
|
214
|
+
if (file)
|
|
215
|
+
paths.push(file);
|
|
216
|
+
i += two ? 2 : 1;
|
|
217
|
+
}
|
|
218
|
+
const kept = paths.filter((f) => !DENY.some((re) => re.test(f)));
|
|
219
|
+
const hidden = paths.length - kept.length;
|
|
220
|
+
const format = "--format=commit %H%nAuthor: %an <%ae>%nDate: %cI%n%n %s%n";
|
|
221
|
+
if (kept.length === 0 && paths.length > 0) {
|
|
222
|
+
const head = await git(["show", "--no-patch", format, hash], dir);
|
|
223
|
+
return { diff: `${head.stdout}\n(${hidden} file${hidden === 1 ? "" : "s"} not shown: secret or key)\n` };
|
|
224
|
+
}
|
|
225
|
+
const r = await git(["show", "--stat", "--patch", format, hash, "--", ...kept.map(literal)], dir);
|
|
226
|
+
if (!r.ok)
|
|
227
|
+
return { diff: "", error: r.stderr || "git show failed" };
|
|
228
|
+
const out = capDiff(r.stdout);
|
|
229
|
+
if (hidden > 0)
|
|
230
|
+
out.diff += `\n(${hidden} file${hidden === 1 ? "" : "s"} not shown: secret or key)\n`;
|
|
231
|
+
return out;
|
|
232
|
+
}
|
|
114
233
|
/** Every clone under the root, alphabetically. Folders without .git are skipped. */
|
|
115
234
|
export async function listRepos() {
|
|
116
235
|
const opened = await openRepos();
|
package/dist/inspector/server.js
CHANGED
|
@@ -19,7 +19,7 @@ import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, sav
|
|
|
19
19
|
import { discoverWorkspaces, forgetWorkspace, listWorkspacesDetailed, markWorkspaceStopped, registerInstance, registerWorkspace, startWorkspace, stopWorkspace, tildify, unregisterInstance, } from "./instances.js";
|
|
20
20
|
import { gitCommit, gitDiff, gitFetch, gitPull, gitPush, gitStatus, startGitSync, } from "./git.js";
|
|
21
21
|
import { getSettings, saveSettings } from "./settings.js";
|
|
22
|
-
import { addRepo, listRepos, openRepos, removeRepo, updateRepo } from "./repos.js";
|
|
22
|
+
import { addRepo, listRepos, openRepos, removeRepo, repoCommitDiff, repoDetail, repoDiff, updateRepo, } from "./repos.js";
|
|
23
23
|
import { createVibeable, deleteVibeable, disposeAllDevs, listVibeables, openVibeables, proxyUpgrade, resolveVibeable, serveVibeable, startDev, stopDev, subscribeVibeable, vibeableStatus, } from "./vibeables.js";
|
|
24
24
|
import { searchAgents } from "./search.js";
|
|
25
25
|
import { appendProjectLog, createProject, deleteProject, getProject, linkProject, listProjects, openProjects, saveProject, } from "./projects.js";
|
|
@@ -583,6 +583,43 @@ async function handleApi(req, res, url) {
|
|
|
583
583
|
return json(res, body, status);
|
|
584
584
|
}
|
|
585
585
|
}
|
|
586
|
+
const repoError = (err) => {
|
|
587
|
+
const msg = err.message;
|
|
588
|
+
return [/^no repository/.test(msg) ? 404 : /are off/.test(msg) ? 409 : 400, { error: msg }];
|
|
589
|
+
};
|
|
590
|
+
// GET /api/repos/<slug> — what is happening in the clone: changed files, line counts, recent commits (unpushed marked)
|
|
591
|
+
if (seg.length === 3 && seg[1] === "repos" && method === "GET") {
|
|
592
|
+
try {
|
|
593
|
+
const detail = await repoDetail(seg[2]);
|
|
594
|
+
return detail ? json(res, detail) : json(res, { error: `no repository "${seg[2]}"` }, 404);
|
|
595
|
+
}
|
|
596
|
+
catch (err) {
|
|
597
|
+
const [status, body] = repoError(err);
|
|
598
|
+
return json(res, body, status);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
// GET /api/repos/<slug>/diff?path= — unified diff of one changed file against HEAD
|
|
602
|
+
if (seg.length === 4 && seg[1] === "repos" && seg[3] === "diff" && method === "GET") {
|
|
603
|
+
try {
|
|
604
|
+
const d = await repoDiff(seg[2], url.searchParams.get("path") ?? "");
|
|
605
|
+
return json(res, d, d.error ? (/^not shown/.test(d.error) ? 404 : 400) : 200);
|
|
606
|
+
}
|
|
607
|
+
catch (err) {
|
|
608
|
+
const [status, body] = repoError(err);
|
|
609
|
+
return json(res, body, status);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
// GET /api/repos/<slug>/commits/<hash> — one commit as a patch
|
|
613
|
+
if (seg.length === 5 && seg[1] === "repos" && seg[3] === "commits" && method === "GET") {
|
|
614
|
+
try {
|
|
615
|
+
const d = await repoCommitDiff(seg[2], seg[4]);
|
|
616
|
+
return json(res, d, d.error ? (/^no such commit/.test(d.error) ? 404 : 400) : 200);
|
|
617
|
+
}
|
|
618
|
+
catch (err) {
|
|
619
|
+
const [status, body] = repoError(err);
|
|
620
|
+
return json(res, body, status);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
586
623
|
// POST /api/repos/<slug>/update — fetch, fast-forward when clean
|
|
587
624
|
if (seg.length === 4 && seg[1] === "repos" && seg[3] === "update" && method === "POST") {
|
|
588
625
|
try {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
import{a as e}from"./editor-B5WUiag9.js";export{e as markdown};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
import{L as e,R as t,Ut as n,i as r,r as i,s as a,u as o}from"./dist-H3rerbMg.js";import{n as s}from"./dist-
|
|
1
|
+
import{L as e,R as t,Ut as n,i as r,r as i,s as a,u as o}from"./dist-H3rerbMg.js";import{n as s}from"./dist-Ca8OxgWP.js";import{r as c}from"./dist-DkEzjj-j.js";var l=i.deserialize({version:14,states:"%pOVOWOOObQPOOOpOSO'#C_OOOO'#Cp'#CpQVOWOOQxQPOOO!TQQOOQ!YQPOOOOOO,58y,58yO!_OSO,58yOOOO-E6n-E6nO!dQQO'#CqQ{QPOOO!iQPOOQ{QPOOO!qQPOOOOOO1G.e1G.eOOQO,59],59]OOQO-E6o-E6oO!yOpO'#CiO#RO`O'#CiQOQPOOO#ZO#tO'#CmO#fO!bO'#CmOOQO,59T,59TO#qOpO,59TO#vO`O,59TOOOO'#Cr'#CrO#{O#tO,59XOOQO,59X,59XOOOO'#Cs'#CsO$WO!bO,59XOOQO1G.o1G.oOOOO-E6p-E6pOOQO1G.s1G.sOOOO-E6q-E6q",stateData:"$g~OjOS~OQROUROkQO~OWTOXUOZUO`VO~OSXOTWO~OXUO[]OlZO~OY^O~O[_O~OT`O~OYaO~OmcOodO~OmfOogO~O^iOnhO~O_jOphO~ObkOqkOrmO~OcnOsnOtmO~OnpO~OppO~ObkOqkOrrO~OcnOsnOtrO~OWX`~",goto:`!^hPPPiPPPPPPPPPmPPPpPPsy!Q!WTROSRe]Re_QSORYSS[T^Rb[QlfRqlQogRso`,nodeNames:`⚠ Content Text Interpolation InterpolationContent }} Entity Attribute VueAttributeName : Identifier @ Is ScriptAttributeValue AttributeScript AttributeScript AttributeName AttributeValue Entity Entity`,maxTerm:36,nodeProps:[[`isolate`,-3,3,13,17,``]],skippedNodes:[0],repeatNodeCount:4,tokenData:"'y~RdXY!aYZ!a]^!apq!ars!rwx!w}!O!|!O!P#t!Q![#y![!]$s!_!`%g!b!c%l!c!}#y#R#S#y#T#j#y#j#k%q#k#o#y%W;'S#y;'S;:j$m<%lO#y~!fSj~XY!aYZ!a]^!apq!a~!wOm~~!|Oo~!b#RX`!b}!O!|!Q![!|![!]!|!c!}!|#R#S!|#T#o!|%W;'S!|;'S;:j#n<%lO!|!b#qP;=`<%l!|~#yOl~%W$QXY#t`!b}!O!|!Q![#y![!]!|!c!}#y#R#S#y#T#o#y%W;'S#y;'S;:j$m<%lO#y%W$pP;=`<%l#y~$zXX~`!b}!O!|!Q![!|![!]!|!c!}!|#R#S!|#T#o!|%W;'S!|;'S;:j#n<%lO!|~%lO[~~%qOZ~%W%xXY#t`!b}!O&e!Q![#y![!]!|!c!}#y#R#S#y#T#o#y%W;'S#y;'S;:j$m<%lO#y!b&jX`!b}!O!|!Q![!|![!]!|!c!}'V#R#S!|#T#o'V%W;'S!|;'S;:j#n<%lO!|!b'^XW!b`!b}!O!|!Q![!|![!]!|!c!}'V#R#S!|#T#o'V%W;'S!|;'S;:j#n<%lO!|",tokenizers:[6,7,new r(`b~RP#q#rU~XP#q#r[~aOT~~`,17,4),new r("!k~RQvwX#o#p!_~^TU~Opmq!]m!^;'Sm;'S;=`!X<%lOm~pUOpmq!]m!]!^!S!^;'Sm;'S;=`!X<%lOm~!XOU~~![P;=`<%lm~!bP#o#p!e~!jOk~~",72,2),new r(`[~RPwxU~ZOp~~`,11,15),new r(`[~RPrsU~ZOn~~`,11,14),new r("!e~RQvwXwx!_~^Tc~Opmq!]m!^;'Sm;'S;=`!X<%lOm~pUOpmq!]m!]!^!S!^;'Sm;'S;=`!X<%lOm~!XOc~~![P;=`<%lm~!dOt~~",66,35),new r("!e~RQrsXvw^~^Or~~cTb~Oprq!]r!^;'Sr;'S;=`!^<%lOr~uUOprq!]r!]!^!X!^;'Sr;'S;=`!^<%lOr~!^Ob~~!aP;=`<%lr~",66,33)],topRules:{Content:[0,1],Attribute:[1,7]},tokenPrec:157}),u=c.parser.configure({top:`SingleExpression`}),d=l.configure({props:[e({Text:t.content,Is:t.definitionOperator,AttributeName:t.attributeName,VueAttributeName:t.keyword,Identifier:t.variableName,"AttributeValue ScriptAttributeValue":t.attributeValue,Entity:t.character,"{{ }}":t.brace,"@ :":t.punctuation})]}),f={parser:u},p=d.configure({wrap:n((e,t)=>e.name==`InterpolationContent`?f:null)}),m=d.configure({wrap:n((e,t)=>e.name==`AttributeScript`?f:null),top:`Attribute`}),h={parser:p},g={parser:m},_=s();function v(e){return e.configure({dialect:`selfClosing`,wrap:n(b)},`vue`)}var y=v(_.language);function b(e,t){switch(e.name){case`Attribute`:return/^(@|:|v-)/.test(t.read(e.from,e.from+2))?g:null;case`Text`:return h}return null}function x(e={}){let t=_;if(e.base){if(e.base.language.name!=`html`||!(e.base.language instanceof a))throw RangeError(`The base option must be the result of calling html(...)`);t=e.base}return new o(t.language==_.language?y:v(t.language),[t.support,t.language.data.of({closeBrackets:{brackets:[`{`,`"`]}})])}export{x as vue};
|