@evandu/super-pi 0.1.128 → 0.1.129
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/agents/super-pi-knowledge/index.ts +4 -3
- package/agents/super-pi-memory/index.ts +5 -4
- package/agents/super-pi-task/core.ts +8 -7
- package/dist/.super-pi-check-inputs.json +2 -2
- package/dist/client/.super-pi-client-sources.json +5 -5
- package/dist/client/assets/{CodeMirrorEditor-BhxCTVTL.js → CodeMirrorEditor-BNVBZZSS.js} +2 -2
- package/dist/client/assets/{dist-DERv1ty2.js → dist-B2nPVdKh.js} +1 -1
- package/dist/client/assets/{dist-BAmT9jUI.js → dist-B61RSa-K.js} +1 -1
- package/dist/client/assets/{dist-BPBlgusB.js → dist-BBW2SE_R.js} +1 -1
- package/dist/client/assets/{dist-BQK4xccN.js → dist-BDygQgZD.js} +1 -1
- package/dist/client/assets/{dist-C-hIztMW.js → dist-BKp4AWEJ.js} +1 -1
- package/dist/client/assets/{dist-Cl87QYqE.js → dist-BhJGK8-b.js} +1 -1
- package/dist/client/assets/{dist-Bplw4N5j.js → dist-BykVFExd.js} +1 -1
- package/dist/client/assets/{dist-K96Y1Tut.js → dist-C90MPTnk.js} +1 -1
- package/dist/client/assets/{dist-DVBpWkDV.js → dist-CMnvChDX.js} +1 -1
- package/dist/client/assets/{dist-BfwwrK33.js → dist-CRzRB9k3.js} +1 -1
- package/dist/client/assets/{dist-BeYwU1xB.js → dist-CWYRjnj-.js} +1 -1
- package/dist/client/assets/{dist-AtHUPHQe.js → dist-CZ9I2JCd.js} +1 -1
- package/dist/client/assets/{dist-8-xcbQPp.js → dist-ChszWGDv.js} +1 -1
- package/dist/client/assets/{dist-Bof_dYLf.js → dist-CiSGSVBZ.js} +1 -1
- package/dist/client/assets/{dist-Dc6xSTJL.js → dist-Cxerc7Bb.js} +1 -1
- package/dist/client/assets/{dist-DobX-p1U.js → dist-DB5M5fZG.js} +1 -1
- package/dist/client/assets/{dist-YUFYdtGJ.js → dist-DZnUERoW.js} +1 -1
- package/dist/client/assets/{dist-BJZUzzzf.js → dist-DmAOqr0M.js} +1 -1
- package/dist/client/assets/{dist-Bw8QOdGP.js → dist-Dq4-QFAJ.js} +1 -1
- package/dist/client/assets/{dist-icIMVISS.js → dist-GkIV1LTX.js} +1 -1
- package/dist/client/assets/{dist-DTXHzd8a.js → dist-HdeIBl2J.js} +1 -1
- package/dist/client/assets/{dist-5LsaarOt.js → dist-Ur37340K.js} +1 -1
- package/dist/client/assets/index-CXB6U5mz.css +1 -0
- package/dist/client/assets/{index-BvQ3umhR.js → index-D_c-CYAr.js} +35 -37
- package/dist/client/index.html +2 -2
- package/dist/server/active-memory.d.ts +8 -3
- package/dist/server/active-memory.js +44 -1
- package/dist/server/application/run-service.js +10 -1
- package/dist/server/git.d.ts +87 -0
- package/dist/server/git.js +301 -2
- package/dist/server/http-server.js +39 -7
- package/dist/server/pi-session.d.ts +9 -0
- package/dist/server/pi-session.js +12 -3
- package/dist/server/runtime.d.ts +12 -0
- package/dist/server/runtime.js +6 -0
- package/dist/server/store.js +16 -5
- package/dist/server/ws-server.js +3 -0
- package/package.json +2 -1
- package/scripts/diagnose.mjs +8 -3
- package/dist/client/assets/index-B8GInEf1.css +0 -1
package/dist/client/index.html
CHANGED
|
@@ -8,8 +8,8 @@
|
|
|
8
8
|
<link rel="alternate icon" href="/favicon.ico" />
|
|
9
9
|
<link rel="apple-touch-icon" href="/favicon.svg" />
|
|
10
10
|
<title>Super Pi</title>
|
|
11
|
-
<script type="module" crossorigin src="/assets/index-
|
|
12
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
11
|
+
<script type="module" crossorigin src="/assets/index-D_c-CYAr.js"></script>
|
|
12
|
+
<link rel="stylesheet" crossorigin href="/assets/index-CXB6U5mz.css">
|
|
13
13
|
</head>
|
|
14
14
|
<body>
|
|
15
15
|
<div id="root"></div>
|
|
@@ -24,6 +24,9 @@ export declare class ActiveMemory {
|
|
|
24
24
|
readonly directory: string;
|
|
25
25
|
private store;
|
|
26
26
|
private runtime;
|
|
27
|
+
private readonly inverted;
|
|
28
|
+
private readonly indexedWorkspaces;
|
|
29
|
+
private readonly indexedMessages;
|
|
27
30
|
readonly notes: MemoryStore;
|
|
28
31
|
private capabilities;
|
|
29
32
|
private reviews;
|
|
@@ -62,10 +65,12 @@ export declare class ActiveMemory {
|
|
|
62
65
|
conversationId: string;
|
|
63
66
|
title: string;
|
|
64
67
|
messageId: string;
|
|
65
|
-
role:
|
|
68
|
+
role: string;
|
|
66
69
|
score: number;
|
|
67
70
|
content: string;
|
|
68
71
|
}[];
|
|
72
|
+
private ensureIndex;
|
|
73
|
+
private addToIndex;
|
|
69
74
|
tool(token: string, body: {
|
|
70
75
|
kind?: string;
|
|
71
76
|
query?: string;
|
|
@@ -77,7 +82,7 @@ export declare class ActiveMemory {
|
|
|
77
82
|
conversationId: string;
|
|
78
83
|
title: string;
|
|
79
84
|
messageId: string;
|
|
80
|
-
role:
|
|
85
|
+
role: string;
|
|
81
86
|
score: number;
|
|
82
87
|
content: string;
|
|
83
88
|
}[] | import("./memory.js").MemoryEntry | {
|
|
@@ -91,7 +96,7 @@ export declare class ActiveMemory {
|
|
|
91
96
|
conversationId: string;
|
|
92
97
|
title: string;
|
|
93
98
|
messageId: string;
|
|
94
|
-
role:
|
|
99
|
+
role: string;
|
|
95
100
|
score: number;
|
|
96
101
|
content: string;
|
|
97
102
|
}[];
|
|
@@ -6,6 +6,9 @@ export class ActiveMemory {
|
|
|
6
6
|
directory;
|
|
7
7
|
store;
|
|
8
8
|
runtime;
|
|
9
|
+
inverted = new Map();
|
|
10
|
+
indexedWorkspaces = new Set();
|
|
11
|
+
indexedMessages = new Set();
|
|
9
12
|
notes;
|
|
10
13
|
capabilities = new Map();
|
|
11
14
|
reviews = new Map();
|
|
@@ -105,7 +108,44 @@ export class ActiveMemory {
|
|
|
105
108
|
const words = query.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu)?.slice(0, 12) ?? [];
|
|
106
109
|
if (!words.length)
|
|
107
110
|
return [];
|
|
108
|
-
|
|
111
|
+
this.ensureIndex(workspaceId);
|
|
112
|
+
const candidates = new Map();
|
|
113
|
+
for (const word of words)
|
|
114
|
+
for (const [id, value] of this.inverted.get(workspaceId)?.get(word) ?? [])
|
|
115
|
+
void id, candidates.set(value.messageId, value);
|
|
116
|
+
return [...candidates.values()].filter(m => !sessionId || m.conversationId === sessionId).map(m => ({ ...m, score: words.reduce((n, word) => n + (m.content.toLocaleLowerCase().includes(word) ? 1 : 0), 0) })).filter(m => m.score > 0).sort((a, b) => b.score - a.score).slice(0, 6).map(m => ({ ...m, content: m.content.slice(0, 1200) }));
|
|
117
|
+
}
|
|
118
|
+
ensureIndex(workspaceId) {
|
|
119
|
+
if (this.indexedWorkspaces.has(workspaceId))
|
|
120
|
+
return;
|
|
121
|
+
const index = new Map();
|
|
122
|
+
for (const conversation of this.store.snapshot().conversations.filter(c => c.workspaceId === workspaceId))
|
|
123
|
+
for (const message of conversation.messages) {
|
|
124
|
+
if (message.role !== "user" && message.role !== "assistant")
|
|
125
|
+
continue;
|
|
126
|
+
const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
|
|
127
|
+
this.indexedMessages.add(message.id);
|
|
128
|
+
for (const word of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
|
|
129
|
+
let bucket = index.get(word);
|
|
130
|
+
if (!bucket)
|
|
131
|
+
index.set(word, bucket = new Map());
|
|
132
|
+
bucket.set(message.id, value);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
this.inverted.set(workspaceId, index);
|
|
136
|
+
this.indexedWorkspaces.add(workspaceId);
|
|
137
|
+
}
|
|
138
|
+
addToIndex(conversation, message) {
|
|
139
|
+
if (!this.indexedWorkspaces.has(conversation.workspaceId) || (message.role !== "user" && message.role !== "assistant"))
|
|
140
|
+
return;
|
|
141
|
+
let terms = this.inverted.get(conversation.workspaceId);
|
|
142
|
+
const value = { conversationId: conversation.id, title: conversation.title, messageId: message.id, role: message.role, content: message.content };
|
|
143
|
+
for (const term of message.content.toLocaleLowerCase().match(/[\p{L}\p{N}_-]{2,}/gu) ?? []) {
|
|
144
|
+
let bucket = terms.get(term);
|
|
145
|
+
if (!bucket)
|
|
146
|
+
terms.set(term, bucket = new Map());
|
|
147
|
+
bucket.set(message.id, value);
|
|
148
|
+
}
|
|
109
149
|
}
|
|
110
150
|
async tool(token, body) {
|
|
111
151
|
const identity = this.identity(token);
|
|
@@ -202,6 +242,9 @@ export class ActiveMemory {
|
|
|
202
242
|
this.cancel(id); }
|
|
203
243
|
async waitForIdle() { await Promise.allSettled([...this.workspaceReviews.values()]); await this.serial; }
|
|
204
244
|
async completed(conversation) {
|
|
245
|
+
for (const message of conversation.messages)
|
|
246
|
+
if (!this.indexedMessages.has(message.id))
|
|
247
|
+
this.addToIndex(conversation, message);
|
|
205
248
|
const settings = await this.configuration();
|
|
206
249
|
if (!settings.enabled || !settings.reviewInterval)
|
|
207
250
|
return;
|
|
@@ -239,7 +239,16 @@ export class RunService {
|
|
|
239
239
|
// processes the result" contract, and the auto-triggered turn goes through the same
|
|
240
240
|
// `composePrompt` path the success case does (so a helper failure surfaces as `state="error"`
|
|
241
241
|
// prose the agent can react to).
|
|
242
|
-
|
|
242
|
+
const custom = this.runtime.sendCustomMessage;
|
|
243
|
+
if (custom) {
|
|
244
|
+
await custom.call(this.runtime, task.conversationId, {
|
|
245
|
+
customType: "super-pi-task-result",
|
|
246
|
+
content: body,
|
|
247
|
+
display: "后台任务结果",
|
|
248
|
+
details: { taskId: sessionId, source: "exec", state: exitCode === 0 ? "completed" : "error", summary: task.title },
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
else if (!this.activeRuns.has(task.conversationId) && !this.interruptTargets.has(task.conversationId)) {
|
|
243
252
|
void this.processWaitingMessage(task.conversationId, queued.id).catch((error) => {
|
|
244
253
|
console.warn(`Super Pi: auto-trigger for background exec result failed: ${error instanceof Error ? error.message : "unknown error"}`);
|
|
245
254
|
});
|
package/dist/server/git.d.ts
CHANGED
|
@@ -50,5 +50,92 @@ export declare function gitDiff(root: string, path: string, staged: boolean): Pr
|
|
|
50
50
|
* version to compare against" - and the third is a refusal, which the caller reports.
|
|
51
51
|
*/
|
|
52
52
|
export declare function gitHeadContent(root: string, path: string): Promise<string | null>;
|
|
53
|
+
/**
|
|
54
|
+
* A file larger than this is refused for blame (`too_large`) rather than blamed. Blame's cost scales
|
|
55
|
+
* with a text file's LINE count, and a file this size can carry millions of them, so the bound is
|
|
56
|
+
* checked from `stat` (working tree) or `git cat-file -s` (a revision) BEFORE any content is read -
|
|
57
|
+
* an oversized file costs one stat, never a buffer.
|
|
58
|
+
*/
|
|
59
|
+
export declare const MAX_BLAME_FILE_BYTES: number;
|
|
60
|
+
/**
|
|
61
|
+
* The most lines one blame response may carry. `--line-porcelain` emits roughly 300-500 bytes of
|
|
62
|
+
* metadata per line, so this cap bounds the response and the parse to single-digit megabytes. A
|
|
63
|
+
* whole-file request for a longer file is answered with the FIRST `MAX_BLAME_LINES` entries and
|
|
64
|
+
* `truncated: true`; a caller that wants more asks for an explicit `startLine`/`endLine` window -
|
|
65
|
+
* which is what the gutter does anyway, and a window may not exceed this cap either.
|
|
66
|
+
*/
|
|
67
|
+
export declare const MAX_BLAME_LINES = 20000;
|
|
68
|
+
export type GitBlameLine = {
|
|
69
|
+
/** 1-based line number in the file AS BLAMED - the working tree by default, or the requested revision. */
|
|
70
|
+
line: number;
|
|
71
|
+
commit: string | null;
|
|
72
|
+
shortCommit: string | null;
|
|
73
|
+
author: string | null;
|
|
74
|
+
authorEmail: string | null;
|
|
75
|
+
/** Author time as an ISO instant, or `null` when git did not supply a parseable one. */
|
|
76
|
+
date: string | null;
|
|
77
|
+
/** First line of the commit message. */
|
|
78
|
+
summary: string | null;
|
|
79
|
+
/** The line differs from the blame anchor and exists only in the working tree - git reported the
|
|
80
|
+
* all-zero commit for it. Such lines carry `null` for every other field: there is no commit to
|
|
81
|
+
* name, and inventing "Not Committed Yet" placeholders would put git's English prose into a
|
|
82
|
+
* structured contract the client is meant to phrase in its own words. */
|
|
83
|
+
isUncommitted: boolean;
|
|
84
|
+
};
|
|
85
|
+
/** Why blame is not available for a file that otherwise exists. Reported as `supported: false` inside
|
|
86
|
+
* a 200, NOT thrown: these are facts about the file or repository a gutter renders as a state word,
|
|
87
|
+
* not request errors. (`not_a_repo` is deliberately NOT one of these - it is thrown, so it maps to
|
|
88
|
+
* the same 404 the other /api/git ops return, per the endpoint's one shared error vocabulary.) */
|
|
89
|
+
export type GitBlameUnsupportedReason = "untracked" | "binary" | "too_large" | "no_head" | "not_in_revision";
|
|
90
|
+
export type GitBlameResponse = {
|
|
91
|
+
/** Workspace-relative path, always `/`-separated: the canonical form of what was requested, not an
|
|
92
|
+
* echo of it - `sub\inner.txt` and `sub/../sub/inner.txt` both come back as `sub/inner.txt`. */
|
|
93
|
+
path: string;
|
|
94
|
+
/** The revision blame was anchored to: the requested one, or `"HEAD"` for the default - which blames
|
|
95
|
+
* the WORKING TREE, which is why uncommitted lines can appear (flagged `isUncommitted`). */
|
|
96
|
+
revision: string;
|
|
97
|
+
lines: GitBlameLine[];
|
|
98
|
+
/** Lines in the file as blamed, including lines outside a requested window. */
|
|
99
|
+
totalLines: number;
|
|
100
|
+
/** True when a WHOLE-FILE request was cut at `MAX_BLAME_LINES`. A windowed request is never
|
|
101
|
+
* `truncated`: the caller asked for exactly what it got. */
|
|
102
|
+
truncated: boolean;
|
|
103
|
+
supported: boolean;
|
|
104
|
+
reason?: GitBlameUnsupportedReason;
|
|
105
|
+
};
|
|
106
|
+
export type GitBlameOptions = {
|
|
107
|
+
/** A commit-ish to blame instead of the working tree. Resolved through `rev-parse --verify` before
|
|
108
|
+
* anything else uses it; a revision git cannot resolve is a `bad_revision` error. */
|
|
109
|
+
revision?: string;
|
|
110
|
+
/** 1-based inclusive window. Both or neither; positive integers; start <= end; end <= totalLines;
|
|
111
|
+
* the span may not exceed `MAX_BLAME_LINES`. Violations throw `bad_range` with the bound named. */
|
|
112
|
+
startLine?: number;
|
|
113
|
+
endLine?: number;
|
|
114
|
+
/** RESERVED, currently a no-op: blame follows the requested path name only and does not aggregate
|
|
115
|
+
* history across renames. The field exists so a caller can state its intent without this contract
|
|
116
|
+
* growing a second shape on the day rename support lands. */
|
|
117
|
+
follow?: boolean;
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* Line-by-line provenance for one file: who last touched each line, in which commit.
|
|
121
|
+
*
|
|
122
|
+
* SECURITY BOUNDARY. `path` arrives from a browser and is contained exactly as `gitDiff` and
|
|
123
|
+
* `gitHeadContent` contain it - resolve against the workspace root, refuse anything that lands
|
|
124
|
+
* outside. Backslashes are normalised to `/` FIRST, so a Windows-spelled relative path resolves like
|
|
125
|
+
* the same request spelled with `/` and the git invocation and the response share the one separator
|
|
126
|
+
* this API speaks. `revision` arrives from the same untrusted place: it may not start with `-` (that
|
|
127
|
+
* is how an argument becomes a git FLAG) and may not carry whitespace or NUL. Every git call here is
|
|
128
|
+
* `execFile` with an argument array - no shell, no string interpolation into a command line - and the
|
|
129
|
+
* path always travels after a bare `--`, so no path can be read as an option.
|
|
130
|
+
*
|
|
131
|
+
* FAILURE SHAPE. Facts about the file or repository (`untracked`, `binary`, `too_large`, `no_head`,
|
|
132
|
+
* `not_in_revision`) come back as `supported: false` - the gutter renders them, it does not error on
|
|
133
|
+
* them. Request mistakes (path escapes, a missing file, a bad revision, an illegal range) throw with
|
|
134
|
+
* a stable `code` for the HTTP layer to map. Git's raw stderr never reaches a caller: expected
|
|
135
|
+
* failures are thrown as written sentences, and an unexpected git failure is logged here and thrown
|
|
136
|
+
* as one stable sentence.
|
|
137
|
+
*/
|
|
138
|
+
export declare function gitBlame(root: string, path: string, options?: GitBlameOptions): Promise<GitBlameResponse>;
|
|
53
139
|
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
54
140
|
export declare function isGitRepository(root: string): Promise<boolean>;
|
|
141
|
+
export declare function gitMutate(root: string, op: string, path?: string, message?: string): Promise<GitWorkspace>;
|
package/dist/server/git.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { execFile } from "node:child_process";
|
|
2
2
|
import { promisify } from "node:util";
|
|
3
|
-
import { basename, resolve, sep } from "node:path";
|
|
3
|
+
import { basename, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
6
|
const exec = promisify(execFile);
|
|
6
|
-
async function git(cwd, args) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: 8 * 1024 * 1024 }); }
|
|
7
|
+
async function git(cwd, args, options) { return exec("git", ["-c", "core.quotepath=false", ...args], { cwd, windowsHide: true, maxBuffer: options?.maxBuffer ?? 8 * 1024 * 1024 }); }
|
|
7
8
|
function files(raw) { const staged = [], unstaged = [], untracked = []; for (const line of raw.split("\0").filter(Boolean)) {
|
|
8
9
|
const xy = line.slice(0, 2), path = line.slice(3).split("\0")[0].replaceAll("\\", "/");
|
|
9
10
|
const item = { path, name: basename(path), xy };
|
|
@@ -63,7 +64,305 @@ export async function gitHeadContent(root, path) {
|
|
|
63
64
|
return null;
|
|
64
65
|
}
|
|
65
66
|
}
|
|
67
|
+
// ---------------------------------------------------------------------------------------------
|
|
68
|
+
// Git blame.
|
|
69
|
+
//
|
|
70
|
+
// The blame gutter needs, for one file: who last touched each line, and in which commit. Git's
|
|
71
|
+
// `--line-porcelain` output is the only source for that, and this module is the only place allowed
|
|
72
|
+
// to parse it - the caller receives the structured `GitBlameLine[]` and never sees porcelain text,
|
|
73
|
+
// the same boundary `gitLog` already draws for its own format.
|
|
74
|
+
// ---------------------------------------------------------------------------------------------
|
|
75
|
+
/**
|
|
76
|
+
* A file larger than this is refused for blame (`too_large`) rather than blamed. Blame's cost scales
|
|
77
|
+
* with a text file's LINE count, and a file this size can carry millions of them, so the bound is
|
|
78
|
+
* checked from `stat` (working tree) or `git cat-file -s` (a revision) BEFORE any content is read -
|
|
79
|
+
* an oversized file costs one stat, never a buffer.
|
|
80
|
+
*/
|
|
81
|
+
export const MAX_BLAME_FILE_BYTES = 2 * 1024 * 1024;
|
|
82
|
+
/**
|
|
83
|
+
* The most lines one blame response may carry. `--line-porcelain` emits roughly 300-500 bytes of
|
|
84
|
+
* metadata per line, so this cap bounds the response and the parse to single-digit megabytes. A
|
|
85
|
+
* whole-file request for a longer file is answered with the FIRST `MAX_BLAME_LINES` entries and
|
|
86
|
+
* `truncated: true`; a caller that wants more asks for an explicit `startLine`/`endLine` window -
|
|
87
|
+
* which is what the gutter does anyway, and a window may not exceed this cap either.
|
|
88
|
+
*/
|
|
89
|
+
export const MAX_BLAME_LINES = 20000;
|
|
90
|
+
/** Output-buffer headroom for the blame process itself: MAX_BLAME_LINES records at ~500 bytes each is
|
|
91
|
+
* ~10MB, and 32MB leaves slack for long author names and summaries without being unbounded. */
|
|
92
|
+
const BLAME_MAX_BUFFER = 32 * 1024 * 1024;
|
|
93
|
+
/** How many leading bytes are sniffed for a NUL byte to classify content as binary - the same
|
|
94
|
+
* heuristic git's own diff machinery uses. A NUL byte cannot appear in UTF-8 text; a binary file's
|
|
95
|
+
* "lines" are meaningless to a gutter, and git blame happily produces metadata for them, so the
|
|
96
|
+
* refusal has to happen here, before the porcelain is ever asked for. */
|
|
97
|
+
const BLAME_SNIFF_BYTES = 8192;
|
|
98
|
+
function unsupportedBlame(path, revision, reason) {
|
|
99
|
+
return { path, revision, lines: [], totalLines: 0, truncated: false, supported: false, reason };
|
|
100
|
+
}
|
|
101
|
+
/** `\n`-delimited line count: a trailing newline does not open a line of its own, an empty file has
|
|
102
|
+
* none, and a lone `\n` is one (empty) line - matching what git blame itself will report. */
|
|
103
|
+
function countTextLines(text) {
|
|
104
|
+
if (text.length === 0)
|
|
105
|
+
return 0;
|
|
106
|
+
let count = 1;
|
|
107
|
+
for (let i = 0; i < text.length; i++)
|
|
108
|
+
if (text.charCodeAt(i) === 10)
|
|
109
|
+
count++;
|
|
110
|
+
if (text.charCodeAt(text.length - 1) === 10)
|
|
111
|
+
count--;
|
|
112
|
+
return count;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* One porcelain record per line: `<40-hex sha> <orig-line> <final-line> [<count>]`, followed by the
|
|
116
|
+
* full header (`author`, `author-mail`, `author-time`, `summary`, `filename`, ...), followed by the
|
|
117
|
+
* line's own content prefixed with TAB. `--line-porcelain` repeats the full header for EVERY line, so
|
|
118
|
+
* parsing is stateless per record - no carry-over of "the commit from the previous record", which is
|
|
119
|
+
* the bug plain `--porcelain` invites. Two rules make the parse safe:
|
|
120
|
+
*
|
|
121
|
+
* 1. A record header is a 40-hex sha followed by two decimal numbers, and nothing else in the stream
|
|
122
|
+
* matches that - every other line is either `key value` metadata or TAB-prefixed content. The
|
|
123
|
+
* optional fourth number is the porcelain GROUP size (a run of consecutive lines from one commit);
|
|
124
|
+
* under `--line-porcelain` every line still gets its own record, so it is parsed and discarded.
|
|
125
|
+
* 2. The TAB-prefixed content line is skipped UNREAD. It is the one part of the stream that can hold
|
|
126
|
+
* arbitrary file bytes - NULs, line-splitting illusions, huge runs - and this API's contract is
|
|
127
|
+
* metadata only.
|
|
128
|
+
*/
|
|
129
|
+
function parseBlamePorcelain(raw) {
|
|
130
|
+
const lines = [];
|
|
131
|
+
let current = null;
|
|
132
|
+
for (const line of raw.split("\n")) {
|
|
133
|
+
if (line.startsWith("\t"))
|
|
134
|
+
continue;
|
|
135
|
+
const header = /^([0-9a-f]{40}) (\d+) (\d+)(?: (\d+))?$/.exec(line);
|
|
136
|
+
if (header) {
|
|
137
|
+
const sha = header[1];
|
|
138
|
+
const uncommitted = /^0{40}$/.test(sha);
|
|
139
|
+
current = {
|
|
140
|
+
line: Number(header[3]),
|
|
141
|
+
commit: uncommitted ? null : sha,
|
|
142
|
+
shortCommit: uncommitted ? null : sha.slice(0, 8),
|
|
143
|
+
author: null, authorEmail: null, date: null, summary: null,
|
|
144
|
+
isUncommitted: uncommitted,
|
|
145
|
+
};
|
|
146
|
+
lines.push(current);
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (!current)
|
|
150
|
+
continue;
|
|
151
|
+
// An uncommitted record still carries git's placeholders in its metadata ("Not Committed Yet", a
|
|
152
|
+
// pseudo address, the time of the diff run); the contract nulls every field but the flag, so that
|
|
153
|
+
// metadata is skipped rather than copied into the response.
|
|
154
|
+
if (current.isUncommitted)
|
|
155
|
+
continue;
|
|
156
|
+
const space = line.indexOf(" ");
|
|
157
|
+
const key = space === -1 ? line : line.slice(0, space);
|
|
158
|
+
const value = space === -1 ? "" : line.slice(space + 1);
|
|
159
|
+
if (key === "author")
|
|
160
|
+
current.author = value;
|
|
161
|
+
else if (key === "author-mail")
|
|
162
|
+
current.authorEmail = value.startsWith("<") && value.endsWith(">") ? value.slice(1, -1) : value;
|
|
163
|
+
else if (key === "author-time") {
|
|
164
|
+
const ms = Number(value) * 1000;
|
|
165
|
+
if (Number.isFinite(ms))
|
|
166
|
+
current.date = new Date(ms).toISOString();
|
|
167
|
+
}
|
|
168
|
+
else if (key === "summary")
|
|
169
|
+
current.summary = value;
|
|
170
|
+
}
|
|
171
|
+
return lines;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Line-by-line provenance for one file: who last touched each line, in which commit.
|
|
175
|
+
*
|
|
176
|
+
* SECURITY BOUNDARY. `path` arrives from a browser and is contained exactly as `gitDiff` and
|
|
177
|
+
* `gitHeadContent` contain it - resolve against the workspace root, refuse anything that lands
|
|
178
|
+
* outside. Backslashes are normalised to `/` FIRST, so a Windows-spelled relative path resolves like
|
|
179
|
+
* the same request spelled with `/` and the git invocation and the response share the one separator
|
|
180
|
+
* this API speaks. `revision` arrives from the same untrusted place: it may not start with `-` (that
|
|
181
|
+
* is how an argument becomes a git FLAG) and may not carry whitespace or NUL. Every git call here is
|
|
182
|
+
* `execFile` with an argument array - no shell, no string interpolation into a command line - and the
|
|
183
|
+
* path always travels after a bare `--`, so no path can be read as an option.
|
|
184
|
+
*
|
|
185
|
+
* FAILURE SHAPE. Facts about the file or repository (`untracked`, `binary`, `too_large`, `no_head`,
|
|
186
|
+
* `not_in_revision`) come back as `supported: false` - the gutter renders them, it does not error on
|
|
187
|
+
* them. Request mistakes (path escapes, a missing file, a bad revision, an illegal range) throw with
|
|
188
|
+
* a stable `code` for the HTTP layer to map. Git's raw stderr never reaches a caller: expected
|
|
189
|
+
* failures are thrown as written sentences, and an unexpected git failure is logged here and thrown
|
|
190
|
+
* as one stable sentence.
|
|
191
|
+
*/
|
|
192
|
+
export async function gitBlame(root, path, options = {}) {
|
|
193
|
+
const relativePath = path.replaceAll("\\", "/");
|
|
194
|
+
if (!relativePath || relativePath.endsWith("/") || relativePath.includes("\0")) {
|
|
195
|
+
throw Object.assign(new Error("文件路径无效"), { code: "path" });
|
|
196
|
+
}
|
|
197
|
+
const safe = resolve(root, relativePath);
|
|
198
|
+
if (!safe.startsWith(resolve(root) + sep))
|
|
199
|
+
throw Object.assign(new Error("文件路径无效"), { code: "path" });
|
|
200
|
+
// The canonical `/`-separated form the response echoes - `sub\inner.txt` and `sub/../sub/inner.txt`
|
|
201
|
+
// both normalise to `sub/inner.txt`, so the client cannot learn a second spelling of one file.
|
|
202
|
+
const canonical = relative(resolve(root), safe).split(sep).join("/");
|
|
203
|
+
const isRepo = await git(root, ["rev-parse", "--git-dir"]).then(() => true).catch(() => false);
|
|
204
|
+
if (!isRepo)
|
|
205
|
+
throw Object.assign(new Error("此工作区不是 Git 仓库"), { code: "not_a_repo" });
|
|
206
|
+
const revision = options.revision?.trim() || undefined;
|
|
207
|
+
if (revision && (revision.startsWith("-") || /[\s\0]/.test(revision))) {
|
|
208
|
+
throw Object.assign(new Error("无效的 revision"), { code: "bad_revision" });
|
|
209
|
+
}
|
|
210
|
+
// An unborn repository has no commit for ANY line to point at, so blame is uniformly unavailable
|
|
211
|
+
// (`no_head`, a supported:false fact) rather than an error - the repository itself is fine, it just
|
|
212
|
+
// has no history yet. Checked before trackedness: the answer does not depend on the file.
|
|
213
|
+
if (!revision) {
|
|
214
|
+
const hasHead = await git(root, ["rev-parse", "--verify", "HEAD"]).then(() => true).catch(() => false);
|
|
215
|
+
if (!hasHead)
|
|
216
|
+
return unsupportedBlame(canonical, "HEAD", "no_head");
|
|
217
|
+
}
|
|
218
|
+
// Resolve the anchor to a concrete commit BEFORE anything else uses it - this is what splits "the
|
|
219
|
+
// revision is bogus" (bad_revision) from "the file is not in that revision" (not_in_revision):
|
|
220
|
+
// both are git exit 128, and only ordering gives them distinct, stable codes.
|
|
221
|
+
let revSha;
|
|
222
|
+
if (revision) {
|
|
223
|
+
const resolved = await git(root, ["rev-parse", "--verify", `${revision}^{commit}`]).then((r) => r.stdout.trim()).catch(() => null);
|
|
224
|
+
if (!resolved)
|
|
225
|
+
throw Object.assign(new Error(`无法解析 revision:${revision}`), { code: "bad_revision" });
|
|
226
|
+
revSha = resolved;
|
|
227
|
+
}
|
|
228
|
+
// Trackedness is judged against the index, exactly as `gitDiff` judges it, so every op answers the
|
|
229
|
+
// same question the same way. A file on disk that the index has never heard of is `untracked` (a
|
|
230
|
+
// supported:false fact - the file is real and the gutter may well be showing it). A path that does
|
|
231
|
+
// not exist at all is an ERROR (`not_found`), deliberately distinct from `untracked`: "git does not
|
|
232
|
+
// know this file" and "there is no such file" are different sentences. A file tracked but deleted
|
|
233
|
+
// from the working tree is `not_found` for the default anchor - which reads the working tree, and
|
|
234
|
+
// git itself refuses with `Cannot lstat` there - while an explicit revision does not need the
|
|
235
|
+
// working-tree file at all and is allowed through.
|
|
236
|
+
const tracked = await git(root, ["ls-files", "--error-unmatch", "--", relativePath]).then(() => true).catch(() => false);
|
|
237
|
+
const onDisk = existsSync(safe);
|
|
238
|
+
if (!tracked && !onDisk)
|
|
239
|
+
throw Object.assign(new Error(`文件不存在:${canonical}`), { code: "not_found" });
|
|
240
|
+
if (!tracked)
|
|
241
|
+
return unsupportedBlame(canonical, revision ?? "HEAD", "untracked");
|
|
242
|
+
if (!onDisk && !revision)
|
|
243
|
+
throw Object.assign(new Error(`文件不存在:${canonical}(已从工作区删除)`), { code: "not_found" });
|
|
244
|
+
// Size, binary sniff and line count, from the content blame will actually read: the committed blob
|
|
245
|
+
// for an explicit revision, the working-tree file for the default anchor. One bounded read (capped
|
|
246
|
+
// above at MAX_BLAME_FILE_BYTES) yields all three, so no request ever streams an unbounded file
|
|
247
|
+
// into memory just to count it.
|
|
248
|
+
let content;
|
|
249
|
+
if (revSha) {
|
|
250
|
+
const size = await git(root, ["cat-file", "-s", `${revSha}:${relativePath}`]).then((r) => Number(r.stdout.trim())).catch(() => null);
|
|
251
|
+
if (size === null)
|
|
252
|
+
return unsupportedBlame(canonical, revision ?? "HEAD", "not_in_revision");
|
|
253
|
+
if (size > MAX_BLAME_FILE_BYTES)
|
|
254
|
+
return unsupportedBlame(canonical, revision ?? "HEAD", "too_large");
|
|
255
|
+
const blob = await git(root, ["cat-file", "blob", `${revSha}:${relativePath}`]).catch(() => null);
|
|
256
|
+
if (blob === null)
|
|
257
|
+
return unsupportedBlame(canonical, revision ?? "HEAD", "not_in_revision");
|
|
258
|
+
content = Buffer.from(blob.stdout, "utf8");
|
|
259
|
+
}
|
|
260
|
+
else {
|
|
261
|
+
try {
|
|
262
|
+
const info = await stat(safe);
|
|
263
|
+
if (info.size > MAX_BLAME_FILE_BYTES)
|
|
264
|
+
return unsupportedBlame(canonical, "HEAD", "too_large");
|
|
265
|
+
content = await readFile(safe);
|
|
266
|
+
}
|
|
267
|
+
catch {
|
|
268
|
+
// The file vanished between the existsSync above and this read - the same not_found, reached
|
|
269
|
+
// through a race instead of a pre-check.
|
|
270
|
+
throw Object.assign(new Error(`文件不存在:${canonical}`), { code: "not_found" });
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const sniffEnd = Math.min(content.length, BLAME_SNIFF_BYTES);
|
|
274
|
+
for (let i = 0; i < sniffEnd; i++)
|
|
275
|
+
if (content[i] === 0)
|
|
276
|
+
return unsupportedBlame(canonical, revision ?? "HEAD", "binary");
|
|
277
|
+
const totalLines = countTextLines(content.toString("utf8"));
|
|
278
|
+
// The window. Validated strictly and AFTER totalLines is known, so each rejection can name the real
|
|
279
|
+
// bound ("the file has 12 lines") instead of a guess.
|
|
280
|
+
const startRaw = options.startLine;
|
|
281
|
+
const endRaw = options.endLine;
|
|
282
|
+
if ((startRaw === undefined) !== (endRaw === undefined)) {
|
|
283
|
+
throw Object.assign(new Error("startLine 与 endLine 必须同时提供"), { code: "bad_range" });
|
|
284
|
+
}
|
|
285
|
+
let start = 1;
|
|
286
|
+
let end = totalLines;
|
|
287
|
+
let windowed = false;
|
|
288
|
+
if (startRaw !== undefined && endRaw !== undefined) {
|
|
289
|
+
windowed = true;
|
|
290
|
+
if (!Number.isInteger(startRaw) || startRaw < 1)
|
|
291
|
+
throw Object.assign(new Error(`startLine 必须是正整数,收到 ${startRaw}`), { code: "bad_range" });
|
|
292
|
+
if (!Number.isInteger(endRaw) || endRaw < 1)
|
|
293
|
+
throw Object.assign(new Error(`endLine 必须是正整数,收到 ${endRaw}`), { code: "bad_range" });
|
|
294
|
+
if (startRaw > endRaw)
|
|
295
|
+
throw Object.assign(new Error(`startLine (${startRaw}) 不能大于 endLine (${endRaw})`), { code: "bad_range" });
|
|
296
|
+
if (endRaw > totalLines)
|
|
297
|
+
throw Object.assign(new Error(`endLine (${endRaw}) 超出文件行数 (${totalLines})`), { code: "bad_range" });
|
|
298
|
+
if (endRaw - startRaw + 1 > MAX_BLAME_LINES)
|
|
299
|
+
throw Object.assign(new Error(`行范围过大:单次最多 ${MAX_BLAME_LINES} 行`), { code: "bad_range" });
|
|
300
|
+
start = startRaw;
|
|
301
|
+
end = endRaw;
|
|
302
|
+
}
|
|
303
|
+
else if (totalLines > MAX_BLAME_LINES) {
|
|
304
|
+
// A whole-file request on an over-cap file is answered with the head of the file and `truncated`.
|
|
305
|
+
// The gutter asks in windows and never hits this; the prefix exists so a naive caller gets useful
|
|
306
|
+
// data rather than a refusal or an unbounded response.
|
|
307
|
+
end = MAX_BLAME_LINES;
|
|
308
|
+
}
|
|
309
|
+
const args = ["blame", "--line-porcelain"];
|
|
310
|
+
if (start !== 1 || end !== totalLines)
|
|
311
|
+
args.push("-L", `${start},${end}`);
|
|
312
|
+
if (revSha)
|
|
313
|
+
args.push(revSha);
|
|
314
|
+
args.push("--", relativePath);
|
|
315
|
+
let raw;
|
|
316
|
+
try {
|
|
317
|
+
raw = (await git(root, args, { maxBuffer: BLAME_MAX_BUFFER })).stdout;
|
|
318
|
+
}
|
|
319
|
+
catch (error) {
|
|
320
|
+
// Git's stderr is for this server's log, not for the user: an unexpected blame failure surfaces
|
|
321
|
+
// as one stable sentence with a stable code, and the raw output stays server-side.
|
|
322
|
+
console.error(`git blame failed for ${canonical}:`, error);
|
|
323
|
+
throw Object.assign(new Error("git blame 执行失败"), { code: "git_error" });
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
path: canonical,
|
|
327
|
+
revision: revision ?? "HEAD",
|
|
328
|
+
lines: parseBlamePorcelain(raw),
|
|
329
|
+
totalLines,
|
|
330
|
+
truncated: !windowed && totalLines > MAX_BLAME_LINES,
|
|
331
|
+
supported: true,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
66
334
|
/** Whether the directory is a git repository at all. The review view asks before it offers a git baseline. */
|
|
67
335
|
export async function isGitRepository(root) {
|
|
68
336
|
return git(root, ["rev-parse", "--git-dir"]).then(() => true).catch(() => false);
|
|
69
337
|
}
|
|
338
|
+
function safePath(root, path) {
|
|
339
|
+
const safe = resolve(root, path);
|
|
340
|
+
if (safe !== resolve(root) && !safe.startsWith(resolve(root) + sep))
|
|
341
|
+
throw Object.assign(new Error("文件路径无效"), { code: "path" });
|
|
342
|
+
return path;
|
|
343
|
+
}
|
|
344
|
+
export async function gitMutate(root, op, path, message) {
|
|
345
|
+
if (op === "stage" || op === "unstage" || op === "discard") {
|
|
346
|
+
if (!path)
|
|
347
|
+
throw Object.assign(new Error("缺少文件路径"), { code: "path" });
|
|
348
|
+
safePath(root, path);
|
|
349
|
+
if (op === "stage")
|
|
350
|
+
await git(root, ["add", "--", path]);
|
|
351
|
+
else if (op === "unstage")
|
|
352
|
+
await git(root, ["restore", "--staged", "--", path]);
|
|
353
|
+
else
|
|
354
|
+
await git(root, ["restore", "--", path]);
|
|
355
|
+
}
|
|
356
|
+
else if (op === "commit") {
|
|
357
|
+
if (!message?.trim())
|
|
358
|
+
throw Object.assign(new Error("提交消息不能为空"), { code: "bad_message" });
|
|
359
|
+
await git(root, ["commit", "-m", message.trim()]);
|
|
360
|
+
}
|
|
361
|
+
else if (op === "pull")
|
|
362
|
+
await git(root, ["pull", "--ff-only"]);
|
|
363
|
+
else if (op === "push")
|
|
364
|
+
await git(root, ["push"]);
|
|
365
|
+
else
|
|
366
|
+
throw Object.assign(new Error("未知 Git 操作"), { code: "bad_op" });
|
|
367
|
+
return gitStatus(root);
|
|
368
|
+
}
|
|
@@ -7,7 +7,7 @@ import { basename, dirname, extname, join, relative, resolve } from "node:path";
|
|
|
7
7
|
import { describe as describeFilesError, fileMeta, listDirectory, MAX_FILE_BYTES, readWorkspaceFile, resolveInsideRoot, toRelative, writeWorkspaceFile, } from "./files.js";
|
|
8
8
|
import { findUploadFile, MAX_UPLOAD_BODY_BYTES, saveUpload } from "./upload.js";
|
|
9
9
|
import { checkDiskVersion } from "./version.js";
|
|
10
|
-
import { gitDiff, gitLog, gitStatus } from "./git.js";
|
|
10
|
+
import { gitBlame, gitDiff, gitLog, gitMutate, gitStatus } from "./git.js";
|
|
11
11
|
import { computeFileDiff } from "./file-diff.js";
|
|
12
12
|
import { MAX_KNOWLEDGE_REQUEST_BYTES } from "./knowledge.js";
|
|
13
13
|
import { MAX_TOOL_BODY_BYTES } from "./tool-registry.js";
|
|
@@ -797,10 +797,6 @@ export function createHttpServer(options) {
|
|
|
797
797
|
if (url.pathname === "/api/git") {
|
|
798
798
|
const json = (status, body) => response.writeHead(status, { "content-type": "application/json; charset=utf-8" }).end(JSON.stringify(body));
|
|
799
799
|
try {
|
|
800
|
-
if (request.method !== "GET") {
|
|
801
|
-
json(405, { error: "Git 视图需要 GET", code: "method" });
|
|
802
|
-
return;
|
|
803
|
-
}
|
|
804
800
|
const workspaceId = url.searchParams.get("workspaceId");
|
|
805
801
|
const workspace = context.store.snapshot().workspaces.find((item) => item.id === workspaceId);
|
|
806
802
|
if (!workspace) {
|
|
@@ -808,6 +804,24 @@ export function createHttpServer(options) {
|
|
|
808
804
|
return;
|
|
809
805
|
}
|
|
810
806
|
const op = url.searchParams.get("op") ?? "status";
|
|
807
|
+
// Blame is a read with a GET shape: every other verb is refused HERE, before the POST branch
|
|
808
|
+
// could ever route the op into gitMutate and before any body is read.
|
|
809
|
+
if (op === "blame" && request.method !== "GET") {
|
|
810
|
+
json(405, { error: "Git 请求方法不支持", code: "method" });
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
if (request.method === "POST") {
|
|
814
|
+
let raw = "";
|
|
815
|
+
for await (const chunk of request)
|
|
816
|
+
raw += chunk;
|
|
817
|
+
const body = raw ? JSON.parse(raw) : {};
|
|
818
|
+
json(200, await gitMutate(workspace.path, op, body.path, body.message));
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
if (request.method !== "GET") {
|
|
822
|
+
json(405, { error: "Git 请求方法不支持", code: "method" });
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
811
825
|
if (op === "status")
|
|
812
826
|
json(200, await gitStatus(workspace.path));
|
|
813
827
|
else if (op === "log")
|
|
@@ -820,6 +834,20 @@ export function createHttpServer(options) {
|
|
|
820
834
|
}
|
|
821
835
|
json(200, await gitDiff(workspace.path, path, url.searchParams.get("staged") === "1"));
|
|
822
836
|
}
|
|
837
|
+
else if (op === "blame") {
|
|
838
|
+
const path = url.searchParams.get("path");
|
|
839
|
+
if (!path) {
|
|
840
|
+
json(400, { error: "blame 需要 path", code: "bad_path" });
|
|
841
|
+
return;
|
|
842
|
+
}
|
|
843
|
+
const startLine = url.searchParams.get("startLine");
|
|
844
|
+
const endLine = url.searchParams.get("endLine");
|
|
845
|
+
json(200, await gitBlame(workspace.path, path, {
|
|
846
|
+
revision: url.searchParams.get("revision") ?? undefined,
|
|
847
|
+
startLine: startLine === null ? undefined : Number(startLine),
|
|
848
|
+
endLine: endLine === null ? undefined : Number(endLine),
|
|
849
|
+
}));
|
|
850
|
+
}
|
|
823
851
|
else
|
|
824
852
|
json(400, { error: "未知 Git 操作", code: "bad_op" });
|
|
825
853
|
}
|
|
@@ -830,6 +858,10 @@ export function createHttpServer(options) {
|
|
|
830
858
|
json(404, { error: "此工作区不是 git 仓库", code: "not_a_repo" });
|
|
831
859
|
else if (code === "path")
|
|
832
860
|
json(400, { error: message, code: "bad_path" });
|
|
861
|
+
else if (code === "not_found")
|
|
862
|
+
json(404, { error: message, code: "not_found" });
|
|
863
|
+
else if (code === "bad_range" || code === "bad_revision")
|
|
864
|
+
json(400, { error: message, code });
|
|
833
865
|
else
|
|
834
866
|
json(400, { error: message, code: "git_error" });
|
|
835
867
|
}
|
|
@@ -900,7 +932,7 @@ export function createHttpServer(options) {
|
|
|
900
932
|
json(400, { error: "请求体不是合法的 JSON。" });
|
|
901
933
|
return;
|
|
902
934
|
}
|
|
903
|
-
const { cmd, cwd, conversationId, capability, toolCallId } = body;
|
|
935
|
+
const { cmd, description, cwd, conversationId, capability, toolCallId } = body;
|
|
904
936
|
if (!cmd || !cwd || !conversationId) {
|
|
905
937
|
json(400, { error: "需要 cmd、cwd 和 conversationId。" });
|
|
906
938
|
return;
|
|
@@ -929,7 +961,7 @@ export function createHttpServer(options) {
|
|
|
929
961
|
await store.setBackgroundTask({
|
|
930
962
|
id: sessionId,
|
|
931
963
|
conversationId,
|
|
932
|
-
title: cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd,
|
|
964
|
+
title: description?.trim() || (cmd.length > 60 ? cmd.slice(0, 60) + "…" : cmd),
|
|
933
965
|
agentId: "exec",
|
|
934
966
|
status: "running",
|
|
935
967
|
startedAt: new Date().toISOString(),
|