@giovannijecha/jecode 0.1.9 → 0.2.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 +36 -18
- package/dist/account-lock.js +112 -0
- package/dist/accounts.js +100 -0
- package/dist/cli-info.js +2 -2
- package/dist/commands.js +1 -1
- package/dist/controller.js +46 -11
- package/dist/credential-commands.js +32 -4
- package/dist/credential-safety.js +10 -1
- package/dist/effort.js +25 -0
- package/dist/external-browser.js +53 -0
- package/dist/oauth-http.js +114 -0
- package/dist/openai-account-command.js +124 -0
- package/dist/openai-account.js +94 -0
- package/dist/openai-oauth-callback.js +186 -0
- package/dist/openai-oauth-tokens.js +65 -0
- package/dist/openai-oauth.js +203 -0
- package/dist/provider-commands.js +79 -38
- package/dist/provider-label.js +10 -0
- package/dist/providers/anthropic-stream.js +8 -2
- package/dist/providers/anthropic.js +22 -6
- package/dist/providers/index.js +2 -1
- package/dist/providers/ollama-stream.js +3 -0
- package/dist/providers/ollama.js +4 -1
- package/dist/providers/openai-codex.js +157 -0
- package/dist/providers/openai-stream.js +13 -9
- package/dist/providers/openai-wire.js +4 -9
- package/dist/providers/openai.js +36 -15
- package/dist/providers/sse.js +1 -1
- package/dist/settings-command.js +64 -9
- package/dist/settings.js +2 -1
- package/dist/tools/args.js +4 -3
- package/dist/tools/fs.js +8 -3
- package/dist/tools/index.js +2 -2
- package/dist/tools/shell.js +2 -0
- package/dist/tui/app-workflows.js +5 -0
- package/dist/tui/components/messages.js +26 -5
- package/dist/tui/feedback.js +10 -6
- package/dist/tui/session-view.js +10 -4
- package/dist/tui/turn.js +4 -1
- package/dist/version.js +14 -0
- package/docs/assets/brand/jeco-256.png +0 -0
- package/package.json +5 -1
package/README.md
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
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.2.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
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
diffs, approvals, reasoning, and status all share one full-screen TUI.
|
|
37
37
|
- **Permission-aware.** Reads stay transparent; dangerous actions ask first.
|
|
38
38
|
Session approvals can be reviewed and revoked.
|
|
39
|
-
- **Provider-neutral.** Use Anthropic
|
|
40
|
-
without changing the workflow.
|
|
39
|
+
- **Provider-neutral.** Use Anthropic or OpenAI API keys, a ChatGPT account, or
|
|
40
|
+
a local/remote Ollama server without changing the workflow.
|
|
41
41
|
- **Lean by construction.** Jecode installs as plain JavaScript, runs on
|
|
42
42
|
Node.js 22.18+ (22.x) or Node.js 24+, executes no installation scripts, and
|
|
43
43
|
has zero third-party runtime dependencies.
|
|
@@ -145,15 +145,27 @@ published package runs no compilation or installation scripts.
|
|
|
145
145
|
## First session
|
|
146
146
|
|
|
147
147
|
Jecode opens on an empty composer instead of forcing a setup wizard. Type
|
|
148
|
-
**/settings** when you are ready to choose a provider, select a model, and
|
|
149
|
-
|
|
150
|
-
saved explicitly under **~/.jecode**; it is never stored in the
|
|
148
|
+
**/settings** when you are ready to choose a provider, select a model, and
|
|
149
|
+
configure authentication. An API key can remain in memory for the current
|
|
150
|
+
session or be saved explicitly under **~/.jecode**; it is never stored in the
|
|
151
|
+
workspace.
|
|
151
152
|
|
|
152
|
-
| Provider |
|
|
153
|
+
| Provider ID | Authentication | Notes |
|
|
153
154
|
|---|---|---|
|
|
154
|
-
|
|
|
155
|
-
|
|
|
156
|
-
|
|
|
155
|
+
| anthropic | ANTHROPIC_API_KEY | Anthropic API |
|
|
156
|
+
| openai | OPENAI_API_KEY | OpenAI API |
|
|
157
|
+
| openai-codex | ChatGPT OAuth | Experimental; uses eligible ChatGPT Codex access |
|
|
158
|
+
| ollama | OLLAMA_API_KEY for Cloud/remote | Cloud with a key, local without one |
|
|
159
|
+
|
|
160
|
+
Choose **openai-codex** to sign in on OpenAI's website without pasting a key.
|
|
161
|
+
Jecode offers a local browser callback and a device-code flow; WSL and remote
|
|
162
|
+
terminals default to the device code. The connection is saved only after the
|
|
163
|
+
flow completes. Availability and usage limits are determined by the ChatGPT
|
|
164
|
+
account and plan, not by OpenAI API credits. This integration is experimental
|
|
165
|
+
and is not an endorsement of Jecode by OpenAI.
|
|
166
|
+
|
|
167
|
+
Anthropic remains API-key only. Jecode does not reuse a Claude consumer
|
|
168
|
+
subscription or copy credentials from another client.
|
|
157
169
|
|
|
158
170
|
Choose **cloud**, **local**, or a custom endpoint from the Ollama connection row
|
|
159
171
|
in **/settings**. Existing users with an Ollama API key automatically use
|
|
@@ -166,11 +178,11 @@ Type **/** to open searchable command completion inside the composer.
|
|
|
166
178
|
|
|
167
179
|
| Command | What it does |
|
|
168
180
|
|---|---|
|
|
169
|
-
| /settings | Manage provider, connection, model, limits, motion, and
|
|
181
|
+
| /settings | Manage provider, connection, model, limits, motion, and authentication |
|
|
170
182
|
| /effort | Change and save reasoning effort directly |
|
|
171
183
|
| /providers | Switch the provider for the next turn |
|
|
172
184
|
| /models | Search the live model catalogue |
|
|
173
|
-
| /credentials |
|
|
185
|
+
| /credentials | Manage API keys and the connected ChatGPT account |
|
|
174
186
|
| /permissions | Manage session tool access and remembered approvals |
|
|
175
187
|
| /new | Start a clean conversation and reset session tool permissions |
|
|
176
188
|
| /export | Save a timestamped Markdown transcript in the launch directory |
|
|
@@ -207,14 +219,16 @@ settings, built-in defaults.
|
|
|
207
219
|
| --ollama-host | OLLAMA_HOST | Cloud with an Ollama key, local without one |
|
|
208
220
|
| --root | — | Current directory |
|
|
209
221
|
| --effort | JECODE_EFFORT | high |
|
|
210
|
-
| --max-tokens | JECODE_MAX_TOKENS | 64000 |
|
|
222
|
+
| --max-tokens | JECODE_MAX_TOKENS | 64000; not sent by openai-codex |
|
|
211
223
|
| --max-steps | JECODE_MAX_STEPS | 40 |
|
|
212
224
|
| --reduced-motion | JECODE_REDUCED_MOTION=1 | Off |
|
|
213
225
|
| --auto-approve | JECODE_AUTO_APPROVE=1 | Off |
|
|
214
226
|
|
|
215
227
|
Persistent preferences live in **~/.jecode/settings.json**. Explicitly saved
|
|
216
|
-
|
|
217
|
-
|
|
228
|
+
API keys live in **~/.jecode/credentials.json**; the ChatGPT OAuth account lives
|
|
229
|
+
separately in **~/.jecode/accounts.json**. Both secret stores use owner-only
|
|
230
|
+
permissions where the operating system supports them. Environment API keys
|
|
231
|
+
always win.
|
|
218
232
|
|
|
219
233
|
Jecode has one interface theme: dark Steel. **NO_COLOR** is supported for
|
|
220
234
|
terminals and pipelines that disable colour.
|
|
@@ -241,9 +255,13 @@ untrusted data.
|
|
|
241
255
|
- Dangerous tools ask by default unless explicitly allowed for the session in
|
|
242
256
|
**/permissions** or the process started with **--auto-approve**.
|
|
243
257
|
- Credential fields are masked and excluded from transcripts. Approved shell
|
|
244
|
-
commands receive no
|
|
245
|
-
|
|
246
|
-
|
|
258
|
+
commands receive no secret-bearing environment variables; `SSH_AUTH_SOCK`
|
|
259
|
+
is preserved so Git and SSH can use the user's agent, which means an approved
|
|
260
|
+
command can request that agent to authenticate. Recognized credential values
|
|
261
|
+
are redacted before tool output reaches the model, screen, history, or export.
|
|
262
|
+
- ChatGPT OAuth uses PKCE and an exact loopback callback or the OpenAI device
|
|
263
|
+
flow. Refresh-token rotation is serialized across Jecode processes; OAuth
|
|
264
|
+
tokens are withheld and redacted like API keys.
|
|
247
265
|
- Terminal control characters are neutralized before rendering.
|
|
248
266
|
- Remote Ollama endpoints require HTTPS. Provider HTTP redirects are rejected
|
|
249
267
|
rather than followed across an implicit trust boundary.
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// A tiny cross-process lock for rotating OAuth credentials.
|
|
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";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
const WAIT_MS = 50;
|
|
10
|
+
const WAIT_LIMIT_MS = 20_000;
|
|
11
|
+
const STALE_MS = 60_000;
|
|
12
|
+
export async function withAccountLock(accountFile, body, signal) {
|
|
13
|
+
throwIfAborted(signal);
|
|
14
|
+
const directory = `${accountFile}.lock`;
|
|
15
|
+
const token = `${process.pid}:${randomUUID()}`;
|
|
16
|
+
const started = Date.now();
|
|
17
|
+
while (!(await acquire(directory, token))) {
|
|
18
|
+
if (signal?.aborted === true)
|
|
19
|
+
throw abortReason(signal);
|
|
20
|
+
if (Date.now() - started >= WAIT_LIMIT_MS) {
|
|
21
|
+
throw new Error("timed out waiting for the account store");
|
|
22
|
+
}
|
|
23
|
+
await recoverStale(directory);
|
|
24
|
+
await wait(WAIT_MS, signal);
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
throwIfAborted(signal);
|
|
28
|
+
return await body();
|
|
29
|
+
}
|
|
30
|
+
finally {
|
|
31
|
+
await release(directory, token);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
async function acquire(directory, token) {
|
|
35
|
+
let created = false;
|
|
36
|
+
try {
|
|
37
|
+
await mkdir(directory, { mode: 0o700 });
|
|
38
|
+
created = true;
|
|
39
|
+
const owner = await open(path.join(directory, "owner"), "wx", 0o600);
|
|
40
|
+
try {
|
|
41
|
+
await owner.writeFile(token, "utf8");
|
|
42
|
+
await owner.sync();
|
|
43
|
+
}
|
|
44
|
+
finally {
|
|
45
|
+
await owner.close();
|
|
46
|
+
}
|
|
47
|
+
return true;
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
if (!created && error.code === "EEXIST")
|
|
51
|
+
return false;
|
|
52
|
+
if (created)
|
|
53
|
+
await removeLock(directory);
|
|
54
|
+
throw error;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
async function recoverStale(directory) {
|
|
58
|
+
try {
|
|
59
|
+
const details = await stat(directory);
|
|
60
|
+
if (Date.now() - details.mtimeMs < STALE_MS)
|
|
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);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
const code = error.code;
|
|
69
|
+
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM")
|
|
70
|
+
throw error;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
async function release(directory, token) {
|
|
74
|
+
try {
|
|
75
|
+
const owner = path.join(directory, "owner");
|
|
76
|
+
if ((await readFile(owner, "utf8")) !== token)
|
|
77
|
+
return;
|
|
78
|
+
await unlink(owner);
|
|
79
|
+
await rmdir(directory);
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
// A recovered stale lock or an already-removed directory is no longer ours.
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
async function removeLock(directory) {
|
|
86
|
+
await unlink(path.join(directory, "owner")).catch(() => undefined);
|
|
87
|
+
await rmdir(directory).catch(() => undefined);
|
|
88
|
+
}
|
|
89
|
+
function wait(ms, signal) {
|
|
90
|
+
return new Promise((resolve, reject) => {
|
|
91
|
+
if (signal?.aborted === true) {
|
|
92
|
+
reject(abortReason(signal));
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const timer = setTimeout(() => {
|
|
96
|
+
signal?.removeEventListener("abort", onAbort);
|
|
97
|
+
resolve();
|
|
98
|
+
}, ms);
|
|
99
|
+
const onAbort = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(signal === undefined ? new Error("cancelled") : abortReason(signal));
|
|
102
|
+
};
|
|
103
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function abortReason(signal) {
|
|
107
|
+
return signal.reason instanceof Error ? signal.reason : new Error("cancelled");
|
|
108
|
+
}
|
|
109
|
+
function throwIfAborted(signal) {
|
|
110
|
+
if (signal?.aborted === true)
|
|
111
|
+
throw abortReason(signal);
|
|
112
|
+
}
|
package/dist/accounts.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
// OAuth accounts persisted under ~/.jecode, apart from API keys.
|
|
2
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { atomicWrite } from "./atomic.js";
|
|
6
|
+
import { withAccountLock } from "./account-lock.js";
|
|
7
|
+
import { userDataLabel, userDataPath } from "./user-data.js";
|
|
8
|
+
let cached;
|
|
9
|
+
export function openAICodexAccount() {
|
|
10
|
+
const account = store().accounts["openai-codex"];
|
|
11
|
+
return account === undefined ? undefined : { ...account };
|
|
12
|
+
}
|
|
13
|
+
export function accountValues() {
|
|
14
|
+
const account = store().accounts["openai-codex"];
|
|
15
|
+
return account === undefined ? [] : [account.accessToken, account.refreshToken];
|
|
16
|
+
}
|
|
17
|
+
export function accountsPath() {
|
|
18
|
+
return userDataPath("accounts.json");
|
|
19
|
+
}
|
|
20
|
+
export function accountsLabel() {
|
|
21
|
+
return userDataLabel("accounts.json");
|
|
22
|
+
}
|
|
23
|
+
export async function updateOpenAICodexAccount(change, signal) {
|
|
24
|
+
const file = accountsPath();
|
|
25
|
+
const directory = path.dirname(file);
|
|
26
|
+
await mkdir(directory, { recursive: true, mode: 0o700 });
|
|
27
|
+
if (process.platform !== "win32")
|
|
28
|
+
await chmod(directory, 0o700);
|
|
29
|
+
return withAccountLock(file, async () => {
|
|
30
|
+
const current = readStore(file);
|
|
31
|
+
const next = await change(current.accounts["openai-codex"]);
|
|
32
|
+
const accounts = { ...current.accounts };
|
|
33
|
+
if (next === undefined)
|
|
34
|
+
delete accounts["openai-codex"];
|
|
35
|
+
else
|
|
36
|
+
accounts["openai-codex"] = { ...next };
|
|
37
|
+
const updated = { version: 1, accounts };
|
|
38
|
+
await atomicWrite(file, `${JSON.stringify(updated, null, 2)}\n`, { mode: 0o600 });
|
|
39
|
+
cached = updated;
|
|
40
|
+
return next === undefined ? undefined : { ...next };
|
|
41
|
+
}, signal);
|
|
42
|
+
}
|
|
43
|
+
export function reloadAccounts() {
|
|
44
|
+
cached = undefined;
|
|
45
|
+
}
|
|
46
|
+
function store() {
|
|
47
|
+
if (cached === undefined)
|
|
48
|
+
cached = readStore(accountsPath());
|
|
49
|
+
return cached;
|
|
50
|
+
}
|
|
51
|
+
function readStore(file) {
|
|
52
|
+
try {
|
|
53
|
+
const parsed = JSON.parse(readFileSync(file, "utf8"));
|
|
54
|
+
return normalize(parsed);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
return { version: 1, accounts: {} };
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function normalize(value) {
|
|
61
|
+
if (!record(value) || value["version"] !== 1 || !record(value["accounts"])) {
|
|
62
|
+
return { version: 1, accounts: {} };
|
|
63
|
+
}
|
|
64
|
+
const account = normalizeOpenAI(value["accounts"]["openai-codex"]);
|
|
65
|
+
return {
|
|
66
|
+
version: 1,
|
|
67
|
+
accounts: account === undefined ? {} : { "openai-codex": account },
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
function normalizeOpenAI(value) {
|
|
71
|
+
if (!record(value))
|
|
72
|
+
return undefined;
|
|
73
|
+
const accessToken = nonempty(value["accessToken"]);
|
|
74
|
+
const refreshToken = nonempty(value["refreshToken"]);
|
|
75
|
+
const accountId = nonempty(value["accountId"]);
|
|
76
|
+
const expiresAt = value["expiresAt"];
|
|
77
|
+
if (accessToken === undefined ||
|
|
78
|
+
refreshToken === undefined ||
|
|
79
|
+
accountId === undefined ||
|
|
80
|
+
typeof expiresAt !== "number" ||
|
|
81
|
+
!Number.isSafeInteger(expiresAt) ||
|
|
82
|
+
expiresAt <= 0)
|
|
83
|
+
return undefined;
|
|
84
|
+
const email = nonempty(value["email"]);
|
|
85
|
+
const plan = nonempty(value["plan"]);
|
|
86
|
+
return {
|
|
87
|
+
accessToken,
|
|
88
|
+
refreshToken,
|
|
89
|
+
expiresAt,
|
|
90
|
+
accountId,
|
|
91
|
+
...(email === undefined ? {} : { email }),
|
|
92
|
+
...(plan === undefined ? {} : { plan }),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function nonempty(value) {
|
|
96
|
+
return typeof value === "string" && value.trim() !== "" ? value : undefined;
|
|
97
|
+
}
|
|
98
|
+
function record(value) {
|
|
99
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
100
|
+
}
|
package/dist/cli-info.js
CHANGED
|
@@ -8,10 +8,10 @@ Usage:
|
|
|
8
8
|
|
|
9
9
|
Options:
|
|
10
10
|
--root <path> workspace root (default: current directory)
|
|
11
|
-
--provider <id> anthropic, openai, or ollama
|
|
11
|
+
--provider <id> anthropic, openai, openai-codex, or ollama
|
|
12
12
|
--model <id> model for the selected provider
|
|
13
13
|
--ollama-host <url> Ollama Cloud, local, or custom endpoint
|
|
14
|
-
--effort <level> low, medium, high, or max
|
|
14
|
+
--effort <level> low, medium, high, xhigh, or max
|
|
15
15
|
--max-tokens <number> output-token ceiling
|
|
16
16
|
--max-steps <number> tool-loop ceiling
|
|
17
17
|
--reduced-motion disable animated terminal states
|
package/dist/commands.js
CHANGED
|
@@ -24,7 +24,7 @@ export const COMMANDS = [
|
|
|
24
24
|
{ name: "permissions", blurb: "manage session tool access" },
|
|
25
25
|
{ name: "settings", blurb: "change and save jecode defaults" },
|
|
26
26
|
{ name: "effort", blurb: "set the reasoning effort" },
|
|
27
|
-
{ name: "credentials", blurb: "
|
|
27
|
+
{ name: "credentials", blurb: "manage API keys and connected accounts" },
|
|
28
28
|
{ name: "models", blurb: "pick a model, from what the provider offers" },
|
|
29
29
|
{ name: "providers", blurb: "pick a provider" },
|
|
30
30
|
];
|
package/dist/controller.js
CHANGED
|
@@ -14,6 +14,7 @@ export const MAX_TOOL_CALLS_PER_STEP = 32;
|
|
|
14
14
|
export async function runTurn(history, options, events, signal) {
|
|
15
15
|
const specs = toolSpecs(options.tools);
|
|
16
16
|
for (let step = 0; step < options.maxSteps; step++) {
|
|
17
|
+
throwIfAborted(signal);
|
|
17
18
|
events.onStep?.(step + 1, options.maxSteps);
|
|
18
19
|
// The message is displayed as it streams; what comes back here is the
|
|
19
20
|
// assembled version, which exists to be appended to the history.
|
|
@@ -28,7 +29,11 @@ export async function runTurn(history, options, events, signal) {
|
|
|
28
29
|
onStream: (event) => events.onStream(event),
|
|
29
30
|
onStatus: (status) => events.onStatus?.(status),
|
|
30
31
|
});
|
|
32
|
+
throwIfAborted(signal);
|
|
31
33
|
const calls = assistant.content.filter(isToolCall);
|
|
34
|
+
if (assistant.content.length === 0) {
|
|
35
|
+
throw new Error(`${options.provider.id} completed without an answer or tool call`);
|
|
36
|
+
}
|
|
32
37
|
if (calls.length > MAX_TOOL_CALLS_PER_STEP) {
|
|
33
38
|
throw new Error(`provider returned ${calls.length} tool calls in one step (maximum ${MAX_TOOL_CALLS_PER_STEP})`);
|
|
34
39
|
}
|
|
@@ -41,14 +46,33 @@ export async function runTurn(history, options, events, signal) {
|
|
|
41
46
|
// but every result from this step goes back in a SINGLE message. Splitting
|
|
42
47
|
// them teaches the model to stop batching its calls.
|
|
43
48
|
const results = [];
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
49
|
+
const announced = new Set();
|
|
50
|
+
try {
|
|
51
|
+
for (let index = 0; index < calls.length; index++) {
|
|
52
|
+
throwIfAborted(signal);
|
|
53
|
+
const call = calls[index];
|
|
54
|
+
events.onToolProgress?.(index + 1, calls.length);
|
|
55
|
+
const preview = await look(call, options, signal);
|
|
56
|
+
throwIfAborted(signal);
|
|
57
|
+
events.onToolCall(call, preview);
|
|
58
|
+
announced.add(call.id);
|
|
59
|
+
const { result, summary } = await settle(call, options, events, signal, preview);
|
|
60
|
+
events.onToolResult(call, result, summary);
|
|
61
|
+
results.push(result);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch (error) {
|
|
65
|
+
if (signal?.aborted !== true)
|
|
66
|
+
throw error;
|
|
67
|
+
for (const call of calls.slice(results.length)) {
|
|
68
|
+
const interrupted = refuse(call, "interrupted before completion", "interrupted");
|
|
69
|
+
if (announced.has(call.id)) {
|
|
70
|
+
events.onToolResult(call, interrupted.result, interrupted.summary);
|
|
71
|
+
}
|
|
72
|
+
results.push(interrupted.result);
|
|
73
|
+
}
|
|
74
|
+
history.push({ role: "user", content: results });
|
|
75
|
+
throw abortReason(signal);
|
|
52
76
|
}
|
|
53
77
|
history.push({ role: "user", content: results });
|
|
54
78
|
}
|
|
@@ -59,9 +83,13 @@ async function settle(call, options, events, signal, preview) {
|
|
|
59
83
|
if (tool === undefined) {
|
|
60
84
|
return refuse(call, `no such tool: ${call.name}`, "unknown tool");
|
|
61
85
|
}
|
|
62
|
-
|
|
86
|
+
throwIfAborted(signal);
|
|
87
|
+
const approved = !tool.dangerous || await events.approve(call);
|
|
88
|
+
throwIfAborted(signal);
|
|
89
|
+
if (!approved) {
|
|
63
90
|
return refuse(call, "the user declined this call — ask them how to proceed", "declined");
|
|
64
91
|
}
|
|
92
|
+
throwIfAborted(signal);
|
|
65
93
|
return runTool(tool, call, {
|
|
66
94
|
...options.toolContext,
|
|
67
95
|
signal,
|
|
@@ -82,14 +110,21 @@ function refuse(call, reason, summary) {
|
|
|
82
110
|
* file, a match that is not there — is simply no preview. Nothing about the
|
|
83
111
|
* turn depends on it, because it exists for the user, not for the model.
|
|
84
112
|
*/
|
|
85
|
-
async function look(call, options) {
|
|
113
|
+
async function look(call, options, signal) {
|
|
86
114
|
const tool = findTool(options.tools, call.name);
|
|
87
115
|
if (tool?.preview === undefined)
|
|
88
116
|
return undefined;
|
|
89
117
|
try {
|
|
90
|
-
return await tool.preview(call.input, options.toolContext);
|
|
118
|
+
return await tool.preview(call.input, { ...options.toolContext, signal });
|
|
91
119
|
}
|
|
92
120
|
catch {
|
|
93
121
|
return undefined;
|
|
94
122
|
}
|
|
95
123
|
}
|
|
124
|
+
function throwIfAborted(signal) {
|
|
125
|
+
if (signal?.aborted === true)
|
|
126
|
+
throw abortReason(signal);
|
|
127
|
+
}
|
|
128
|
+
function abortReason(signal) {
|
|
129
|
+
return signal.reason instanceof Error ? signal.reason : new Error("interrupted");
|
|
130
|
+
}
|
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
import { heading } from "./tui/picker.js";
|
|
3
3
|
import { EMPTY } from "./tui/editor.js";
|
|
4
4
|
import { PROVIDERS } from "./providers/index.js";
|
|
5
|
+
import { openAIAccountHint } from "./openai-account.js";
|
|
6
|
+
import { ensureOpenAIAccount, openAIAccountCommand, } from "./openai-account-command.js";
|
|
5
7
|
import { credentialSource, forgetSaved, hasSaved, hold, keep, storeLabel, } from "./credentials.js";
|
|
6
8
|
/** Ask for a key, then ask separately whether it may be written to disk. */
|
|
7
9
|
export async function askForKey(name, host, pal) {
|
|
@@ -52,10 +54,12 @@ export async function credentialsCommand(session, host) {
|
|
|
52
54
|
if (choose === undefined)
|
|
53
55
|
return;
|
|
54
56
|
const index = await choose({
|
|
55
|
-
title: heading("
|
|
57
|
+
title: heading("authentication", "secrets are never shown", session.palette),
|
|
56
58
|
options: PROVIDERS.map((provider) => ({
|
|
57
|
-
label: provider.keyVar
|
|
58
|
-
hint:
|
|
59
|
+
label: provider.auth.kind === "api-key" ? provider.auth.keyVar : `${provider.auth.label} account`,
|
|
60
|
+
hint: provider.auth.kind === "api-key"
|
|
61
|
+
? credentialSource(provider.auth.keyVar) ?? "missing"
|
|
62
|
+
: openAIAccountHint(),
|
|
59
63
|
})),
|
|
60
64
|
index: Math.max(0, PROVIDERS.findIndex((provider) => provider.id === session.provider.id)),
|
|
61
65
|
});
|
|
@@ -64,7 +68,11 @@ export async function credentialsCommand(session, host) {
|
|
|
64
68
|
const provider = PROVIDERS[index];
|
|
65
69
|
if (provider === undefined)
|
|
66
70
|
return;
|
|
67
|
-
|
|
71
|
+
if (provider.auth.kind === "oauth") {
|
|
72
|
+
await openAIAccountCommand(session, host);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const name = provider.auth.keyVar;
|
|
68
76
|
const source = credentialSource(name);
|
|
69
77
|
if (source === "environment") {
|
|
70
78
|
host.emit({
|
|
@@ -93,6 +101,26 @@ export async function credentialsCommand(session, host) {
|
|
|
93
101
|
}
|
|
94
102
|
await askForKey(name, host, session.palette);
|
|
95
103
|
}
|
|
104
|
+
/** Offer the authentication flow owned by a provider, if that is its blocker. */
|
|
105
|
+
export async function ensureProviderAuthentication(provider, session, host) {
|
|
106
|
+
const blocked = provider.blocked();
|
|
107
|
+
if (blocked === undefined)
|
|
108
|
+
return true;
|
|
109
|
+
if (provider.auth.kind === "oauth") {
|
|
110
|
+
return provider.auth.account === "openai-codex"
|
|
111
|
+
? ensureOpenAIAccount(session, host)
|
|
112
|
+
: false;
|
|
113
|
+
}
|
|
114
|
+
if (!blocked.startsWith(`${provider.auth.keyVar} `)) {
|
|
115
|
+
host.emit({ kind: "notice", text: blocked, tone: "error" });
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
await askForKey(provider.auth.keyVar, host, session.palette);
|
|
119
|
+
return provider.blocked() === undefined;
|
|
120
|
+
}
|
|
121
|
+
export function authenticationNeed(provider) {
|
|
122
|
+
return provider.auth.kind === "oauth" ? `${provider.auth.label} sign-in` : "an API key";
|
|
123
|
+
}
|
|
96
124
|
async function offerForget(name, session, host, hint) {
|
|
97
125
|
if (host.choose === undefined)
|
|
98
126
|
return;
|
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
// The shell needs a useful process environment, not the application's secrets.
|
|
2
2
|
import { credentialValues } from "./credentials.js";
|
|
3
|
+
import { accountValues } from "./accounts.js";
|
|
3
4
|
const REDACTED = "[credential redacted]";
|
|
4
5
|
const SENSITIVE_ENVIRONMENT_NAME = /(?:^|_)(?:API_?KEY|ACCESS_?KEY|PRIVATE_?KEY|KEY|TOKEN|SECRET|PASSWORD|PASSWD|PASS|PWD|CREDENTIALS?|AUTH|JWT|COOKIE|PAT)(?:_|$)/i;
|
|
5
6
|
const COMPACT_SENSITIVE_ENVIRONMENT_NAME = /^(?:PGPASSWORD)$/i;
|
|
7
|
+
const SAFE_ENVIRONMENT_NAMES = new Set([
|
|
8
|
+
"SSH_AUTH_SOCK",
|
|
9
|
+
"PWD",
|
|
10
|
+
"OLDPWD",
|
|
11
|
+
"PASSWORD_STORE_DIR",
|
|
12
|
+
]);
|
|
6
13
|
/** Preserve ordinary tool configuration while withholding credential-like values. */
|
|
7
14
|
export function shellEnvironment(source = process.env) {
|
|
8
15
|
const environment = {};
|
|
@@ -56,11 +63,13 @@ function sensitiveEnvironmentName(name) {
|
|
|
56
63
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
57
64
|
.replace(/[^a-z0-9]+/gi, "_")
|
|
58
65
|
.replace(/^_+|_+$/g, "");
|
|
66
|
+
if (SAFE_ENVIRONMENT_NAMES.has(normalized.toUpperCase()))
|
|
67
|
+
return false;
|
|
59
68
|
return (SENSITIVE_ENVIRONMENT_NAME.test(normalized) ||
|
|
60
69
|
COMPACT_SENSITIVE_ENVIRONMENT_NAME.test(normalized));
|
|
61
70
|
}
|
|
62
71
|
function secrets(source) {
|
|
63
|
-
const values = new Set(credentialValues());
|
|
72
|
+
const values = new Set([...credentialValues(), ...accountValues()]);
|
|
64
73
|
for (const [name, value] of Object.entries(source)) {
|
|
65
74
|
if (value !== undefined && value !== "" && sensitiveEnvironment(name, value))
|
|
66
75
|
values.add(value);
|
package/dist/effort.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One ordered vocabulary for reasoning depth. Providers expose the subset a
|
|
2
|
+
// selected model accepts; this module only validates and reconciles that data.
|
|
3
|
+
export const EFFORTS = ["low", "medium", "high", "xhigh", "max"];
|
|
4
|
+
export function isEffort(value) {
|
|
5
|
+
return typeof value === "string" && EFFORTS.includes(value);
|
|
6
|
+
}
|
|
7
|
+
export function requireSupportedEffort(model, requested, supported) {
|
|
8
|
+
if (supported.includes(requested))
|
|
9
|
+
return requested;
|
|
10
|
+
const choices = supported.length === 0 ? "none" : supported.join(", ");
|
|
11
|
+
throw new Error(`${model} does not support effort "${requested}" (available: ${choices})`);
|
|
12
|
+
}
|
|
13
|
+
/** Keep the requested depth when possible, otherwise choose the nearest lower level. */
|
|
14
|
+
export function compatibleEffort(requested, supported) {
|
|
15
|
+
if (supported.length === 0)
|
|
16
|
+
return undefined;
|
|
17
|
+
if (supported.includes(requested))
|
|
18
|
+
return requested;
|
|
19
|
+
const requestedIndex = EFFORTS.indexOf(requested);
|
|
20
|
+
const candidates = EFFORTS.filter((value) => supported.includes(value));
|
|
21
|
+
if (requestedIndex < 0)
|
|
22
|
+
return candidates[0];
|
|
23
|
+
return [...candidates].reverse().find((value) => EFFORTS.indexOf(value) < requestedIndex)
|
|
24
|
+
?? candidates[0];
|
|
25
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// Open one HTTPS URL without involving a shell or interpolating commands.
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
export async function openExternal(url) {
|
|
5
|
+
const target = new URL(url);
|
|
6
|
+
if (target.protocol !== "https:")
|
|
7
|
+
throw new Error("only HTTPS links may be opened");
|
|
8
|
+
const command = browserCommand(target.href);
|
|
9
|
+
if (command === undefined)
|
|
10
|
+
return false;
|
|
11
|
+
return new Promise((resolve) => {
|
|
12
|
+
const child = spawn(command.file, command.args, {
|
|
13
|
+
detached: true,
|
|
14
|
+
stdio: "ignore",
|
|
15
|
+
windowsHide: true,
|
|
16
|
+
});
|
|
17
|
+
child.once("error", () => resolve(false));
|
|
18
|
+
child.once("spawn", () => {
|
|
19
|
+
child.unref();
|
|
20
|
+
resolve(true);
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function browserCommand(url) {
|
|
25
|
+
if (process.platform === "win32") {
|
|
26
|
+
return { file: "rundll32.exe", args: ["url.dll,FileProtocolHandler", url] };
|
|
27
|
+
}
|
|
28
|
+
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] };
|
|
33
|
+
}
|
|
34
|
+
export function headlessEnvironment() {
|
|
35
|
+
if (isWsl() || process.env["SSH_CONNECTION"] !== undefined || process.env["SSH_TTY"] !== undefined) {
|
|
36
|
+
return true;
|
|
37
|
+
}
|
|
38
|
+
return process.platform === "linux" &&
|
|
39
|
+
process.env["DISPLAY"] === undefined &&
|
|
40
|
+
process.env["WAYLAND_DISPLAY"] === undefined;
|
|
41
|
+
}
|
|
42
|
+
function isWsl() {
|
|
43
|
+
if (process.platform !== "linux")
|
|
44
|
+
return false;
|
|
45
|
+
if (process.env["WSL_DISTRO_NAME"] !== undefined || process.env["WSL_INTEROP"] !== undefined)
|
|
46
|
+
return true;
|
|
47
|
+
try {
|
|
48
|
+
return /microsoft/i.test(readFileSync("/proc/version", "utf8"));
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|