@giovannijecha/jecode 0.2.3 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/accounts.js +2 -2
- package/dist/controller.js +44 -14
- package/dist/credentials.js +24 -13
- package/dist/executable.js +70 -0
- package/dist/external-browser.js +19 -6
- package/dist/prompt.js +0 -2
- package/dist/settings.js +9 -6
- package/dist/{account-lock.js → store-lock.js} +8 -8
- package/dist/tools/fs.js +8 -2
- package/dist/tools/ripgrep.js +219 -0
- package/dist/tools/search.js +74 -25
- package/dist/tools/shell.js +36 -6
- package/dist/transcript.js +1 -1
- package/dist/tui/components/tool.js +1 -1
- package/dist/ui/theme.js +18 -17
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -25,13 +25,14 @@
|
|
|
25
25
|
<a href="https://github.com/giovannijecha/jecode/releases">Releases</a>
|
|
26
26
|
</p>
|
|
27
27
|
|
|
28
|
-
> Jecode is an early 0.
|
|
28
|
+
> Jecode is an early 0.3.x release. The core loop is usable today; commands and
|
|
29
29
|
> terminal interactions may still evolve before 1.0.
|
|
30
30
|
|
|
31
31
|
## Why Jecode
|
|
32
32
|
|
|
33
33
|
- **One controller.** One visible loop talks to the model, runs tools, and returns
|
|
34
|
-
control to you.
|
|
34
|
+
control to you. Independent reads can overlap inside one step; writes and
|
|
35
|
+
commands remain ordered. There are no hidden workers or delegated agents.
|
|
35
36
|
- **Terminal-native.** The transcript, composer, searchable menus, tool output,
|
|
36
37
|
diffs, approvals, reasoning, and status all share one full-screen TUI.
|
|
37
38
|
- **Permission-aware.** Reads stay transparent; dangerous actions ask first.
|
package/dist/accounts.js
CHANGED
|
@@ -3,7 +3,7 @@ import { chmod, mkdir } from "node:fs/promises";
|
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { atomicWrite } from "./atomic.js";
|
|
6
|
-
import {
|
|
6
|
+
import { withStoreLock } from "./store-lock.js";
|
|
7
7
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
8
8
|
let cached;
|
|
9
9
|
export function openAICodexAccount() {
|
|
@@ -36,7 +36,7 @@ export async function updateOpenAICodexAccount(change, signal) {
|
|
|
36
36
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
37
37
|
if (process.platform !== "win32")
|
|
38
38
|
await chmod(directory, 0o700);
|
|
39
|
-
return
|
|
39
|
+
return withStoreLock(file, async () => {
|
|
40
40
|
const current = readStore(file);
|
|
41
41
|
const next = await change(current.accounts["openai-codex"]);
|
|
42
42
|
const accounts = { ...current.accounts };
|
package/dist/controller.js
CHANGED
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
import { isToolCall } from "./types.js";
|
|
7
7
|
import { findTool, runTool, toolSpecs } from "./tools/index.js";
|
|
8
8
|
export const MAX_TOOL_CALLS_PER_STEP = 32;
|
|
9
|
+
/** Independent read calls share one bounded execution wave. */
|
|
10
|
+
export const MAX_CONCURRENT_TOOL_CALLS = 4;
|
|
9
11
|
/**
|
|
10
12
|
* Run one user turn to completion: keep exchanging with the model until it
|
|
11
13
|
* stops asking for tools. `history` is mutated in place, so an aborted turn
|
|
@@ -44,25 +46,35 @@ export async function runTurn(history, options, events, signal) {
|
|
|
44
46
|
events.onUsage?.(assistant.usage);
|
|
45
47
|
return; // the model is done — hand back to the user
|
|
46
48
|
}
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
//
|
|
49
|
+
// Consecutive shared reads run together. An exclusive call is an ordered
|
|
50
|
+
// barrier, so writes, approvals, and commands never overlap other work.
|
|
51
|
+
// Every result still goes back in a SINGLE message and in call order.
|
|
50
52
|
const results = [];
|
|
51
53
|
const announced = new Set();
|
|
52
54
|
try {
|
|
53
55
|
if (assistant.usage !== undefined)
|
|
54
56
|
events.onUsage?.(assistant.usage);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
57
|
+
while (results.length < calls.length) {
|
|
58
|
+
const start = results.length;
|
|
59
|
+
const batch = nextBatch(calls, start, options.tools);
|
|
60
|
+
const prepared = [];
|
|
61
|
+
for (let offset = 0; offset < batch.length; offset++) {
|
|
62
|
+
throwIfAborted(signal);
|
|
63
|
+
const call = batch[offset];
|
|
64
|
+
events.onToolProgress?.(start + offset + 1, calls.length);
|
|
65
|
+
const preview = await look(call, options, signal);
|
|
66
|
+
throwIfAborted(signal);
|
|
67
|
+
announced.add(call.id);
|
|
68
|
+
events.onToolCall(call, preview);
|
|
69
|
+
prepared.push({ call, preview });
|
|
70
|
+
}
|
|
71
|
+
const runs = await Promise.all(prepared.map(({ call, preview }) => settle(call, options, events, signal, preview)));
|
|
72
|
+
for (let offset = 0; offset < runs.length; offset++) {
|
|
73
|
+
const call = prepared[offset]?.call;
|
|
74
|
+
const run = runs[offset];
|
|
75
|
+
results.push(run.result);
|
|
76
|
+
events.onToolResult(call, run.result, run.summary);
|
|
77
|
+
}
|
|
66
78
|
}
|
|
67
79
|
}
|
|
68
80
|
catch (error) {
|
|
@@ -96,6 +108,24 @@ export async function runTurn(history, options, events, signal) {
|
|
|
96
108
|
}
|
|
97
109
|
throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
|
|
98
110
|
}
|
|
111
|
+
function nextBatch(calls, start, tools) {
|
|
112
|
+
const first = calls[start];
|
|
113
|
+
if (first === undefined)
|
|
114
|
+
return [];
|
|
115
|
+
if (!shared(findTool(tools, first.name)))
|
|
116
|
+
return [first];
|
|
117
|
+
const batch = [];
|
|
118
|
+
for (let index = start; index < calls.length && batch.length < MAX_CONCURRENT_TOOL_CALLS; index++) {
|
|
119
|
+
const call = calls[index];
|
|
120
|
+
if (!shared(findTool(tools, call.name)))
|
|
121
|
+
break;
|
|
122
|
+
batch.push(call);
|
|
123
|
+
}
|
|
124
|
+
return batch;
|
|
125
|
+
}
|
|
126
|
+
function shared(tool) {
|
|
127
|
+
return tool?.concurrency === "shared" && !tool.dangerous;
|
|
128
|
+
}
|
|
99
129
|
function assertToolCallIds(calls) {
|
|
100
130
|
const seen = new Set();
|
|
101
131
|
for (const call of calls) {
|
package/dist/credentials.js
CHANGED
|
@@ -13,6 +13,7 @@ import { chmod, mkdir } from "node:fs/promises";
|
|
|
13
13
|
import { readFileSync } from "node:fs";
|
|
14
14
|
import * as path from "node:path";
|
|
15
15
|
import { atomicWrite } from "./atomic.js";
|
|
16
|
+
import { withStoreLock } from "./store-lock.js";
|
|
16
17
|
import { legacyUserDataPath, userDataLabel, userDataPath } from "./user-data.js";
|
|
17
18
|
/** Keys this session was given but not asked to keep. Dies with the window. */
|
|
18
19
|
const held = new Map();
|
|
@@ -69,22 +70,30 @@ export function hold(name, value) {
|
|
|
69
70
|
*/
|
|
70
71
|
export async function keep(name, value) {
|
|
71
72
|
const file = storePath();
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
73
|
+
await prepare(file);
|
|
74
|
+
return withStoreLock(file, async () => {
|
|
75
|
+
const all = { ...readSavedStore(), [name]: value };
|
|
76
|
+
await persist(file, all);
|
|
77
|
+
hold(name, value);
|
|
78
|
+
saved = all;
|
|
79
|
+
return file;
|
|
80
|
+
});
|
|
77
81
|
}
|
|
78
82
|
/** Remove only the saved copy. An environment or session value is untouched. */
|
|
79
83
|
export async function forgetSaved(name) {
|
|
80
|
-
const all = { ...fromDisk() };
|
|
81
|
-
if (use(all[name]) === undefined)
|
|
82
|
-
return false;
|
|
83
|
-
delete all[name];
|
|
84
84
|
const file = storePath();
|
|
85
|
-
await
|
|
86
|
-
|
|
87
|
-
|
|
85
|
+
await prepare(file);
|
|
86
|
+
return withStoreLock(file, async () => {
|
|
87
|
+
const all = { ...readSavedStore() };
|
|
88
|
+
if (use(all[name]) === undefined) {
|
|
89
|
+
saved = all;
|
|
90
|
+
return false;
|
|
91
|
+
}
|
|
92
|
+
delete all[name];
|
|
93
|
+
await persist(file, all);
|
|
94
|
+
saved = all;
|
|
95
|
+
return true;
|
|
96
|
+
});
|
|
88
97
|
}
|
|
89
98
|
export function storePath() {
|
|
90
99
|
return userDataPath("credentials.json");
|
|
@@ -126,11 +135,13 @@ function readStore(file) {
|
|
|
126
135
|
return error.code === "ENOENT" ? undefined : {};
|
|
127
136
|
}
|
|
128
137
|
}
|
|
129
|
-
async function
|
|
138
|
+
async function prepare(file) {
|
|
130
139
|
const directory = path.dirname(file);
|
|
131
140
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
132
141
|
if (process.platform !== "win32")
|
|
133
142
|
await chmod(directory, 0o700);
|
|
143
|
+
}
|
|
144
|
+
async function persist(file, values) {
|
|
134
145
|
await atomicWrite(file, `${JSON.stringify(values, null, 2)}\n`, { mode: 0o600 });
|
|
135
146
|
}
|
|
136
147
|
/** An empty variable is an unset variable — an exported "" is not a key. */
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Resolve optional native helpers without ever executing workspace content.
|
|
2
|
+
import { accessSync, constants, realpathSync, statSync } from "node:fs";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
/** Return one canonical executable from PATH, skipping empty and rejected entries. */
|
|
5
|
+
export function resolveExecutable(name, options = {}) {
|
|
6
|
+
if (name === "" || path.basename(name) !== name)
|
|
7
|
+
return undefined;
|
|
8
|
+
const cwd = path.resolve(options.cwd ?? process.cwd());
|
|
9
|
+
const rejected = options.rejectUnder === undefined
|
|
10
|
+
? undefined
|
|
11
|
+
: canonical(options.rejectUnder);
|
|
12
|
+
const searchPath = options.searchPath ?? process.env["PATH"] ?? "";
|
|
13
|
+
for (const raw of searchPath.split(path.delimiter)) {
|
|
14
|
+
const entry = unquote(raw.trim());
|
|
15
|
+
// An empty PATH entry means the current directory. That is exactly the
|
|
16
|
+
// workspace-controlled lookup this resolver exists to exclude.
|
|
17
|
+
if (entry === "")
|
|
18
|
+
continue;
|
|
19
|
+
const directory = path.resolve(cwd, entry);
|
|
20
|
+
for (const candidate of executableNames(name)) {
|
|
21
|
+
const executable = usable(path.join(directory, candidate));
|
|
22
|
+
if (executable === undefined)
|
|
23
|
+
continue;
|
|
24
|
+
if (rejected !== undefined && within(rejected, executable))
|
|
25
|
+
continue;
|
|
26
|
+
return executable;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
function executableNames(name) {
|
|
32
|
+
if (process.platform !== "win32" || path.extname(name) !== "")
|
|
33
|
+
return [name];
|
|
34
|
+
return [`${name}.com`, `${name}.exe`];
|
|
35
|
+
}
|
|
36
|
+
function usable(file) {
|
|
37
|
+
try {
|
|
38
|
+
const resolved = realpathSync.native(file);
|
|
39
|
+
if (!statSync(resolved).isFile())
|
|
40
|
+
return undefined;
|
|
41
|
+
accessSync(resolved, process.platform === "win32" ? constants.F_OK : constants.X_OK);
|
|
42
|
+
return resolved;
|
|
43
|
+
}
|
|
44
|
+
catch {
|
|
45
|
+
return undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function canonical(directory) {
|
|
49
|
+
try {
|
|
50
|
+
return realpathSync.native(directory);
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return path.resolve(directory);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function within(root, candidate) {
|
|
57
|
+
const relative = path.relative(root, candidate);
|
|
58
|
+
return relative === "" || (relative !== ".." &&
|
|
59
|
+
!relative.startsWith(`..${path.sep}`) &&
|
|
60
|
+
!path.isAbsolute(relative));
|
|
61
|
+
}
|
|
62
|
+
function unquote(value) {
|
|
63
|
+
if (value.length < 2)
|
|
64
|
+
return value;
|
|
65
|
+
const first = value[0];
|
|
66
|
+
const last = value[value.length - 1];
|
|
67
|
+
return (first === '"' && last === '"') || (first === "'" && last === "'")
|
|
68
|
+
? value.slice(1, -1)
|
|
69
|
+
: value;
|
|
70
|
+
}
|
package/dist/external-browser.js
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
// Open one HTTPS URL without involving a shell or interpolating commands.
|
|
2
2
|
import { spawn } from "node:child_process";
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { resolveExecutable } from "./executable.js";
|
|
4
6
|
export async function openExternal(url) {
|
|
5
7
|
const target = new URL(url);
|
|
6
8
|
if (target.protocol !== "https:")
|
|
@@ -21,15 +23,26 @@ export async function openExternal(url) {
|
|
|
21
23
|
});
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
|
-
function browserCommand(url) {
|
|
26
|
+
export function browserCommand(url) {
|
|
25
27
|
if (process.platform === "win32") {
|
|
26
|
-
|
|
28
|
+
const windows = process.env["SystemRoot"] ?? process.env["WINDIR"];
|
|
29
|
+
const file = windows === undefined
|
|
30
|
+
? undefined
|
|
31
|
+
: resolveExecutable("rundll32.exe", {
|
|
32
|
+
searchPath: path.join(windows, "System32"),
|
|
33
|
+
rejectUnder: process.cwd(),
|
|
34
|
+
});
|
|
35
|
+
return file === undefined ? undefined : { file, args: ["url.dll,FileProtocolHandler", url] };
|
|
27
36
|
}
|
|
37
|
+
const name = launcherName();
|
|
38
|
+
const searchPath = process.platform === "darwin" ? "/usr/bin" : undefined;
|
|
39
|
+
const file = resolveExecutable(name, { searchPath, rejectUnder: process.cwd() });
|
|
40
|
+
return file === undefined ? undefined : { file, args: [url] };
|
|
41
|
+
}
|
|
42
|
+
function launcherName() {
|
|
28
43
|
if (process.platform === "darwin")
|
|
29
|
-
return
|
|
30
|
-
|
|
31
|
-
return { file: "explorer.exe", args: [url] };
|
|
32
|
-
return { file: "xdg-open", args: [url] };
|
|
44
|
+
return "open";
|
|
45
|
+
return isWsl() ? "explorer.exe" : "xdg-open";
|
|
33
46
|
}
|
|
34
47
|
export function headlessEnvironment() {
|
|
35
48
|
if (isWsl() || process.env["SSH_CONNECTION"] !== undefined || process.env["SSH_TTY"] !== undefined) {
|
package/dist/prompt.js
CHANGED
|
@@ -2,8 +2,6 @@
|
|
|
2
2
|
// gets rewritten far more often than the loop that carries it.
|
|
3
3
|
export function systemPrompt(config) {
|
|
4
4
|
return [
|
|
5
|
-
"You are jecode, a coding agent working in a terminal alongside the user.",
|
|
6
|
-
"",
|
|
7
5
|
`Workspace root: ${config.root}`,
|
|
8
6
|
`Platform: ${process.platform}`,
|
|
9
7
|
"",
|
package/dist/settings.js
CHANGED
|
@@ -6,6 +6,7 @@ import { atomicWrite } from "./atomic.js";
|
|
|
6
6
|
import { EFFORTS } from "./effort.js";
|
|
7
7
|
import { providerNames } from "./providers/index.js";
|
|
8
8
|
import { parseOllamaEndpoint } from "./providers/ollama-endpoint.js";
|
|
9
|
+
import { withStoreLock } from "./store-lock.js";
|
|
9
10
|
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
10
11
|
export { EFFORTS } from "./effort.js";
|
|
11
12
|
let saved;
|
|
@@ -15,15 +16,17 @@ export function readSettings() {
|
|
|
15
16
|
return copy(saved);
|
|
16
17
|
}
|
|
17
18
|
export async function updateSettings(patch) {
|
|
18
|
-
const next = normalize({ ...readSettings(), ...patch });
|
|
19
19
|
const file = settingsPath();
|
|
20
20
|
const directory = path.dirname(file);
|
|
21
21
|
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
22
22
|
if (process.platform !== "win32")
|
|
23
23
|
await chmod(directory, 0o700);
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
24
|
+
return withStoreLock(file, async () => {
|
|
25
|
+
const next = normalize({ ...readStore(file), ...patch });
|
|
26
|
+
await atomicWrite(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
|
|
27
|
+
saved = next;
|
|
28
|
+
return file;
|
|
29
|
+
});
|
|
27
30
|
}
|
|
28
31
|
export function settingsPath() {
|
|
29
32
|
return userDataPath("settings.json");
|
|
@@ -35,9 +38,9 @@ export function settingsLabel() {
|
|
|
35
38
|
export function reloadSettings() {
|
|
36
39
|
saved = undefined;
|
|
37
40
|
}
|
|
38
|
-
function readStore() {
|
|
41
|
+
function readStore(file = settingsPath()) {
|
|
39
42
|
try {
|
|
40
|
-
return normalize(JSON.parse(readFileSync(
|
|
43
|
+
return normalize(JSON.parse(readFileSync(file, "utf8")));
|
|
41
44
|
}
|
|
42
45
|
catch {
|
|
43
46
|
// Missing, unreadable, and malformed stores all fall back safely. A bad
|
|
@@ -1,24 +1,24 @@
|
|
|
1
|
-
// A tiny cross-process lock for
|
|
1
|
+
// A tiny cross-process lock for persistent user-store mutations.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
import { mkdir, open, readFile, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
3
|
+
// Every writer rereads inside the lock. That prevents two Jecode processes
|
|
4
|
+
// from replacing unrelated settings, API keys, or rotated OAuth credentials
|
|
5
|
+
// with snapshots they cached before the other process wrote.
|
|
7
6
|
import { randomUUID } from "node:crypto";
|
|
7
|
+
import { mkdir, open, readFile, rename, rmdir, stat, unlink } from "node:fs/promises";
|
|
8
8
|
import * as path from "node:path";
|
|
9
9
|
const WAIT_MS = 50;
|
|
10
10
|
const WAIT_LIMIT_MS = 20_000;
|
|
11
11
|
const STALE_MS = 60_000;
|
|
12
|
-
export async function
|
|
12
|
+
export async function withStoreLock(file, body, signal) {
|
|
13
13
|
throwIfAborted(signal);
|
|
14
|
-
const directory = `${
|
|
14
|
+
const directory = `${file}.lock`;
|
|
15
15
|
const token = `${process.pid}:${randomUUID()}`;
|
|
16
16
|
const started = Date.now();
|
|
17
17
|
while (!(await acquire(directory, token))) {
|
|
18
18
|
if (signal?.aborted === true)
|
|
19
19
|
throw abortReason(signal);
|
|
20
20
|
if (Date.now() - started >= WAIT_LIMIT_MS) {
|
|
21
|
-
throw new Error(
|
|
21
|
+
throw new Error(`timed out waiting for ${path.basename(file)}`);
|
|
22
22
|
}
|
|
23
23
|
await recoverStale(directory);
|
|
24
24
|
await wait(WAIT_MS, signal);
|
package/dist/tools/fs.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import { constants } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
|
-
import { optionalBool, optionalInt, requireString } from "./args.js";
|
|
5
|
+
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
6
6
|
import { assertDirectWritableInRoot, displayPath, resolveDirectWritableInRoot, resolveExistingInRoot, } from "./paths.js";
|
|
7
7
|
import { assertEditableText, assertReplacementFits, MAX_EDITABLE_CHARS, MAX_EDITABLE_LINES, readEditableText, } from "./text-boundary.js";
|
|
8
8
|
import { atomicWrite } from "../atomic.js";
|
|
@@ -15,6 +15,7 @@ export const readFile = {
|
|
|
15
15
|
description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
16
16
|
"(1-based) and cap how many lines come back. Large files are truncated.",
|
|
17
17
|
dangerous: false,
|
|
18
|
+
concurrency: "shared",
|
|
18
19
|
input: {
|
|
19
20
|
type: "object",
|
|
20
21
|
properties: {
|
|
@@ -45,6 +46,7 @@ export const listDir = {
|
|
|
45
46
|
name: "list_dir",
|
|
46
47
|
description: "List the entries of a directory inside the workspace. Directories end with a slash.",
|
|
47
48
|
dangerous: false,
|
|
49
|
+
concurrency: "shared",
|
|
48
50
|
input: {
|
|
49
51
|
type: "object",
|
|
50
52
|
properties: {
|
|
@@ -54,7 +56,9 @@ export const listDir = {
|
|
|
54
56
|
},
|
|
55
57
|
async run(args, ctx) {
|
|
56
58
|
const root = await resolveExistingInRoot(ctx.root, ".");
|
|
57
|
-
const
|
|
59
|
+
const requested = optionalString(args, "path");
|
|
60
|
+
const relative = requested === undefined || requested.trim() === "" ? "." : requested;
|
|
61
|
+
const target = await resolveExistingInRoot(root, relative);
|
|
58
62
|
const entries = [];
|
|
59
63
|
let chars = 0;
|
|
60
64
|
let truncated = false;
|
|
@@ -91,6 +95,7 @@ export const writeFile = {
|
|
|
91
95
|
`${MAX_EDITABLE_CHARS} characters and ${MAX_EDITABLE_LINES} lines. ` +
|
|
92
96
|
"To change part of an existing file, prefer edit_file.",
|
|
93
97
|
dangerous: true,
|
|
98
|
+
concurrency: "exclusive",
|
|
94
99
|
input: {
|
|
95
100
|
type: "object",
|
|
96
101
|
properties: {
|
|
@@ -131,6 +136,7 @@ export const editFile = {
|
|
|
131
136
|
`Whole-file changes are limited to ${MAX_EDITABLE_CHARS} characters and ` +
|
|
132
137
|
`${MAX_EDITABLE_LINES} lines.`,
|
|
133
138
|
dangerous: true,
|
|
139
|
+
concurrency: "exclusive",
|
|
134
140
|
input: {
|
|
135
141
|
type: "object",
|
|
136
142
|
properties: {
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// Optional native acceleration for bounded literal search.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import * as path from "node:path";
|
|
4
|
+
import { shellEnvironment } from "../credential-safety.js";
|
|
5
|
+
import { resolveExecutable } from "../executable.js";
|
|
6
|
+
const MAX_BATCH_FILES = 256;
|
|
7
|
+
const MAX_BATCH_BYTES = 16_000_000;
|
|
8
|
+
const MAX_COMMAND_CHARS = 12_000;
|
|
9
|
+
const MAX_QUERY_CHARS = 4_000;
|
|
10
|
+
const MAX_EVENT_CHARS = 8_000_000;
|
|
11
|
+
const MAX_STDERR_CHARS = 16_000;
|
|
12
|
+
/** Return undefined when ripgrep is unavailable or cannot preserve the contract. */
|
|
13
|
+
export async function trySearchWithRipgrep(options) {
|
|
14
|
+
if (options.files.length === 0)
|
|
15
|
+
return { matches: [], binaryPaths: [] };
|
|
16
|
+
if (options.query.length > MAX_QUERY_CHARS || options.query.includes("\0"))
|
|
17
|
+
return undefined;
|
|
18
|
+
const executable = resolveExecutable("rg", { rejectUnder: options.root });
|
|
19
|
+
if (executable === undefined)
|
|
20
|
+
return undefined;
|
|
21
|
+
const matches = [];
|
|
22
|
+
const binaryPaths = new Set();
|
|
23
|
+
try {
|
|
24
|
+
for (const batch of batches(options.files, options.query.length)) {
|
|
25
|
+
throwIfAborted(options.signal);
|
|
26
|
+
const searched = await searchBatch(executable, batch, options);
|
|
27
|
+
if (searched === undefined)
|
|
28
|
+
return undefined;
|
|
29
|
+
for (const match of searched.matches) {
|
|
30
|
+
if (matches.length >= options.limit)
|
|
31
|
+
break;
|
|
32
|
+
matches.push(match);
|
|
33
|
+
}
|
|
34
|
+
for (const file of searched.binaryPaths)
|
|
35
|
+
binaryPaths.add(file);
|
|
36
|
+
if (matches.length >= options.limit)
|
|
37
|
+
break;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
catch (error) {
|
|
41
|
+
if (options.signal?.aborted === true)
|
|
42
|
+
throw abortReason(options.signal);
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
45
|
+
return { matches, binaryPaths: [...binaryPaths] };
|
|
46
|
+
}
|
|
47
|
+
function batches(files, queryChars) {
|
|
48
|
+
const groups = [];
|
|
49
|
+
let group = [];
|
|
50
|
+
let bytes = 0;
|
|
51
|
+
let chars = queryChars;
|
|
52
|
+
for (const file of files) {
|
|
53
|
+
const nextChars = chars + file.path.length + 1;
|
|
54
|
+
if (group.length > 0 &&
|
|
55
|
+
(group.length >= MAX_BATCH_FILES ||
|
|
56
|
+
bytes + file.bytes > MAX_BATCH_BYTES ||
|
|
57
|
+
nextChars > MAX_COMMAND_CHARS)) {
|
|
58
|
+
groups.push(group);
|
|
59
|
+
group = [];
|
|
60
|
+
bytes = 0;
|
|
61
|
+
chars = queryChars;
|
|
62
|
+
}
|
|
63
|
+
group.push(file);
|
|
64
|
+
bytes += file.bytes;
|
|
65
|
+
chars += file.path.length + 1;
|
|
66
|
+
}
|
|
67
|
+
if (group.length > 0)
|
|
68
|
+
groups.push(group);
|
|
69
|
+
return groups;
|
|
70
|
+
}
|
|
71
|
+
function searchBatch(executable, files, options) {
|
|
72
|
+
throwIfAborted(options.signal);
|
|
73
|
+
const args = [
|
|
74
|
+
"--json",
|
|
75
|
+
"--no-config",
|
|
76
|
+
"--fixed-strings",
|
|
77
|
+
"--max-filesize",
|
|
78
|
+
"1000000",
|
|
79
|
+
"--max-count",
|
|
80
|
+
String(options.limit),
|
|
81
|
+
...(options.caseSensitive ? [] : ["--ignore-case"]),
|
|
82
|
+
"--",
|
|
83
|
+
options.query,
|
|
84
|
+
...files.map((file) => file.path),
|
|
85
|
+
];
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let child;
|
|
88
|
+
try {
|
|
89
|
+
child = spawn(executable, args, {
|
|
90
|
+
cwd: path.dirname(executable),
|
|
91
|
+
env: shellEnvironment(),
|
|
92
|
+
windowsHide: true,
|
|
93
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
resolve(undefined);
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
const matches = [];
|
|
101
|
+
const binaryPaths = new Set();
|
|
102
|
+
let buffered = "";
|
|
103
|
+
let stderr = "";
|
|
104
|
+
let invalid = false;
|
|
105
|
+
let settled = false;
|
|
106
|
+
const onAbort = () => child.kill();
|
|
107
|
+
options.signal?.addEventListener("abort", onAbort, { once: true });
|
|
108
|
+
if (options.signal?.aborted === true)
|
|
109
|
+
onAbort();
|
|
110
|
+
const finish = (value, error) => {
|
|
111
|
+
if (settled)
|
|
112
|
+
return;
|
|
113
|
+
settled = true;
|
|
114
|
+
options.signal?.removeEventListener("abort", onAbort);
|
|
115
|
+
if (error !== undefined)
|
|
116
|
+
reject(error);
|
|
117
|
+
else
|
|
118
|
+
resolve(value);
|
|
119
|
+
};
|
|
120
|
+
const consume = (line) => {
|
|
121
|
+
if (line === "" || invalid)
|
|
122
|
+
return;
|
|
123
|
+
try {
|
|
124
|
+
const event = JSON.parse(line);
|
|
125
|
+
const parsed = ripgrepEvent(event);
|
|
126
|
+
if (parsed?.kind === "match")
|
|
127
|
+
matches.push(parsed.match);
|
|
128
|
+
if (parsed?.kind === "binary")
|
|
129
|
+
binaryPaths.add(parsed.path);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
invalid = true;
|
|
133
|
+
child.kill();
|
|
134
|
+
}
|
|
135
|
+
};
|
|
136
|
+
child.stdout.setEncoding("utf8");
|
|
137
|
+
child.stderr.setEncoding("utf8");
|
|
138
|
+
child.stdout.on("data", (chunk) => {
|
|
139
|
+
buffered += chunk;
|
|
140
|
+
if (buffered.length > MAX_EVENT_CHARS) {
|
|
141
|
+
invalid = true;
|
|
142
|
+
child.kill();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
let newline = buffered.indexOf("\n");
|
|
146
|
+
while (newline !== -1) {
|
|
147
|
+
consume(buffered.slice(0, newline));
|
|
148
|
+
buffered = buffered.slice(newline + 1);
|
|
149
|
+
newline = buffered.indexOf("\n");
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
child.stderr.on("data", (chunk) => {
|
|
153
|
+
stderr = `${stderr}${chunk}`.slice(-MAX_STDERR_CHARS);
|
|
154
|
+
});
|
|
155
|
+
child.on("error", (error) => {
|
|
156
|
+
if (options.signal?.aborted === true)
|
|
157
|
+
finish(undefined, abortReason(options.signal));
|
|
158
|
+
else if (error.code === "ENOENT")
|
|
159
|
+
finish(undefined);
|
|
160
|
+
else
|
|
161
|
+
finish(undefined);
|
|
162
|
+
});
|
|
163
|
+
child.on("close", (code) => {
|
|
164
|
+
if (options.signal?.aborted === true) {
|
|
165
|
+
finish(undefined, abortReason(options.signal));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
consume(buffered);
|
|
169
|
+
if (invalid || (code !== 0 && code !== 1) || stderr.trim() !== "") {
|
|
170
|
+
finish(undefined);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const order = new Map(files.map((file, index) => [file.path, index]));
|
|
174
|
+
finish({
|
|
175
|
+
matches: matches
|
|
176
|
+
.filter((match) => !binaryPaths.has(match.path))
|
|
177
|
+
.sort((a, b) => ((order.get(a.path) ?? Number.MAX_SAFE_INTEGER) -
|
|
178
|
+
(order.get(b.path) ?? Number.MAX_SAFE_INTEGER) ||
|
|
179
|
+
a.line - b.line)),
|
|
180
|
+
binaryPaths: [...binaryPaths],
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
function ripgrepEvent(value) {
|
|
186
|
+
if (!record(value) || !record(value["data"]))
|
|
187
|
+
return undefined;
|
|
188
|
+
const data = value["data"];
|
|
189
|
+
const file = textField(data["path"]);
|
|
190
|
+
if (file === undefined)
|
|
191
|
+
return undefined;
|
|
192
|
+
if (value["type"] === "match") {
|
|
193
|
+
const line = data["line_number"];
|
|
194
|
+
const text = textField(data["lines"]);
|
|
195
|
+
if (typeof line !== "number" || !Number.isInteger(line) || text === undefined)
|
|
196
|
+
return undefined;
|
|
197
|
+
return {
|
|
198
|
+
kind: "match",
|
|
199
|
+
match: { path: file, line, text: text.replace(/\r?\n$/, "") },
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
if (value["type"] === "end" && typeof data["binary_offset"] === "number") {
|
|
203
|
+
return { kind: "binary", path: file };
|
|
204
|
+
}
|
|
205
|
+
return undefined;
|
|
206
|
+
}
|
|
207
|
+
function textField(value) {
|
|
208
|
+
return record(value) && typeof value["text"] === "string" ? value["text"] : undefined;
|
|
209
|
+
}
|
|
210
|
+
function record(value) {
|
|
211
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
212
|
+
}
|
|
213
|
+
function throwIfAborted(signal) {
|
|
214
|
+
if (signal?.aborted === true)
|
|
215
|
+
throw abortReason(signal);
|
|
216
|
+
}
|
|
217
|
+
function abortReason(signal) {
|
|
218
|
+
return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
219
|
+
}
|
package/dist/tools/search.js
CHANGED
|
@@ -3,18 +3,24 @@ import * as fs from "node:fs/promises";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { optionalBool, optionalInt, optionalString, requireString } from "./args.js";
|
|
5
5
|
import { displayPath, resolveExistingInRoot } from "./paths.js";
|
|
6
|
+
import { trySearchWithRipgrep } from "./ripgrep.js";
|
|
6
7
|
const DEFAULT_RESULTS = 100;
|
|
7
8
|
const MAX_RESULTS = 500;
|
|
8
9
|
const MAX_VISITED = 20_000;
|
|
9
10
|
const MAX_FILE_BYTES = 1_000_000;
|
|
10
11
|
const MAX_MATCH_LINE = 500;
|
|
11
12
|
const MAX_GLOB_CHARS = 512;
|
|
13
|
+
const RG_PREFIX_BYTES = 2_000_000;
|
|
14
|
+
const RG_PREFIX_FILES = 500;
|
|
15
|
+
const MIN_RG_TAIL_BYTES = 2_000_000;
|
|
16
|
+
const MIN_RG_TAIL_FILES = 500;
|
|
12
17
|
const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
|
|
13
18
|
export const findFiles = {
|
|
14
19
|
name: "find_files",
|
|
15
20
|
description: "Find files inside the workspace by glob (for example **/*.ts). Skips dependency and VCS " +
|
|
16
21
|
"directories, never follows symlinks, and returns a bounded list.",
|
|
17
22
|
dangerous: false,
|
|
23
|
+
concurrency: "shared",
|
|
18
24
|
input: {
|
|
19
25
|
type: "object",
|
|
20
26
|
properties: {
|
|
@@ -51,6 +57,7 @@ export const searchText = {
|
|
|
51
57
|
description: "Search UTF-8 text files inside the workspace for a literal string. Skips dependencies, VCS " +
|
|
52
58
|
"directories, symlinks, binary files, and files over 1 MB; results are bounded.",
|
|
53
59
|
dangerous: false,
|
|
60
|
+
concurrency: "shared",
|
|
54
61
|
input: {
|
|
55
62
|
type: "object",
|
|
56
63
|
properties: {
|
|
@@ -75,7 +82,11 @@ export const searchText = {
|
|
|
75
82
|
const match = pattern === undefined || pattern === "" ? () => true : glob(pattern);
|
|
76
83
|
const limit = resultLimit(args);
|
|
77
84
|
const found = [];
|
|
85
|
+
const tail = [];
|
|
78
86
|
let skipped = 0;
|
|
87
|
+
let prefixBytes = 0;
|
|
88
|
+
let prefixFiles = 0;
|
|
89
|
+
let tailBytes = 0;
|
|
79
90
|
const walked = await walk(start, scoped, async (lexical) => {
|
|
80
91
|
const relative = displayPath(scoped.root, lexical);
|
|
81
92
|
if (!match(relative))
|
|
@@ -86,33 +97,35 @@ export const searchText = {
|
|
|
86
97
|
skipped++;
|
|
87
98
|
return false;
|
|
88
99
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
}
|
|
96
|
-
text = data.toString("utf8");
|
|
97
|
-
}
|
|
98
|
-
catch (error) {
|
|
99
|
-
if (skippable(error)) {
|
|
100
|
-
skipped++;
|
|
101
|
-
return false;
|
|
102
|
-
}
|
|
103
|
-
throw error;
|
|
104
|
-
}
|
|
105
|
-
for (const [index, line] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
|
|
106
|
-
checkAbort(ctx.signal);
|
|
107
|
-
const haystack = sensitive ? line : line.toLocaleLowerCase();
|
|
108
|
-
if (!haystack.includes(needle))
|
|
109
|
-
continue;
|
|
110
|
-
found.push(`${relative}:${index + 1}:${clip(line)}`);
|
|
111
|
-
if (found.length >= limit)
|
|
112
|
-
return true;
|
|
100
|
+
const candidate = { path: file, bytes: info.size };
|
|
101
|
+
if (prefixFiles + 1 > RG_PREFIX_FILES ||
|
|
102
|
+
prefixBytes + info.size > RG_PREFIX_BYTES) {
|
|
103
|
+
tail.push(candidate);
|
|
104
|
+
tailBytes += info.size;
|
|
105
|
+
return false;
|
|
113
106
|
}
|
|
114
|
-
|
|
107
|
+
prefixFiles++;
|
|
108
|
+
prefixBytes += info.size;
|
|
109
|
+
const searched = await portableSearch([candidate], scoped, needle, sensitive, limit - found.length);
|
|
110
|
+
found.push(...searched.matches);
|
|
111
|
+
skipped += searched.skipped;
|
|
112
|
+
return found.length >= limit;
|
|
115
113
|
});
|
|
114
|
+
const accelerated = preferRipgrep(tail, tailBytes)
|
|
115
|
+
? await trySearchWithRipgrep({
|
|
116
|
+
root: scoped.root,
|
|
117
|
+
files: tail,
|
|
118
|
+
query,
|
|
119
|
+
caseSensitive: sensitive,
|
|
120
|
+
limit: limit - found.length,
|
|
121
|
+
signal: ctx.signal,
|
|
122
|
+
})
|
|
123
|
+
: undefined;
|
|
124
|
+
const portable = accelerated === undefined && tail.length > 0
|
|
125
|
+
? await portableSearch(tail, scoped, needle, sensitive, limit - found.length)
|
|
126
|
+
: undefined;
|
|
127
|
+
found.push(...(portable?.matches ?? accelerated?.matches.map((match) => (`${displayPath(scoped.root, match.path)}:${match.line}:${clip(match.text)}`)) ?? []));
|
|
128
|
+
skipped += portable?.skipped ?? accelerated?.binaryPaths.length ?? 0;
|
|
116
129
|
const extra = skipped === 0 ? "" : ` · skipped ${skipped} binary/large/unreadable`;
|
|
117
130
|
return {
|
|
118
131
|
output: found.length === 0 ? "[no matches]" : found.join("\n"),
|
|
@@ -120,6 +133,42 @@ export const searchText = {
|
|
|
120
133
|
};
|
|
121
134
|
},
|
|
122
135
|
};
|
|
136
|
+
async function portableSearch(files, ctx, needle, sensitive, limit) {
|
|
137
|
+
const found = [];
|
|
138
|
+
let skipped = 0;
|
|
139
|
+
for (const file of files) {
|
|
140
|
+
checkAbort(ctx.signal);
|
|
141
|
+
let text;
|
|
142
|
+
try {
|
|
143
|
+
const data = await fs.readFile(file.path);
|
|
144
|
+
if (data.includes(0)) {
|
|
145
|
+
skipped++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
text = data.toString("utf8");
|
|
149
|
+
}
|
|
150
|
+
catch (error) {
|
|
151
|
+
if (skippable(error)) {
|
|
152
|
+
skipped++;
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
throw error;
|
|
156
|
+
}
|
|
157
|
+
for (const [index, line] of text.replace(/\r\n?/g, "\n").split("\n").entries()) {
|
|
158
|
+
checkAbort(ctx.signal);
|
|
159
|
+
const haystack = sensitive ? line : line.toLocaleLowerCase();
|
|
160
|
+
if (!haystack.includes(needle))
|
|
161
|
+
continue;
|
|
162
|
+
found.push(`${displayPath(ctx.root, file.path)}:${index + 1}:${clip(line)}`);
|
|
163
|
+
if (found.length >= limit)
|
|
164
|
+
return { matches: found, skipped };
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { matches: found, skipped };
|
|
168
|
+
}
|
|
169
|
+
function preferRipgrep(files, bytes) {
|
|
170
|
+
return files.length >= MIN_RG_TAIL_FILES || bytes >= MIN_RG_TAIL_BYTES;
|
|
171
|
+
}
|
|
123
172
|
async function walk(start, ctx, visit) {
|
|
124
173
|
const pending = [start];
|
|
125
174
|
let seen = 0;
|
package/dist/tools/shell.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
// Shell tool. One command, captured output, a timeout, and a hard cap on how
|
|
2
2
|
// much of it comes back.
|
|
3
3
|
import { spawn } from "node:child_process";
|
|
4
|
+
import * as path from "node:path";
|
|
4
5
|
import { optionalInt, requireString } from "./args.js";
|
|
5
6
|
import { credentialRedactor, redactCredentials, shellEnvironment } from "../credential-safety.js";
|
|
7
|
+
import { resolveExecutable } from "../executable.js";
|
|
6
8
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
7
9
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
8
10
|
const MAX_OUTPUT_CHARS = 30_000;
|
|
@@ -13,6 +15,7 @@ export const runCommand = {
|
|
|
13
15
|
"and stderr. The shell is not a filesystem sandbox, so calls ask for approval by default. Output is " +
|
|
14
16
|
"truncated past 30000 characters.",
|
|
15
17
|
dangerous: true,
|
|
18
|
+
concurrency: "exclusive",
|
|
16
19
|
input: {
|
|
17
20
|
type: "object",
|
|
18
21
|
properties: {
|
|
@@ -61,13 +64,15 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
|
61
64
|
let drainTimer;
|
|
62
65
|
const timer = setTimeout(() => {
|
|
63
66
|
timedOut = true;
|
|
64
|
-
|
|
65
|
-
forceTimer = setTimeout(() => stopTree(child.pid, true), 500);
|
|
67
|
+
requestStop();
|
|
66
68
|
}, timeoutMs);
|
|
69
|
+
const requestStop = () => {
|
|
70
|
+
stopTree(child.pid, false);
|
|
71
|
+
forceTimer ??= setTimeout(() => stopTree(child.pid, true), 500);
|
|
72
|
+
};
|
|
67
73
|
const onAbort = () => {
|
|
68
74
|
aborted = signal === undefined ? new Error("aborted") : abortReason(signal);
|
|
69
|
-
|
|
70
|
-
forceTimer = setTimeout(() => stopTree(child.pid, true), 500);
|
|
75
|
+
requestStop();
|
|
71
76
|
};
|
|
72
77
|
signal?.addEventListener("abort", onAbort, { once: true });
|
|
73
78
|
const cleanup = () => {
|
|
@@ -100,6 +105,11 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
|
100
105
|
reject(error);
|
|
101
106
|
});
|
|
102
107
|
child.on("exit", (code) => {
|
|
108
|
+
// The group can outlive its leader. Once a requested stop makes the
|
|
109
|
+
// shell exit, force the remaining descendants before cleanup cancels the
|
|
110
|
+
// fallback timer.
|
|
111
|
+
if (timedOut || aborted !== undefined)
|
|
112
|
+
stopTree(child.pid, true);
|
|
103
113
|
// `close` normally follows once both pipes drain. A detached descendant
|
|
104
114
|
// can inherit those descriptors after the command itself has exited,
|
|
105
115
|
// though, so bound that final drain instead of hanging the tool on it.
|
|
@@ -152,8 +162,28 @@ function stopTree(pid, force) {
|
|
|
152
162
|
return;
|
|
153
163
|
if (process.platform === "win32") {
|
|
154
164
|
const args = ["/pid", String(pid), "/T", ...(force ? ["/F"] : [])];
|
|
155
|
-
const
|
|
156
|
-
|
|
165
|
+
const windows = process.env["SystemRoot"] ?? process.env["WINDIR"];
|
|
166
|
+
const taskkill = windows === undefined
|
|
167
|
+
? undefined
|
|
168
|
+
: resolveExecutable("taskkill.exe", {
|
|
169
|
+
searchPath: path.join(windows, "System32"),
|
|
170
|
+
rejectUnder: process.cwd(),
|
|
171
|
+
});
|
|
172
|
+
if (taskkill !== undefined) {
|
|
173
|
+
const killer = spawn(taskkill, args, {
|
|
174
|
+
cwd: path.dirname(taskkill),
|
|
175
|
+
windowsHide: true,
|
|
176
|
+
stdio: "ignore",
|
|
177
|
+
});
|
|
178
|
+
killer.on("error", () => undefined);
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
try {
|
|
182
|
+
process.kill(pid, force ? "SIGKILL" : "SIGTERM");
|
|
183
|
+
}
|
|
184
|
+
catch {
|
|
185
|
+
// The process may already be gone, or the platform helper unavailable.
|
|
186
|
+
}
|
|
157
187
|
return;
|
|
158
188
|
}
|
|
159
189
|
try {
|
package/dist/transcript.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// A portable transcript: screen blocks in, Markdown out.
|
|
2
2
|
import { terminalText } from "./ui/terminal-text.js";
|
|
3
3
|
export function defaultTranscriptName(now = new Date()) {
|
|
4
|
-
return `jecode-transcript-${now.toISOString().replace(/[
|
|
4
|
+
return `jecode-transcript-${now.toISOString().replace(/[-:.]/g, "")}.md`;
|
|
5
5
|
}
|
|
6
6
|
export function transcriptMarkdown(blocks) {
|
|
7
7
|
const out = ["# jecode transcript", ""];
|
|
@@ -136,7 +136,7 @@ function statusInk(tone, pal) {
|
|
|
136
136
|
return pal.ink.removed;
|
|
137
137
|
if (tone === "pending" || tone === "deny")
|
|
138
138
|
return pal.ink.attention;
|
|
139
|
-
return pal.ink.
|
|
139
|
+
return pal.ink.added;
|
|
140
140
|
}
|
|
141
141
|
function failureLine(line) {
|
|
142
142
|
return line.startsWith("✖") || line.startsWith("×") || line.includes("AssertionError") || /\bfailed\b/i.test(line);
|
package/dist/ui/theme.js
CHANGED
|
@@ -1,25 +1,26 @@
|
|
|
1
1
|
// Jecode's fixed terminal identity. Components depend on semantic tokens, not
|
|
2
2
|
// literal colours, while the product exposes one deliberate dark Steel look.
|
|
3
|
-
// Jecode's fixed dark Steel baseline.
|
|
4
|
-
//
|
|
3
|
+
// Jecode's fixed dark Steel baseline. Structural blues, semantic outcomes,
|
|
4
|
+
// and slate surfaces keep the transcript vivid without turning it decorative.
|
|
5
|
+
// Components depend on these roles rather than embedding presentation values.
|
|
5
6
|
export const STEEL = {
|
|
6
|
-
accent: [
|
|
7
|
-
accentSoft: [
|
|
8
|
-
focus: [
|
|
9
|
-
rule: [
|
|
7
|
+
accent: [102, 155, 210],
|
|
8
|
+
accentSoft: [131, 213, 245],
|
|
9
|
+
focus: [102, 155, 210],
|
|
10
|
+
rule: [53, 80, 110],
|
|
10
11
|
ink: {
|
|
11
|
-
fg: [212,
|
|
12
|
-
bright: [
|
|
13
|
-
muted: [
|
|
14
|
-
attention: [
|
|
15
|
-
added: [
|
|
16
|
-
removed: [
|
|
12
|
+
fg: [212, 218, 225],
|
|
13
|
+
bright: [235, 239, 244],
|
|
14
|
+
muted: [112, 124, 137],
|
|
15
|
+
attention: [230, 191, 95],
|
|
16
|
+
added: [134, 203, 146],
|
|
17
|
+
removed: [232, 112, 112],
|
|
17
18
|
},
|
|
18
19
|
surface: {
|
|
19
|
-
subtle: [
|
|
20
|
-
inset: [
|
|
21
|
-
added: [
|
|
22
|
-
removed: [
|
|
23
|
-
attention: [
|
|
20
|
+
subtle: [31, 38, 47],
|
|
21
|
+
inset: [18, 24, 31],
|
|
22
|
+
added: [22, 55, 34],
|
|
23
|
+
removed: [62, 24, 27],
|
|
24
|
+
attention: [62, 50, 19],
|
|
24
25
|
},
|
|
25
26
|
};
|