@giovannijecha/jecode 0.2.4 → 0.3.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 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.2.x release. The core loop is usable today; commands and
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. There are no hidden workers or delegated agents.
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 { withAccountLock } from "./account-lock.js";
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 withAccountLock(file, async () => {
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 };
@@ -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
- // Calls run one after another because approval prompts serialise anyway,
48
- // but every result from this step goes back in a SINGLE message. Splitting
49
- // them teaches the model to stop batching its calls.
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
- for (let index = 0; index < calls.length; index++) {
56
- throwIfAborted(signal);
57
- const call = calls[index];
58
- events.onToolProgress?.(index + 1, calls.length);
59
- const preview = await look(call, options, signal);
60
- throwIfAborted(signal);
61
- announced.add(call.id);
62
- events.onToolCall(call, preview);
63
- const { result, summary } = await settle(call, options, events, signal, preview);
64
- results.push(result);
65
- events.onToolResult(call, result, summary);
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) {
@@ -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
- const all = { ...fromDisk(), [name]: value };
73
- await persist(file, all);
74
- hold(name, value);
75
- saved = all;
76
- return file;
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 persist(file, all);
86
- saved = all;
87
- return true;
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 persist(file, values) {
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
+ }
@@ -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
- return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
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 { file: "open", args: [url] };
30
- if (isWsl())
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/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
- await atomicWrite(file, `${JSON.stringify(next, null, 2)}\n`, { mode: 0o600 });
25
- saved = next;
26
- return file;
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(settingsPath(), "utf8")));
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 rotating OAuth credentials.
1
+ // A tiny cross-process lock for persistent user-store mutations.
2
2
  //
3
- // Refresh tokens may rotate after one use. Two Jecode processes refreshing
4
- // the same account concurrently would make one of them persist a dead token,
5
- // so account mutations serialize through an atomic lock directory.
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, 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 withAccountLock(accountFile, body, signal) {
12
+ export async function withStoreLock(file, body, signal) {
13
13
  throwIfAborted(signal);
14
- const directory = `${accountFile}.lock`;
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("timed out waiting for the account store");
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);
@@ -59,10 +59,12 @@ async function recoverStale(directory) {
59
59
  const details = await stat(directory);
60
60
  if (Date.now() - details.mtimeMs < STALE_MS)
61
61
  return;
62
- const quarantined = `${directory}.${randomUUID()}.stale`;
63
- await rename(directory, quarantined);
64
- await unlink(path.join(quarantined, "owner")).catch(() => undefined);
65
- await rmdir(quarantined).catch(() => undefined);
62
+ if (await ownerIsAlive(directory))
63
+ return;
64
+ // Remove the owner first, then the now-empty directory. `rmdir` cannot
65
+ // erase a fresh lock acquired after another waiter wins this recovery,
66
+ // whereas renaming the shared path can steal that new lock in an ABA race.
67
+ await removeLock(directory);
66
68
  }
67
69
  catch (error) {
68
70
  const code = error.code;
@@ -70,6 +72,36 @@ async function recoverStale(directory) {
70
72
  throw error;
71
73
  }
72
74
  }
75
+ async function ownerIsAlive(directory) {
76
+ let token;
77
+ try {
78
+ token = await readFile(path.join(directory, "owner"), "utf8");
79
+ }
80
+ catch (error) {
81
+ const code = error.code;
82
+ if (code === "ENOENT")
83
+ return false;
84
+ if (code === "EACCES" || code === "EPERM")
85
+ return true;
86
+ throw error;
87
+ }
88
+ const match = /^([1-9]\d*):/.exec(token.trim());
89
+ if (match === null)
90
+ return false;
91
+ const pid = Number(match[1]);
92
+ if (!Number.isSafeInteger(pid) || pid > 0x7fff_ffff)
93
+ return false;
94
+ try {
95
+ process.kill(pid, 0);
96
+ return true;
97
+ }
98
+ catch (error) {
99
+ // ESRCH is the one portable proof that the owner no longer exists.
100
+ // Permission failures and unknown platform errors must not authorize a
101
+ // second writer to enter the same store.
102
+ return error.code !== "ESRCH";
103
+ }
104
+ }
73
105
  async function release(directory, token) {
74
106
  try {
75
107
  const owner = path.join(directory, "owner");
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 target = await resolveExistingInRoot(root, args.path === undefined ? "." : requireString(args, "path"));
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,230 @@
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 overLimit = false;
106
+ let settled = false;
107
+ const onAbort = () => child.kill();
108
+ options.signal?.addEventListener("abort", onAbort, { once: true });
109
+ if (options.signal?.aborted === true)
110
+ onAbort();
111
+ const finish = (value, error) => {
112
+ if (settled)
113
+ return;
114
+ settled = true;
115
+ options.signal?.removeEventListener("abort", onAbort);
116
+ if (error !== undefined)
117
+ reject(error);
118
+ else
119
+ resolve(value);
120
+ };
121
+ const consume = (line) => {
122
+ if (line === "" || invalid || overLimit)
123
+ return;
124
+ try {
125
+ const event = JSON.parse(line);
126
+ const parsed = ripgrepEvent(event);
127
+ if (parsed?.kind === "match") {
128
+ // `rg --max-count` is per file, not global. Once the raw stream
129
+ // exceeds the requested result count, stop the accelerator and let
130
+ // the portable scanner produce the exact bounded answer. Returning
131
+ // early here would lose the later binary-file end marker.
132
+ if (matches.length >= options.limit) {
133
+ overLimit = true;
134
+ child.kill();
135
+ return;
136
+ }
137
+ matches.push(parsed.match);
138
+ }
139
+ if (parsed?.kind === "binary")
140
+ binaryPaths.add(parsed.path);
141
+ }
142
+ catch {
143
+ invalid = true;
144
+ child.kill();
145
+ }
146
+ };
147
+ child.stdout.setEncoding("utf8");
148
+ child.stderr.setEncoding("utf8");
149
+ child.stdout.on("data", (chunk) => {
150
+ buffered += chunk;
151
+ if (buffered.length > MAX_EVENT_CHARS) {
152
+ invalid = true;
153
+ child.kill();
154
+ return;
155
+ }
156
+ let newline = buffered.indexOf("\n");
157
+ while (newline !== -1) {
158
+ consume(buffered.slice(0, newline));
159
+ buffered = buffered.slice(newline + 1);
160
+ newline = buffered.indexOf("\n");
161
+ }
162
+ });
163
+ child.stderr.on("data", (chunk) => {
164
+ stderr = `${stderr}${chunk}`.slice(-MAX_STDERR_CHARS);
165
+ });
166
+ child.on("error", (error) => {
167
+ if (options.signal?.aborted === true)
168
+ finish(undefined, abortReason(options.signal));
169
+ else if (error.code === "ENOENT")
170
+ finish(undefined);
171
+ else
172
+ finish(undefined);
173
+ });
174
+ child.on("close", (code) => {
175
+ if (options.signal?.aborted === true) {
176
+ finish(undefined, abortReason(options.signal));
177
+ return;
178
+ }
179
+ consume(buffered);
180
+ if (invalid || overLimit || (code !== 0 && code !== 1) || stderr.trim() !== "") {
181
+ finish(undefined);
182
+ return;
183
+ }
184
+ const order = new Map(files.map((file, index) => [file.path, index]));
185
+ finish({
186
+ matches: matches
187
+ .filter((match) => !binaryPaths.has(match.path))
188
+ .sort((a, b) => ((order.get(a.path) ?? Number.MAX_SAFE_INTEGER) -
189
+ (order.get(b.path) ?? Number.MAX_SAFE_INTEGER) ||
190
+ a.line - b.line)),
191
+ binaryPaths: [...binaryPaths],
192
+ });
193
+ });
194
+ });
195
+ }
196
+ function ripgrepEvent(value) {
197
+ if (!record(value) || !record(value["data"]))
198
+ return undefined;
199
+ const data = value["data"];
200
+ const file = textField(data["path"]);
201
+ if (file === undefined)
202
+ return undefined;
203
+ if (value["type"] === "match") {
204
+ const line = data["line_number"];
205
+ const text = textField(data["lines"]);
206
+ if (typeof line !== "number" || !Number.isInteger(line) || text === undefined)
207
+ return undefined;
208
+ return {
209
+ kind: "match",
210
+ match: { path: file, line, text: text.replace(/\r?\n$/, "") },
211
+ };
212
+ }
213
+ if (value["type"] === "end" && typeof data["binary_offset"] === "number") {
214
+ return { kind: "binary", path: file };
215
+ }
216
+ return undefined;
217
+ }
218
+ function textField(value) {
219
+ return record(value) && typeof value["text"] === "string" ? value["text"] : undefined;
220
+ }
221
+ function record(value) {
222
+ return typeof value === "object" && value !== null && !Array.isArray(value);
223
+ }
224
+ function throwIfAborted(signal) {
225
+ if (signal?.aborted === true)
226
+ throw abortReason(signal);
227
+ }
228
+ function abortReason(signal) {
229
+ return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
230
+ }
@@ -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
- let text;
90
- try {
91
- const data = await fs.readFile(file);
92
- if (data.includes(0)) {
93
- skipped++;
94
- return false;
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
- return false;
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;
@@ -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
- stopTree(child.pid, false);
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
- stopTree(child.pid, false);
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 killer = spawn("taskkill", args, { windowsHide: true, stdio: "ignore" });
156
- killer.on("error", () => undefined);
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 {
@@ -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(/[-:]/g, "").replace(/\.\d{3}Z$/, "Z")}.md`;
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.muted;
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. Components depend on these semantic
4
- // roles rather than embedding presentation values of their own.
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: [138, 190, 183],
7
- accentSoft: [0, 215, 255],
8
- focus: [95, 135, 255],
9
- rule: [80, 80, 80],
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, 212, 212],
12
- bright: [212, 212, 212],
13
- muted: [128, 128, 128],
14
- attention: [255, 255, 0],
15
- added: [181, 189, 104],
16
- removed: [204, 102, 102],
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: [52, 53, 65],
20
- inset: [40, 40, 50],
21
- added: [40, 50, 40],
22
- removed: [60, 40, 40],
23
- attention: [58, 58, 74],
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
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@giovannijecha/jecode",
3
- "version": "0.2.4",
3
+ "version": "0.3.1",
4
4
  "description": "An owned coding agent with zero external runtime dependencies.",
5
5
  "license": "MIT",
6
6
  "repository": {