@giovannijecha/jecode 0.2.1 → 0.2.3
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/dist/accounts.js +12 -2
- package/dist/controller.js +42 -13
- package/dist/credential-safety.js +11 -1
- package/dist/credentials.js +19 -4
- package/dist/providers/ollama-stream.js +8 -4
- package/dist/providers/ollama-wire.js +19 -3
- package/dist/tools/fs.js +32 -9
- package/dist/tools/search.js +105 -20
- package/dist/tools/shell.js +28 -9
- package/dist/tui/app.js +17 -3
- package/dist/tui/keys.js +33 -0
- package/dist/tui/screen.js +19 -5
- package/dist/ui/width.js +41 -1
- package/package.json +1 -1
package/dist/accounts.js
CHANGED
|
@@ -11,8 +11,18 @@ export function openAICodexAccount() {
|
|
|
11
11
|
return account === undefined ? undefined : { ...account };
|
|
12
12
|
}
|
|
13
13
|
export function accountValues() {
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
// Keep both snapshots. Another Jecode process may rotate the refresh token
|
|
15
|
+
// after this process populated its account cache; shell-output redaction
|
|
16
|
+
// must recognize the newly persisted values without forgetting the old ones.
|
|
17
|
+
const values = new Set();
|
|
18
|
+
for (const source of [store(), readStore(accountsPath())]) {
|
|
19
|
+
const account = source.accounts["openai-codex"];
|
|
20
|
+
if (account === undefined)
|
|
21
|
+
continue;
|
|
22
|
+
values.add(account.accessToken);
|
|
23
|
+
values.add(account.refreshToken);
|
|
24
|
+
}
|
|
25
|
+
return [...values];
|
|
16
26
|
}
|
|
17
27
|
export function accountsPath() {
|
|
18
28
|
return userDataPath("accounts.json");
|
package/dist/controller.js
CHANGED
|
@@ -37,47 +37,76 @@ export async function runTurn(history, options, events, signal) {
|
|
|
37
37
|
if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
|
|
38
38
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
39
39
|
}
|
|
40
|
+
assertToolCallIds(calls);
|
|
40
41
|
history.push(assistant);
|
|
41
|
-
if (
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
if (calls.length === 0) {
|
|
43
|
+
if (assistant.usage !== undefined)
|
|
44
|
+
events.onUsage?.(assistant.usage);
|
|
44
45
|
return; // the model is done — hand back to the user
|
|
46
|
+
}
|
|
45
47
|
// Calls run one after another because approval prompts serialise anyway,
|
|
46
48
|
// but every result from this step goes back in a SINGLE message. Splitting
|
|
47
49
|
// them teaches the model to stop batching its calls.
|
|
48
50
|
const results = [];
|
|
49
51
|
const announced = new Set();
|
|
50
52
|
try {
|
|
53
|
+
if (assistant.usage !== undefined)
|
|
54
|
+
events.onUsage?.(assistant.usage);
|
|
51
55
|
for (let index = 0; index < calls.length; index++) {
|
|
52
56
|
throwIfAborted(signal);
|
|
53
57
|
const call = calls[index];
|
|
54
58
|
events.onToolProgress?.(index + 1, calls.length);
|
|
55
59
|
const preview = await look(call, options, signal);
|
|
56
60
|
throwIfAborted(signal);
|
|
57
|
-
events.onToolCall(call, preview);
|
|
58
61
|
announced.add(call.id);
|
|
62
|
+
events.onToolCall(call, preview);
|
|
59
63
|
const { result, summary } = await settle(call, options, events, signal, preview);
|
|
60
|
-
events.onToolResult(call, result, summary);
|
|
61
64
|
results.push(result);
|
|
65
|
+
events.onToolResult(call, result, summary);
|
|
62
66
|
}
|
|
63
67
|
}
|
|
64
68
|
catch (error) {
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
const interrupted = signal?.aborted === true;
|
|
70
|
+
const repairs = [];
|
|
67
71
|
for (const call of calls.slice(results.length)) {
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
results.push(
|
|
72
|
+
const run = interrupted
|
|
73
|
+
? refuse(call, "interrupted before completion", "interrupted")
|
|
74
|
+
: refuse(call, "tool processing stopped before completion", "failed");
|
|
75
|
+
repairs.push({ call, run });
|
|
76
|
+
results.push(run.result);
|
|
73
77
|
}
|
|
74
78
|
history.push({ role: "user", content: results });
|
|
75
|
-
|
|
79
|
+
// History repair is the invariant. UI recovery is best-effort and must
|
|
80
|
+
// never replace the original exception or leave the conversation open.
|
|
81
|
+
for (const { call, run } of repairs) {
|
|
82
|
+
if (!announced.has(call.id))
|
|
83
|
+
continue;
|
|
84
|
+
try {
|
|
85
|
+
events.onToolResult(call, run.result, run.summary);
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
// The surface is already failing; the next turn can still proceed.
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (interrupted)
|
|
92
|
+
throw abortReason(signal);
|
|
93
|
+
throw error;
|
|
76
94
|
}
|
|
77
95
|
history.push({ role: "user", content: results });
|
|
78
96
|
}
|
|
79
97
|
throw new Error(`gave up after ${options.maxSteps} steps without finishing (raise --max-steps)`);
|
|
80
98
|
}
|
|
99
|
+
function assertToolCallIds(calls) {
|
|
100
|
+
const seen = new Set();
|
|
101
|
+
for (const call of calls) {
|
|
102
|
+
if (call.id.trim() === "")
|
|
103
|
+
throw new Error("provider returned a tool call without an id");
|
|
104
|
+
if (seen.has(call.id)) {
|
|
105
|
+
throw new Error("provider returned duplicate tool call ids in one step");
|
|
106
|
+
}
|
|
107
|
+
seen.add(call.id);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
81
110
|
async function settle(call, options, events, signal, preview) {
|
|
82
111
|
const tool = findTool(options.tools, call.name);
|
|
83
112
|
if (tool === undefined) {
|
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
import { credentialValues } from "./credentials.js";
|
|
3
3
|
import { accountValues } from "./accounts.js";
|
|
4
4
|
const REDACTED = "[credential redacted]";
|
|
5
|
+
const MIN_HEURISTIC_SECRET_CHARS = 8;
|
|
6
|
+
const EXPLICIT_CREDENTIAL_ENVIRONMENT_NAMES = new Set([
|
|
7
|
+
"ANTHROPIC_API_KEY",
|
|
8
|
+
"OLLAMA_API_KEY",
|
|
9
|
+
"OPENAI_API_KEY",
|
|
10
|
+
]);
|
|
5
11
|
const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
|
|
6
12
|
const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
|
|
7
13
|
const SAFE_ENVIRONMENT_NAMES = new Set([
|
|
@@ -71,8 +77,12 @@ function sensitiveEnvironmentName(name) {
|
|
|
71
77
|
function secrets(source) {
|
|
72
78
|
const values = new Set([...credentialValues(), ...accountValues()]);
|
|
73
79
|
for (const [name, value] of Object.entries(source)) {
|
|
74
|
-
if (value
|
|
80
|
+
if (value === undefined || value === "")
|
|
81
|
+
continue;
|
|
82
|
+
const explicit = EXPLICIT_CREDENTIAL_ENVIRONMENT_NAMES.has(name.toUpperCase());
|
|
83
|
+
if (explicit || (value.length >= MIN_HEURISTIC_SECRET_CHARS && sensitiveEnvironment(name, value))) {
|
|
75
84
|
values.add(value);
|
|
85
|
+
}
|
|
76
86
|
}
|
|
77
87
|
return [...values].filter((value) => value !== "").sort((left, right) => right.length - left.length);
|
|
78
88
|
}
|
package/dist/credentials.js
CHANGED
|
@@ -31,8 +31,20 @@ export function credentialSource(name) {
|
|
|
31
31
|
/** Values that must never survive in shell output, regardless of their source. */
|
|
32
32
|
export function credentialValues() {
|
|
33
33
|
const stored = fromDisk();
|
|
34
|
-
|
|
35
|
-
|
|
34
|
+
// A second process can replace the saved store after this session cached it.
|
|
35
|
+
// Redact both snapshots so neither a stale nor a newly persisted key can
|
|
36
|
+
// cross the shell boundary.
|
|
37
|
+
const current = readSavedStore();
|
|
38
|
+
const names = new Set([
|
|
39
|
+
...held.keys(),
|
|
40
|
+
...Object.keys(stored),
|
|
41
|
+
...Object.keys(current),
|
|
42
|
+
]);
|
|
43
|
+
const values = new Set([
|
|
44
|
+
...held.values(),
|
|
45
|
+
...Object.values(stored),
|
|
46
|
+
...Object.values(current),
|
|
47
|
+
]);
|
|
36
48
|
for (const name of names) {
|
|
37
49
|
const environment = use(process.env[name]);
|
|
38
50
|
if (environment !== undefined)
|
|
@@ -94,10 +106,13 @@ export function reload() {
|
|
|
94
106
|
function fromDisk() {
|
|
95
107
|
if (saved !== undefined)
|
|
96
108
|
return saved;
|
|
109
|
+
saved = readSavedStore();
|
|
110
|
+
return saved;
|
|
111
|
+
}
|
|
112
|
+
function readSavedStore() {
|
|
97
113
|
const current = readStore(storePath());
|
|
98
114
|
const legacy = legacyUserDataPath("credentials.json");
|
|
99
|
-
|
|
100
|
-
return saved;
|
|
115
|
+
return current ?? (legacy === undefined ? undefined : readStore(legacy)) ?? {};
|
|
101
116
|
}
|
|
102
117
|
function readStore(file) {
|
|
103
118
|
try {
|
|
@@ -8,6 +8,7 @@ export async function assembleOllama(events, onStream) {
|
|
|
8
8
|
const calls = new Map();
|
|
9
9
|
let toolArgumentChars = 0;
|
|
10
10
|
let content = "";
|
|
11
|
+
let reasoning = "";
|
|
11
12
|
let finishReason;
|
|
12
13
|
let usage;
|
|
13
14
|
for await (const raw of events) {
|
|
@@ -28,9 +29,12 @@ export async function assembleOllama(events, onStream) {
|
|
|
28
29
|
continue;
|
|
29
30
|
// Reasoning has no standardized field name across the models Ollama
|
|
30
31
|
// serves, so both spellings in circulation are accepted.
|
|
31
|
-
const
|
|
32
|
-
if (typeof
|
|
33
|
-
|
|
32
|
+
const reasoningDelta = delta.reasoning ?? delta.reasoning_content;
|
|
33
|
+
if (typeof reasoningDelta === "string" && reasoningDelta !== "") {
|
|
34
|
+
// Keep the authoritative copy as well as the display event. Ollama
|
|
35
|
+
// expects reasoning to accompany an assistant tool call on continuation.
|
|
36
|
+
reasoning += reasoningDelta;
|
|
37
|
+
onStream?.({ kind: "thinking", text: reasoningDelta });
|
|
34
38
|
}
|
|
35
39
|
if (typeof delta.content === "string" && delta.content !== "") {
|
|
36
40
|
content += delta.content;
|
|
@@ -59,5 +63,5 @@ export async function assembleOllama(events, onStream) {
|
|
|
59
63
|
// Not every server sends an id, and the loop needs one to pair the result
|
|
60
64
|
// back to its call.
|
|
61
65
|
.map(([index, call]) => (call.id === "" ? { ...call, id: `call_${index}` } : call));
|
|
62
|
-
return { content, toolCalls, finishReason, usage };
|
|
66
|
+
return { content, reasoning, toolCalls, finishReason, usage };
|
|
63
67
|
}
|
|
@@ -40,6 +40,9 @@ export function toWireMessages(system, messages) {
|
|
|
40
40
|
if (texts.length === 0 && toolCalls.length === 0)
|
|
41
41
|
continue;
|
|
42
42
|
const turn = { role: "assistant", content: texts.join("\n") };
|
|
43
|
+
const reasoning = ollamaReasoning(message);
|
|
44
|
+
if (reasoning !== undefined)
|
|
45
|
+
turn["reasoning"] = reasoning;
|
|
43
46
|
// An empty tool_calls array is not the same as no tool_calls to every
|
|
44
47
|
// server, so the key is omitted rather than sent empty.
|
|
45
48
|
if (toolCalls.length > 0)
|
|
@@ -59,9 +62,22 @@ export function fromWireReply(reply) {
|
|
|
59
62
|
for (const call of reply.toolCalls) {
|
|
60
63
|
content.push({ kind: "tool_call", id: call.id, name: call.name, input: parseArgs(call.args) });
|
|
61
64
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
+
const raw = reply.reasoning === "" ? undefined : { reasoning: reply.reasoning };
|
|
66
|
+
return {
|
|
67
|
+
role: "assistant",
|
|
68
|
+
content,
|
|
69
|
+
...(raw === undefined ? {} : { raw, rawFrom: "ollama" }),
|
|
70
|
+
usage: normalizeUsage(reply),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
function ollamaReasoning(message) {
|
|
74
|
+
if (message.rawFrom !== "ollama" ||
|
|
75
|
+
typeof message.raw !== "object" ||
|
|
76
|
+
message.raw === null ||
|
|
77
|
+
Array.isArray(message.raw))
|
|
78
|
+
return undefined;
|
|
79
|
+
const reasoning = message.raw["reasoning"];
|
|
80
|
+
return typeof reasoning === "string" && reasoning !== "" ? reasoning : undefined;
|
|
65
81
|
}
|
|
66
82
|
function normalizeUsage(reply) {
|
|
67
83
|
if (reply.usage === undefined)
|
package/dist/tools/fs.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
// Filesystem tools: read, list, write, edit.
|
|
2
|
-
import {
|
|
2
|
+
import { constants } from "node:fs";
|
|
3
3
|
import * as fs from "node:fs/promises";
|
|
4
4
|
import * as path from "node:path";
|
|
5
5
|
import { optionalBool, optionalInt, requireString } from "./args.js";
|
|
@@ -9,9 +9,10 @@ import { atomicWrite } from "../atomic.js";
|
|
|
9
9
|
const MAX_READ_CHARS = 60_000;
|
|
10
10
|
const MAX_LIST_CHARS = 60_000;
|
|
11
11
|
const MAX_LIST_ENTRIES = 2_000;
|
|
12
|
+
const READ_CHUNK_BYTES = 64 * 1024;
|
|
12
13
|
export const readFile = {
|
|
13
14
|
name: "read_file",
|
|
14
|
-
description: "Read a UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
15
|
+
description: "Read a regular UTF-8 text file inside the workspace. Optionally start at a line " +
|
|
15
16
|
"(1-based) and cap how many lines come back. Large files are truncated.",
|
|
16
17
|
dangerous: false,
|
|
17
18
|
input: {
|
|
@@ -28,7 +29,7 @@ export const readFile = {
|
|
|
28
29
|
const target = await resolveExistingInRoot(root, requireString(args, "path"));
|
|
29
30
|
const offset = optionalInt(args, "offset");
|
|
30
31
|
const limit = optionalInt(args, "limit");
|
|
31
|
-
const { text, truncated } = await readRange(target, offset, limit);
|
|
32
|
+
const { text, truncated } = await readRange(target, offset, limit, ctx.signal);
|
|
32
33
|
if (truncated) {
|
|
33
34
|
return {
|
|
34
35
|
output: `${text}\n\n[truncated at ${MAX_READ_CHARS} characters — read a narrower range]`,
|
|
@@ -169,13 +170,22 @@ export const editFile = {
|
|
|
169
170
|
};
|
|
170
171
|
},
|
|
171
172
|
};
|
|
172
|
-
async function readRange(target, offset, limit) {
|
|
173
|
+
async function readRange(target, offset, limit, signal) {
|
|
174
|
+
throwIfAborted(signal);
|
|
173
175
|
const firstLine = Math.max(1, offset ?? 1);
|
|
174
176
|
const lineCount = limit === undefined ? undefined : Math.max(0, limit);
|
|
175
177
|
const endLine = lineCount === undefined ? Number.POSITIVE_INFINITY : firstLine + lineCount;
|
|
176
178
|
if (lineCount === 0)
|
|
177
179
|
return { text: "", truncated: false };
|
|
178
|
-
|
|
180
|
+
if (!(await fs.lstat(target)).isFile())
|
|
181
|
+
throw new Error("path must be a regular file");
|
|
182
|
+
// O_NONBLOCK prevents a path swapped to a FIFO between lstat and open from
|
|
183
|
+
// waiting forever for a writer. The handle stat closes that race before any
|
|
184
|
+
// content is accepted.
|
|
185
|
+
const flags = process.platform === "win32"
|
|
186
|
+
? "r"
|
|
187
|
+
: constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
|
|
188
|
+
const handle = await fs.open(target, flags);
|
|
179
189
|
const decoder = new TextDecoder();
|
|
180
190
|
let text = "";
|
|
181
191
|
let line = 1;
|
|
@@ -216,19 +226,32 @@ async function readRange(target, offset, limit) {
|
|
|
216
226
|
}
|
|
217
227
|
};
|
|
218
228
|
try {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
229
|
+
if (!(await handle.stat()).isFile())
|
|
230
|
+
throw new Error("path must be a regular file");
|
|
231
|
+
let position = 0;
|
|
232
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
233
|
+
while (!stopped) {
|
|
234
|
+
throwIfAborted(signal);
|
|
235
|
+
const { bytesRead } = await handle.read(buffer, 0, buffer.length, position);
|
|
236
|
+
throwIfAborted(signal);
|
|
237
|
+
if (bytesRead === 0)
|
|
222
238
|
break;
|
|
239
|
+
position += bytesRead;
|
|
240
|
+
consume(decoder.decode(buffer.subarray(0, bytesRead), { stream: true }));
|
|
223
241
|
}
|
|
224
242
|
if (!stopped)
|
|
225
243
|
consume(decoder.decode());
|
|
226
244
|
}
|
|
227
245
|
finally {
|
|
228
|
-
|
|
246
|
+
await handle.close();
|
|
229
247
|
}
|
|
230
248
|
return { text, truncated };
|
|
231
249
|
}
|
|
250
|
+
function throwIfAborted(signal) {
|
|
251
|
+
if (signal?.aborted !== true)
|
|
252
|
+
return;
|
|
253
|
+
throw signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
254
|
+
}
|
|
232
255
|
/**
|
|
233
256
|
* The edit worked out against a given text — the one place its rules live.
|
|
234
257
|
*
|
package/dist/tools/search.js
CHANGED
|
@@ -8,6 +8,7 @@ const MAX_RESULTS = 500;
|
|
|
8
8
|
const MAX_VISITED = 20_000;
|
|
9
9
|
const MAX_FILE_BYTES = 1_000_000;
|
|
10
10
|
const MAX_MATCH_LINE = 500;
|
|
11
|
+
const MAX_GLOB_CHARS = 512;
|
|
11
12
|
const SKIP = new Set([".git", ".hg", ".svn", "node_modules"]);
|
|
12
13
|
export const findFiles = {
|
|
13
14
|
name: "find_files",
|
|
@@ -17,7 +18,10 @@ export const findFiles = {
|
|
|
17
18
|
input: {
|
|
18
19
|
type: "object",
|
|
19
20
|
properties: {
|
|
20
|
-
pattern: {
|
|
21
|
+
pattern: {
|
|
22
|
+
type: "string",
|
|
23
|
+
description: "Glob matched against workspace-relative paths. Maximum 512 characters.",
|
|
24
|
+
},
|
|
21
25
|
path: { type: "string", description: "Directory to search, relative to the workspace root." },
|
|
22
26
|
max_results: { type: "integer", description: "Maximum paths returned. Defaults to 100, caps at 500." },
|
|
23
27
|
},
|
|
@@ -52,7 +56,10 @@ export const searchText = {
|
|
|
52
56
|
properties: {
|
|
53
57
|
query: { type: "string", description: "Literal text to find." },
|
|
54
58
|
path: { type: "string", description: "Directory to search, relative to the workspace root." },
|
|
55
|
-
pattern: {
|
|
59
|
+
pattern: {
|
|
60
|
+
type: "string",
|
|
61
|
+
description: "Optional file glob, for example **/*.ts. Maximum 512 characters.",
|
|
62
|
+
},
|
|
56
63
|
case_sensitive: { type: "boolean", description: "Defaults to false." },
|
|
57
64
|
max_results: { type: "integer", description: "Maximum matching lines. Defaults to 100, caps at 500." },
|
|
58
65
|
},
|
|
@@ -166,29 +173,107 @@ function resultLimit(args) {
|
|
|
166
173
|
}
|
|
167
174
|
function glob(pattern) {
|
|
168
175
|
const normalized = pattern.replace(/\\/g, "/");
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
+
if (normalized.length > MAX_GLOB_CHARS) {
|
|
177
|
+
throw new Error(`"pattern" must be at most ${MAX_GLOB_CHARS} characters`);
|
|
178
|
+
}
|
|
179
|
+
const tokens = tokenizeGlob(normalized.toLowerCase());
|
|
180
|
+
const basenameOnly = !normalized.includes("/");
|
|
181
|
+
return (relative) => {
|
|
182
|
+
const candidate = relative.replace(/\\/g, "/");
|
|
183
|
+
const target = (basenameOnly ? path.posix.basename(candidate) : candidate).toLowerCase();
|
|
184
|
+
return matchGlob(tokens, Array.from(target));
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
function tokenizeGlob(pattern) {
|
|
188
|
+
const chars = Array.from(pattern);
|
|
189
|
+
const tokens = [];
|
|
190
|
+
let index = 0;
|
|
191
|
+
while (index < chars.length) {
|
|
192
|
+
const char = chars[index];
|
|
193
|
+
if (char === "*") {
|
|
194
|
+
let end = index + 1;
|
|
195
|
+
while (chars[end] === "*")
|
|
196
|
+
end++;
|
|
197
|
+
if (end - index >= 2) {
|
|
198
|
+
if (chars[end] === "/") {
|
|
199
|
+
tokens.push({ kind: "globdir-start" }, { kind: "globdir-body" });
|
|
200
|
+
index = end + 1;
|
|
201
|
+
}
|
|
202
|
+
else {
|
|
203
|
+
tokens.push({ kind: "globstar" });
|
|
204
|
+
index = end;
|
|
205
|
+
}
|
|
176
206
|
}
|
|
177
207
|
else {
|
|
178
|
-
|
|
179
|
-
index
|
|
208
|
+
tokens.push({ kind: "star" });
|
|
209
|
+
index = end;
|
|
180
210
|
}
|
|
211
|
+
continue;
|
|
181
212
|
}
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
else if (char === "?")
|
|
185
|
-
source += "[^/]";
|
|
186
|
-
else
|
|
187
|
-
source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
213
|
+
tokens.push(char === "?" ? { kind: "one" } : { kind: "literal", value: char });
|
|
214
|
+
index++;
|
|
188
215
|
}
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
216
|
+
return tokens;
|
|
217
|
+
}
|
|
218
|
+
/** Thompson-style wildcard matching: O(pattern × path), with no regex backtracking. */
|
|
219
|
+
function matchGlob(tokens, text) {
|
|
220
|
+
let states = epsilonClosure(new Set([0]), tokens);
|
|
221
|
+
for (const char of text) {
|
|
222
|
+
const next = new Set();
|
|
223
|
+
for (const state of states) {
|
|
224
|
+
const token = tokens[state];
|
|
225
|
+
if (token === undefined)
|
|
226
|
+
continue;
|
|
227
|
+
switch (token.kind) {
|
|
228
|
+
case "literal":
|
|
229
|
+
if (token.value === char)
|
|
230
|
+
next.add(state + 1);
|
|
231
|
+
break;
|
|
232
|
+
case "one":
|
|
233
|
+
if (char !== "/")
|
|
234
|
+
next.add(state + 1);
|
|
235
|
+
break;
|
|
236
|
+
case "star":
|
|
237
|
+
if (char !== "/")
|
|
238
|
+
next.add(state);
|
|
239
|
+
break;
|
|
240
|
+
case "globstar":
|
|
241
|
+
next.add(state);
|
|
242
|
+
break;
|
|
243
|
+
case "globdir-body":
|
|
244
|
+
next.add(state);
|
|
245
|
+
if (char === "/")
|
|
246
|
+
next.add(state + 1);
|
|
247
|
+
break;
|
|
248
|
+
case "globdir-start":
|
|
249
|
+
break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
states = epsilonClosure(next, tokens);
|
|
253
|
+
if (states.size === 0)
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
return epsilonClosure(states, tokens).has(tokens.length);
|
|
257
|
+
}
|
|
258
|
+
function epsilonClosure(seed, tokens) {
|
|
259
|
+
const states = new Set(seed);
|
|
260
|
+
const pending = [...seed];
|
|
261
|
+
while (pending.length > 0) {
|
|
262
|
+
const state = pending.pop();
|
|
263
|
+
const token = tokens[state];
|
|
264
|
+
const targets = token?.kind === "globdir-start"
|
|
265
|
+
? [state + 1, state + 2]
|
|
266
|
+
: token?.kind === "star" || token?.kind === "globstar"
|
|
267
|
+
? [state + 1]
|
|
268
|
+
: [];
|
|
269
|
+
for (const target of targets) {
|
|
270
|
+
if (states.has(target))
|
|
271
|
+
continue;
|
|
272
|
+
states.add(target);
|
|
273
|
+
pending.push(target);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
return states;
|
|
192
277
|
}
|
|
193
278
|
function summary(count, limit, capped, one, many) {
|
|
194
279
|
const noun = count === 1 ? one : many;
|
package/dist/tools/shell.js
CHANGED
|
@@ -4,7 +4,9 @@ import { spawn } from "node:child_process";
|
|
|
4
4
|
import { optionalInt, requireString } from "./args.js";
|
|
5
5
|
import { credentialRedactor, redactCredentials, shellEnvironment } from "../credential-safety.js";
|
|
6
6
|
const DEFAULT_TIMEOUT_MS = 120_000;
|
|
7
|
+
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
7
8
|
const MAX_OUTPUT_CHARS = 30_000;
|
|
9
|
+
const PIPE_DRAIN_MS = 100;
|
|
8
10
|
export const runCommand = {
|
|
9
11
|
name: "run_command",
|
|
10
12
|
description: "Run a shell command starting in the workspace root and return its combined stdout " +
|
|
@@ -24,6 +26,9 @@ export const runCommand = {
|
|
|
24
26
|
const timeoutMs = optionalInt(args, "timeout_ms") ?? DEFAULT_TIMEOUT_MS;
|
|
25
27
|
if (timeoutMs <= 0)
|
|
26
28
|
throw new Error('"timeout_ms" must be a positive integer');
|
|
29
|
+
if (timeoutMs > MAX_TIMEOUT_MS) {
|
|
30
|
+
throw new Error(`"timeout_ms" must be at most ${MAX_TIMEOUT_MS}ms`);
|
|
31
|
+
}
|
|
27
32
|
const result = await execute(command, ctx.root, timeoutMs, ctx.signal, ctx.onOutput);
|
|
28
33
|
const output = redactCredentials(result.output);
|
|
29
34
|
const summary = result.timedOut
|
|
@@ -53,6 +58,7 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
|
53
58
|
let aborted;
|
|
54
59
|
let settled = false;
|
|
55
60
|
let forceTimer;
|
|
61
|
+
let drainTimer;
|
|
56
62
|
const timer = setTimeout(() => {
|
|
57
63
|
timedOut = true;
|
|
58
64
|
stopTree(child.pid, false);
|
|
@@ -68,8 +74,20 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
|
68
74
|
clearTimeout(timer);
|
|
69
75
|
if (forceTimer !== undefined)
|
|
70
76
|
clearTimeout(forceTimer);
|
|
77
|
+
if (drainTimer !== undefined)
|
|
78
|
+
clearTimeout(drainTimer);
|
|
71
79
|
signal?.removeEventListener("abort", onAbort);
|
|
72
80
|
};
|
|
81
|
+
const finish = (code) => {
|
|
82
|
+
if (settled)
|
|
83
|
+
return;
|
|
84
|
+
settled = true;
|
|
85
|
+
cleanup();
|
|
86
|
+
if (aborted !== undefined)
|
|
87
|
+
reject(aborted);
|
|
88
|
+
else
|
|
89
|
+
resolve({ output: output.value().trimEnd(), code, timedOut });
|
|
90
|
+
};
|
|
73
91
|
child.stdout.setEncoding("utf8");
|
|
74
92
|
child.stderr.setEncoding("utf8");
|
|
75
93
|
child.stdout.on("data", output.append);
|
|
@@ -81,16 +99,17 @@ function execute(command, cwd, timeoutMs, signal, onOutput) {
|
|
|
81
99
|
cleanup();
|
|
82
100
|
reject(error);
|
|
83
101
|
});
|
|
84
|
-
child.on("
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
102
|
+
child.on("exit", (code) => {
|
|
103
|
+
// `close` normally follows once both pipes drain. A detached descendant
|
|
104
|
+
// can inherit those descriptors after the command itself has exited,
|
|
105
|
+
// though, so bound that final drain instead of hanging the tool on it.
|
|
106
|
+
drainTimer = setTimeout(() => {
|
|
107
|
+
child.stdout.destroy();
|
|
108
|
+
child.stderr.destroy();
|
|
109
|
+
finish(code);
|
|
110
|
+
}, PIPE_DRAIN_MS);
|
|
93
111
|
});
|
|
112
|
+
child.on("close", finish);
|
|
94
113
|
});
|
|
95
114
|
}
|
|
96
115
|
function capture(onOutput) {
|
package/dist/tui/app.js
CHANGED
|
@@ -34,6 +34,7 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
34
34
|
let closed;
|
|
35
35
|
let frameTimer;
|
|
36
36
|
let spinTimer;
|
|
37
|
+
let escapeTimer;
|
|
37
38
|
let stopResize = () => { };
|
|
38
39
|
let stopInput = () => { };
|
|
39
40
|
// Timers outlive the teardown they were scheduled before. Painting after the
|
|
@@ -118,6 +119,8 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
118
119
|
clearInterval(spinTimer);
|
|
119
120
|
if (frameTimer !== undefined)
|
|
120
121
|
clearTimeout(frameTimer);
|
|
122
|
+
if (escapeTimer !== undefined)
|
|
123
|
+
clearTimeout(escapeTimer);
|
|
121
124
|
feedback.close();
|
|
122
125
|
terminal.leave();
|
|
123
126
|
closed?.();
|
|
@@ -204,12 +207,23 @@ export async function runApp(session, transcriptRoot, environment = {}) {
|
|
|
204
207
|
draw();
|
|
205
208
|
});
|
|
206
209
|
stopInput = terminal.onInput((chunk) => {
|
|
207
|
-
|
|
210
|
+
if (escapeTimer !== undefined)
|
|
211
|
+
clearTimeout(escapeTimer);
|
|
212
|
+
for (const key of keys.push(chunk)) {
|
|
213
|
+
if (!live)
|
|
214
|
+
break;
|
|
208
215
|
input.handle(key);
|
|
209
|
-
|
|
216
|
+
}
|
|
217
|
+
if (!live)
|
|
218
|
+
return;
|
|
219
|
+
escapeTimer = setTimeout(() => {
|
|
220
|
+
escapeTimer = undefined;
|
|
221
|
+
if (!live)
|
|
222
|
+
return;
|
|
210
223
|
for (const key of keys.flush())
|
|
211
224
|
input.handle(key);
|
|
212
|
-
|
|
225
|
+
if (live)
|
|
226
|
+
render();
|
|
213
227
|
}, ESCAPE_MS);
|
|
214
228
|
render();
|
|
215
229
|
});
|
package/dist/tui/keys.js
CHANGED
|
@@ -12,6 +12,8 @@ const PASTE_END = "[201~";
|
|
|
12
12
|
// one packs the coordinates into single bytes and simply stops being able to
|
|
13
13
|
// say where the pointer is past column 223.
|
|
14
14
|
const MOUSE = /^\[<(\d+);(\d+);(\d+)([Mm])/;
|
|
15
|
+
const CSI_SEQUENCE = /^\[[0-?]*[ -/]*[@-~]/;
|
|
16
|
+
const SS3_SEQUENCE = /^O[ -~]/;
|
|
15
17
|
const BUTTONS = ["left", "middle", "right", "none"];
|
|
16
18
|
// Both the normal and the application-cursor forms, because a terminal sends
|
|
17
19
|
// either depending on the mode it thinks it is in.
|
|
@@ -59,6 +61,16 @@ export function decoder() {
|
|
|
59
61
|
if (pasting) {
|
|
60
62
|
const end = held.indexOf(ESC + PASTE_END);
|
|
61
63
|
if (end === -1) {
|
|
64
|
+
const interrupt = firstInterrupt(held);
|
|
65
|
+
if (interrupt !== -1) {
|
|
66
|
+
// A missing bracketed-paste terminator must not trap the decoder
|
|
67
|
+
// forever. Ctrl+C/Ctrl+D are emergency exits: discard the partial
|
|
68
|
+
// paste, then let the normal control-key path handle the byte.
|
|
69
|
+
held = held.slice(interrupt);
|
|
70
|
+
pasted = "";
|
|
71
|
+
pasting = false;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
62
74
|
// Hold back a possible partial terminator rather than pasting it.
|
|
63
75
|
const safe = held.length - PASTE_END.length - 1;
|
|
64
76
|
if (safe > 0) {
|
|
@@ -96,6 +108,13 @@ export function decoder() {
|
|
|
96
108
|
keys.push({ name: SEQUENCES[match], text: "", ctrl: false });
|
|
97
109
|
continue;
|
|
98
110
|
}
|
|
111
|
+
const unbound = completeTerminalSequence(rest);
|
|
112
|
+
if (unbound !== undefined) {
|
|
113
|
+
// Terminals have many optional keys and mode reports. An unbound but
|
|
114
|
+
// complete CSI/SS3 sequence is terminal protocol, never editor text.
|
|
115
|
+
held = rest.slice(unbound.length);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
99
118
|
if (!final && couldGrow(rest))
|
|
100
119
|
break;
|
|
101
120
|
held = held.slice(1);
|
|
@@ -139,6 +158,15 @@ export function decoder() {
|
|
|
139
158
|
},
|
|
140
159
|
};
|
|
141
160
|
}
|
|
161
|
+
function firstInterrupt(text) {
|
|
162
|
+
const ctrlC = text.indexOf(String.fromCharCode(3));
|
|
163
|
+
const ctrlD = text.indexOf(String.fromCharCode(4));
|
|
164
|
+
if (ctrlC === -1)
|
|
165
|
+
return ctrlD;
|
|
166
|
+
if (ctrlD === -1)
|
|
167
|
+
return ctrlC;
|
|
168
|
+
return Math.min(ctrlC, ctrlD);
|
|
169
|
+
}
|
|
142
170
|
function matchSequence(rest) {
|
|
143
171
|
for (const seq of Object.keys(SEQUENCES)) {
|
|
144
172
|
if (rest.startsWith(seq))
|
|
@@ -146,10 +174,15 @@ function matchSequence(rest) {
|
|
|
146
174
|
}
|
|
147
175
|
return undefined;
|
|
148
176
|
}
|
|
177
|
+
function completeTerminalSequence(rest) {
|
|
178
|
+
return CSI_SEQUENCE.exec(rest)?.[0] ?? SS3_SEQUENCE.exec(rest)?.[0];
|
|
179
|
+
}
|
|
149
180
|
/** Whether `rest` is still a viable prefix of something we know. */
|
|
150
181
|
function couldGrow(rest) {
|
|
151
182
|
if (PASTE_START.startsWith(rest))
|
|
152
183
|
return true;
|
|
184
|
+
if (/^\[[0-?]*[ -/]*$/.test(rest))
|
|
185
|
+
return true;
|
|
153
186
|
// A mouse report has no fixed length, so it grows until its final letter.
|
|
154
187
|
if (/^\[<?\d*;?\d*;?\d*$/.test(rest))
|
|
155
188
|
return true;
|
package/dist/tui/screen.js
CHANGED
|
@@ -37,6 +37,7 @@ const CURSOR_RESET = `${CSI}0 q`;
|
|
|
37
37
|
const SYNC_BEGIN = `${CSI}?2026h`;
|
|
38
38
|
const SYNC_END = `${CSI}?2026l`;
|
|
39
39
|
let active = false;
|
|
40
|
+
let handlersRegistered = false;
|
|
40
41
|
export function interactive() {
|
|
41
42
|
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
42
43
|
}
|
|
@@ -53,9 +54,7 @@ export function enter(reducedMotion = false) {
|
|
|
53
54
|
if (active)
|
|
54
55
|
return;
|
|
55
56
|
active = true;
|
|
56
|
-
|
|
57
|
-
process.on("SIGTERM", onFatalSignal);
|
|
58
|
-
process.on("SIGHUP", onFatalSignal);
|
|
57
|
+
registerProcessHandlers();
|
|
59
58
|
write(ALT_ON + WRAP_OFF + CURSOR_HIDE + (reducedMotion ? CURSOR_STEADY : CURSOR_BLOCK) + PASTE_ON + MOUSE_ON);
|
|
60
59
|
process.stdin.setRawMode(true);
|
|
61
60
|
process.stdin.setEncoding("utf8");
|
|
@@ -74,9 +73,24 @@ export function setReducedMotion(reducedMotion) {
|
|
|
74
73
|
if (active)
|
|
75
74
|
write(reducedMotion ? CURSOR_STEADY : CURSOR_BLOCK);
|
|
76
75
|
}
|
|
77
|
-
function
|
|
76
|
+
function registerProcessHandlers() {
|
|
77
|
+
if (handlersRegistered)
|
|
78
|
+
return;
|
|
79
|
+
handlersRegistered = true;
|
|
80
|
+
process.on("exit", leave);
|
|
81
|
+
process.on("uncaughtExceptionMonitor", leave);
|
|
82
|
+
process.on("SIGTERM", onSigterm);
|
|
83
|
+
process.on("SIGHUP", onSighup);
|
|
84
|
+
}
|
|
85
|
+
function onSigterm() {
|
|
86
|
+
onFatalSignal(15);
|
|
87
|
+
}
|
|
88
|
+
function onSighup() {
|
|
89
|
+
onFatalSignal(1);
|
|
90
|
+
}
|
|
91
|
+
function onFatalSignal(number) {
|
|
78
92
|
leave();
|
|
79
|
-
process.exit(
|
|
93
|
+
process.exit(128 + number);
|
|
80
94
|
}
|
|
81
95
|
export function onResize(handler) {
|
|
82
96
|
process.stdout.on("resize", handler);
|
package/dist/ui/width.js
CHANGED
|
@@ -27,6 +27,43 @@ const WIDE = [
|
|
|
27
27
|
[0x1fa70, 0x1faff],
|
|
28
28
|
[0x20000, 0x3fffd],
|
|
29
29
|
];
|
|
30
|
+
/** Default emoji-presentation ranges below the main supplementary blocks. */
|
|
31
|
+
const EMOJI_WIDE = [
|
|
32
|
+
[0x231a, 0x231b],
|
|
33
|
+
[0x23e9, 0x23ec],
|
|
34
|
+
[0x23f0, 0x23f0],
|
|
35
|
+
[0x23f3, 0x23f3],
|
|
36
|
+
[0x25fd, 0x25fe],
|
|
37
|
+
[0x2614, 0x2615],
|
|
38
|
+
[0x2648, 0x2653],
|
|
39
|
+
[0x267f, 0x267f],
|
|
40
|
+
[0x2693, 0x2693],
|
|
41
|
+
[0x26a1, 0x26a1],
|
|
42
|
+
[0x26aa, 0x26ab],
|
|
43
|
+
[0x26bd, 0x26be],
|
|
44
|
+
[0x26c4, 0x26c5],
|
|
45
|
+
[0x26ce, 0x26ce],
|
|
46
|
+
[0x26d4, 0x26d4],
|
|
47
|
+
[0x26ea, 0x26ea],
|
|
48
|
+
[0x26f2, 0x26f3],
|
|
49
|
+
[0x26f5, 0x26f5],
|
|
50
|
+
[0x26fa, 0x26fa],
|
|
51
|
+
[0x26fd, 0x26fd],
|
|
52
|
+
[0x2705, 0x2705],
|
|
53
|
+
[0x270a, 0x270b],
|
|
54
|
+
[0x2728, 0x2728],
|
|
55
|
+
[0x274c, 0x274c],
|
|
56
|
+
[0x274e, 0x274e],
|
|
57
|
+
[0x2753, 0x2755],
|
|
58
|
+
[0x2757, 0x2757],
|
|
59
|
+
[0x2795, 0x2797],
|
|
60
|
+
[0x27b0, 0x27b0],
|
|
61
|
+
[0x27bf, 0x27bf],
|
|
62
|
+
[0x2b1b, 0x2b1c],
|
|
63
|
+
[0x2b50, 0x2b50],
|
|
64
|
+
[0x2b55, 0x2b55],
|
|
65
|
+
[0x1f1e6, 0x1f1ff],
|
|
66
|
+
];
|
|
30
67
|
/** Ranges that occupy no cell of their own: they attach to what precedes. */
|
|
31
68
|
const ZERO = [
|
|
32
69
|
[0x0300, 0x036f],
|
|
@@ -54,6 +91,7 @@ function inRanges(code, ranges) {
|
|
|
54
91
|
}
|
|
55
92
|
// The emoji presentation selector, built rather than typed: an invisible byte
|
|
56
93
|
// in source is a byte nobody reviews.
|
|
94
|
+
const VS15 = String.fromCodePoint(0xfe0e);
|
|
57
95
|
const VS16 = String.fromCodePoint(0xfe0f);
|
|
58
96
|
// Grapheme segmentation is in the standard library, so a family emoji built
|
|
59
97
|
// out of five code points and three joiners counts as the one thing the
|
|
@@ -80,9 +118,11 @@ export function charWidth(cluster) {
|
|
|
80
118
|
return 0;
|
|
81
119
|
if (inRanges(code, ZERO))
|
|
82
120
|
return 0;
|
|
121
|
+
if (cluster.includes(VS15))
|
|
122
|
+
return inRanges(code, WIDE) ? 2 : 1;
|
|
83
123
|
if (cluster.includes(VS16))
|
|
84
124
|
return 2;
|
|
85
|
-
return inRanges(code, WIDE) ? 2 : 1;
|
|
125
|
+
return inRanges(code, WIDE) || inRanges(code, EMOJI_WIDE) ? 2 : 1;
|
|
86
126
|
}
|
|
87
127
|
export function textWidth(text) {
|
|
88
128
|
let total = 0;
|