jeo-code 0.6.41 → 0.7.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/CHANGELOG.md +40 -0
- package/README.ja.md +31 -38
- package/README.ko.md +31 -38
- package/README.md +32 -38
- package/README.zh.md +31 -38
- package/package.json +4 -2
- package/src/agent/compaction.ts +2 -1
- package/src/agent/computer-supervisor.ts +59 -0
- package/src/agent/config-schema.ts +5 -0
- package/src/agent/engine.ts +9 -4
- package/src/agent/json.ts +75 -8
- package/src/agent/loop-guards.ts +16 -5
- package/src/agent/memory-okf.ts +2 -0
- package/src/agent/memory.ts +25 -8
- package/src/agent/state.ts +38 -7
- package/src/agent/task-tool.ts +12 -3
- package/src/agent/tool-schemas.ts +19 -0
- package/src/ai/model-catalog.ts +22 -0
- package/src/ai/model-discovery.ts +27 -9
- package/src/ai/providers/anthropic.ts +10 -6
- package/src/ai/providers/openai-compatible-catalog.ts +2 -0
- package/src/ai/types.ts +2 -1
- package/src/auth/index.ts +1 -1
- package/src/auth/refresh.ts +47 -2
- package/src/auth/storage.ts +11 -5
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +16 -0
- package/src/commands/approve.ts +17 -1
- package/src/commands/computer.ts +334 -0
- package/src/commands/launch/input.ts +49 -6
- package/src/commands/launch.ts +41 -18
- package/src/commands/ralplan.ts +4 -0
- package/src/commands/team.ts +13 -0
- package/src/index.ts +1 -0
- package/src/prompts/agents/executor.md +6 -4
- package/src/prompts/skills/ralplan/SKILL.md +5 -0
- package/src/prompts/skills/team/SKILL.md +5 -0
- package/src/tui/app.ts +7 -4
- package/src/tui/components/ascii-art.ts +6 -2
- package/src/tui/components/slash.ts +1 -0
- package/src/tui/components/themes.ts +14 -0
- package/src/util/terminal-restore.ts +41 -0
package/src/auth/storage.ts
CHANGED
|
@@ -8,11 +8,13 @@ import { jeoEnv } from "../util/env";
|
|
|
8
8
|
/** Providers with an interactive OAuth login + refresh flow. */
|
|
9
9
|
export type OAuthProvider = "anthropic" | "openai" | "gemini" | "antigravity";
|
|
10
10
|
/** Every provider jeo resolves a credential for: OAuth-capable ∪ API-key-only. */
|
|
11
|
-
export type AuthProvider = OAuthProvider | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax";
|
|
11
|
+
export type AuthProvider = OAuthProvider | "xai" | "kimi" | "groq" | "deepseek" | "mistral" | "openrouter" | "together" | "cerebras" | "fireworks" | "nvidia" | "alibaba-coding-plan" | "huggingface" | "nanogpt" | "qwen-portal" | "synthetic" | "venice" | "zenmux" | "qianfan" | "xiaomi" | "xiaomi-token-plan-ams" | "xiaomi-token-plan-cn" | "xiaomi-token-plan-sgp" | "minimax-code" | "minimax-code-cn" | "zai" | "minimax" | "tencent";
|
|
12
|
+
|
|
12
13
|
|
|
13
14
|
export const OAUTH_PROVIDERS: readonly OAuthProvider[] = ["anthropic", "openai", "gemini", "antigravity"];
|
|
14
15
|
/** API-key-only providers (no OAuth flow) — resolved from config.providers / `<NAME>_API_KEY`. */
|
|
15
|
-
export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "kimi", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax"];
|
|
16
|
+
export const API_KEY_ONLY_PROVIDERS: readonly AuthProvider[] = ["xai", "kimi", "groq", "deepseek", "mistral", "openrouter", "together", "cerebras", "fireworks", "nvidia", "alibaba-coding-plan", "huggingface", "nanogpt", "qwen-portal", "synthetic", "venice", "zenmux", "qianfan", "xiaomi", "xiaomi-token-plan-ams", "xiaomi-token-plan-cn", "xiaomi-token-plan-sgp", "minimax-code", "minimax-code-cn", "zai", "minimax", "tencent"];
|
|
17
|
+
|
|
16
18
|
/** Narrow an AuthProvider to the OAuth-capable subset (xai/kimi have no OAuth flow). */
|
|
17
19
|
export function isOAuthProvider(p: AuthProvider): p is OAuthProvider {
|
|
18
20
|
return (OAUTH_PROVIDERS as readonly string[]).includes(p);
|
|
@@ -167,9 +169,13 @@ export async function resolveCredential(provider: AuthProvider): Promise<Credent
|
|
|
167
169
|
}).catch(() => {});
|
|
168
170
|
}
|
|
169
171
|
const result = await refreshPromise;
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
// refreshOAuthToken encodes the best credential for every outcome: the
|
|
173
|
+
// fresh token on success, the stale token on a transient blip, and an
|
|
174
|
+
// API-key/logged-out fallback after a definitive (dead refresh token)
|
|
175
|
+
// failure cleared the credential. Return it directly instead of reusing
|
|
176
|
+
// the in-memory stale token, so a dead OAuth credential degrades cleanly
|
|
177
|
+
// (to an API key or re-login) rather than looping on a doomed refresh.
|
|
178
|
+
return result.credential;
|
|
173
179
|
} catch {
|
|
174
180
|
// Fall through and use the (stale) access token; the provider call will surface a 401.
|
|
175
181
|
}
|
package/src/cli/runner.ts
CHANGED
|
@@ -235,6 +235,15 @@ export const COMMANDS: readonly CommandSpec[] = [
|
|
|
235
235
|
return args => Promise.resolve(m.runLedger(args));
|
|
236
236
|
},
|
|
237
237
|
},
|
|
238
|
+
{
|
|
239
|
+
name: "computer",
|
|
240
|
+
summary: "Execute desktop automation actions (screenshot, click, type, keypress, scroll, drag, wait, batch).",
|
|
241
|
+
usage: "computer <action> [args]",
|
|
242
|
+
loader: async () => {
|
|
243
|
+
const m = await import("../commands/computer");
|
|
244
|
+
return args => m.runComputerCommand(args);
|
|
245
|
+
},
|
|
246
|
+
},
|
|
238
247
|
];
|
|
239
248
|
|
|
240
249
|
export function findCommand(name: string): CommandSpec | undefined {
|
package/src/cli.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { dispatch } from "./cli/runner";
|
|
3
|
+
import { restoreTerminalState } from "./util/terminal-restore";
|
|
3
4
|
import pkg from "../package.json";
|
|
4
5
|
|
|
5
6
|
const APP_NAME = "jeo";
|
|
@@ -16,11 +17,26 @@ if (typeof Bun !== "undefined" && Bun.semver?.order(Bun.version, MIN_BUN_VERSION
|
|
|
16
17
|
}
|
|
17
18
|
process.title = APP_NAME;
|
|
18
19
|
|
|
20
|
+
// Last-resort crash net: a background `fetch()` socket error (or any stray
|
|
21
|
+
// rejection/throw) can tear the process down outside the try/catch below, AFTER
|
|
22
|
+
// the REPL has put stdin in raw mode + enabled bracketed paste. Without this the
|
|
23
|
+
// shell is left mute ("error printed, then input is dead"). Restore the terminal
|
|
24
|
+
// SYNCHRONOUSLY, print one clean line, and exit non-zero — never a raw stack dump.
|
|
25
|
+
const fatal = (err: unknown): never => {
|
|
26
|
+
restoreTerminalState();
|
|
27
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
28
|
+
process.stderr.write(`error: ${msg}\n`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
};
|
|
31
|
+
process.on("uncaughtException", fatal);
|
|
32
|
+
process.on("unhandledRejection", fatal);
|
|
33
|
+
|
|
19
34
|
try {
|
|
20
35
|
const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
|
|
21
36
|
if (code !== 0) process.exit(code);
|
|
22
37
|
} catch (err) {
|
|
23
38
|
// Service-readiness: never surface a raw stack trace to users; clean error + non-zero exit.
|
|
39
|
+
restoreTerminalState();
|
|
24
40
|
process.stderr.write(`error: ${(err as Error)?.message ?? String(err)}\n`);
|
|
25
41
|
process.exit(1);
|
|
26
42
|
}
|
package/src/commands/approve.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
3
4
|
import {
|
|
4
5
|
readGlobalConfig,
|
|
5
6
|
readWorkflowState,
|
|
@@ -75,8 +76,10 @@ export async function runApproveCommand(args: string[] = []): Promise<void> {
|
|
|
75
76
|
// rubber stamp — validate the plan against the exact contract `jeo team`
|
|
76
77
|
// enforces, so a schema-invalid/unknown-role plan is refused HERE instead of
|
|
77
78
|
// aborting later at execution time.
|
|
79
|
+
let planContent = "";
|
|
78
80
|
try {
|
|
79
|
-
|
|
81
|
+
planContent = await fs.readFile(resolvedInputPath, "utf-8");
|
|
82
|
+
const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(planContent)));
|
|
80
83
|
if (!parsed.success) {
|
|
81
84
|
console.log(
|
|
82
85
|
`[ERROR] Refusing to approve: the plan is not in the shape 'jeo team' executes (top-level 'steps:' list of { name, role? }).\n` +
|
|
@@ -114,6 +117,19 @@ export async function runApproveCommand(args: string[] = []): Promise<void> {
|
|
|
114
117
|
return;
|
|
115
118
|
}
|
|
116
119
|
|
|
120
|
+
// Round-13: verify the plan's hash matches the consensus hash to prevent silent edits
|
|
121
|
+
if (ralplanState.consensus_hash) {
|
|
122
|
+
const currentHash = createHash("sha256").update(planContent).digest("hex");
|
|
123
|
+
if (currentHash !== ralplanState.consensus_hash) {
|
|
124
|
+
console.log(
|
|
125
|
+
`[ERROR] Refusing to approve: the plan file has been modified since the consensus critic reviewed it.\n` +
|
|
126
|
+
` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve again.`
|
|
127
|
+
);
|
|
128
|
+
process.exitCode = 1;
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
117
133
|
// Update ralplan-state.json to approved: true
|
|
118
134
|
ralplanState.approved = true;
|
|
119
135
|
await writeWorkflowState("ralplan", ralplanState, cwd);
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import * as os from "node:os";
|
|
4
|
+
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
5
|
+
import { readGlobalConfig } from "../agent/state";
|
|
6
|
+
|
|
7
|
+
export interface ComputerAction {
|
|
8
|
+
action: "screenshot" | "click" | "double_click" | "move" | "drag" | "scroll" | "type" | "keypress" | "wait" | "batch";
|
|
9
|
+
x?: number;
|
|
10
|
+
y?: number;
|
|
11
|
+
text?: string;
|
|
12
|
+
key?: string;
|
|
13
|
+
deltaX?: number;
|
|
14
|
+
deltaY?: number;
|
|
15
|
+
duration?: number;
|
|
16
|
+
actions?: ComputerAction[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function logAudit(action: ComputerAction, success: boolean, error?: string) {
|
|
20
|
+
try {
|
|
21
|
+
const auditDir = path.join(process.cwd(), ".jeo");
|
|
22
|
+
await fs.mkdir(auditDir, { recursive: true });
|
|
23
|
+
const auditPath = path.join(auditDir, "computer-audit.jsonl");
|
|
24
|
+
const logEntry = JSON.stringify({
|
|
25
|
+
timestamp: new Date().toISOString(),
|
|
26
|
+
action,
|
|
27
|
+
success,
|
|
28
|
+
error,
|
|
29
|
+
}) + "\n";
|
|
30
|
+
await fs.appendFile(auditPath, logEntry, "utf-8");
|
|
31
|
+
} catch {
|
|
32
|
+
// best-effort
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: string; stderr: string }> {
|
|
37
|
+
try {
|
|
38
|
+
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
39
|
+
await proc.exited;
|
|
40
|
+
const stdout = await new Response(proc.stdout).text();
|
|
41
|
+
const stderr = await new Response(proc.stderr).text();
|
|
42
|
+
return {
|
|
43
|
+
success: proc.exitCode === 0,
|
|
44
|
+
stdout,
|
|
45
|
+
stderr,
|
|
46
|
+
};
|
|
47
|
+
} catch (err: any) {
|
|
48
|
+
return {
|
|
49
|
+
success: false,
|
|
50
|
+
stdout: "",
|
|
51
|
+
stderr: err.message || String(err),
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function executeComputerAction(action: ComputerAction): Promise<{ success: boolean; output: string; error?: string }> {
|
|
57
|
+
const config = await readGlobalConfig().catch(() => null);
|
|
58
|
+
if (!config?.computer?.enabled) {
|
|
59
|
+
return {
|
|
60
|
+
success: false,
|
|
61
|
+
output: "",
|
|
62
|
+
error: "Computer use is disabled. Enable it by setting 'computer.enabled: true' in ~/.jeo/config.json",
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Read-only actions don't need supervisor inputAllowed check
|
|
67
|
+
const isReadOnly = action.action === "screenshot" || action.action === "wait";
|
|
68
|
+
if (!isReadOnly) {
|
|
69
|
+
if (!computerSupervisor.inputAllowed) {
|
|
70
|
+
const err = "Action blocked by fail-closed supervisor (kill switch not live, heartbeat stale, or suspended).";
|
|
71
|
+
await logAudit(action, false, err);
|
|
72
|
+
return { success: false, output: "", error: err };
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const isMac = os.platform() === "darwin";
|
|
77
|
+
|
|
78
|
+
switch (action.action) {
|
|
79
|
+
case "screenshot": {
|
|
80
|
+
const tmpDir = os.tmpdir();
|
|
81
|
+
const screenPath = path.join(tmpDir, `jeo-screen-${Date.now()}.png`);
|
|
82
|
+
let cmd: string[];
|
|
83
|
+
if (isMac) {
|
|
84
|
+
cmd = ["screencapture", "-x", screenPath];
|
|
85
|
+
} else {
|
|
86
|
+
cmd = ["scrot", "-z", screenPath];
|
|
87
|
+
}
|
|
88
|
+
const res = await runCommand(cmd);
|
|
89
|
+
if (!res.success) {
|
|
90
|
+
await logAudit(action, false, res.stderr);
|
|
91
|
+
return { success: false, output: "", error: `Failed to capture screenshot: ${res.stderr}` };
|
|
92
|
+
}
|
|
93
|
+
try {
|
|
94
|
+
const bytes = await fs.readFile(screenPath);
|
|
95
|
+
const base64 = bytes.toString("base64");
|
|
96
|
+
await fs.unlink(screenPath).catch(() => {});
|
|
97
|
+
await logAudit(action, true);
|
|
98
|
+
return { success: true, output: base64 };
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
await logAudit(action, false, err.message);
|
|
101
|
+
return { success: false, output: "", error: `Failed to read screenshot file: ${err.message}` };
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
case "click": {
|
|
106
|
+
if (action.x === undefined || action.y === undefined) {
|
|
107
|
+
return { success: false, output: "", error: "Missing coordinates x and y for click action." };
|
|
108
|
+
}
|
|
109
|
+
let cmd: string[];
|
|
110
|
+
if (isMac) {
|
|
111
|
+
cmd = ["cliclick", `c:${action.x},${action.y}`];
|
|
112
|
+
} else {
|
|
113
|
+
cmd = ["xdotool", "mousemove", String(action.x), String(action.y), "click", "1"];
|
|
114
|
+
}
|
|
115
|
+
const res = await runCommand(cmd);
|
|
116
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
117
|
+
return res.success
|
|
118
|
+
? { success: true, output: `Clicked at (${action.x}, ${action.y})` }
|
|
119
|
+
: { success: false, output: "", error: `Click failed: ${res.stderr}` };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
case "double_click": {
|
|
123
|
+
if (action.x === undefined || action.y === undefined) {
|
|
124
|
+
return { success: false, output: "", error: "Missing coordinates x and y for double_click action." };
|
|
125
|
+
}
|
|
126
|
+
let cmd: string[];
|
|
127
|
+
if (isMac) {
|
|
128
|
+
cmd = ["cliclick", `dc:${action.x},${action.y}`];
|
|
129
|
+
} else {
|
|
130
|
+
cmd = ["xdotool", "mousemove", String(action.x), String(action.y), "click", "--repeat", "2", "--delay", "100", "1"];
|
|
131
|
+
}
|
|
132
|
+
const res = await runCommand(cmd);
|
|
133
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
134
|
+
return res.success
|
|
135
|
+
? { success: true, output: `Double clicked at (${action.x}, ${action.y})` }
|
|
136
|
+
: { success: false, output: "", error: `Double click failed: ${res.stderr}` };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
case "move": {
|
|
140
|
+
if (action.x === undefined || action.y === undefined) {
|
|
141
|
+
return { success: false, output: "", error: "Missing coordinates x and y for move action." };
|
|
142
|
+
}
|
|
143
|
+
let cmd: string[];
|
|
144
|
+
if (isMac) {
|
|
145
|
+
cmd = ["cliclick", `m:${action.x},${action.y}`];
|
|
146
|
+
} else {
|
|
147
|
+
cmd = ["xdotool", "mousemove", String(action.x), String(action.y)];
|
|
148
|
+
}
|
|
149
|
+
const res = await runCommand(cmd);
|
|
150
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
151
|
+
return res.success
|
|
152
|
+
? { success: true, output: `Moved cursor to (${action.x}, ${action.y})` }
|
|
153
|
+
: { success: false, output: "", error: `Move failed: ${res.stderr}` };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
case "drag": {
|
|
157
|
+
if (action.x === undefined || action.y === undefined) {
|
|
158
|
+
return { success: false, output: "", error: "Missing coordinates x and y for drag action." };
|
|
159
|
+
}
|
|
160
|
+
let cmd: string[];
|
|
161
|
+
if (isMac) {
|
|
162
|
+
cmd = ["cliclick", `dd:${action.x},${action.y}`];
|
|
163
|
+
} else {
|
|
164
|
+
cmd = ["xdotool", "mousemove", String(action.x), String(action.y), "mousedown", "1"];
|
|
165
|
+
}
|
|
166
|
+
const res = await runCommand(cmd);
|
|
167
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
168
|
+
return res.success
|
|
169
|
+
? { success: true, output: `Dragged to (${action.x}, ${action.y})` }
|
|
170
|
+
: { success: false, output: "", error: `Drag failed: ${res.stderr}` };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
case "scroll": {
|
|
174
|
+
let cmd: string[];
|
|
175
|
+
const dx = action.deltaX ?? 0;
|
|
176
|
+
const dy = action.deltaY ?? 0;
|
|
177
|
+
if (isMac) {
|
|
178
|
+
// cliclick scroll deltaY deltaX
|
|
179
|
+
cmd = ["cliclick", `w:${dy},${dx}`];
|
|
180
|
+
} else {
|
|
181
|
+
// xdotool click 4 (scroll up), 5 (scroll down), 6 (scroll left), 7 (scroll right)
|
|
182
|
+
const actions: string[] = [];
|
|
183
|
+
if (dy > 0) {
|
|
184
|
+
for (let i = 0; i < dy; i++) actions.push("click", "4");
|
|
185
|
+
} else if (dy < 0) {
|
|
186
|
+
for (let i = 0; i < -dy; i++) actions.push("click", "5");
|
|
187
|
+
}
|
|
188
|
+
if (dx > 0) {
|
|
189
|
+
for (let i = 0; i < dx; i++) actions.push("click", "7");
|
|
190
|
+
} else if (dx < 0) {
|
|
191
|
+
for (let i = 0; i < -dx; i++) actions.push("click", "6");
|
|
192
|
+
}
|
|
193
|
+
cmd = ["xdotool", ...actions];
|
|
194
|
+
}
|
|
195
|
+
const res = await runCommand(cmd);
|
|
196
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
197
|
+
return res.success
|
|
198
|
+
? { success: true, output: `Scrolled by deltaX=${dx}, deltaY=${dy}` }
|
|
199
|
+
: { success: false, output: "", error: `Scroll failed: ${res.stderr}` };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case "type": {
|
|
203
|
+
if (action.text === undefined) {
|
|
204
|
+
return { success: false, output: "", error: "Missing text for type action." };
|
|
205
|
+
}
|
|
206
|
+
let cmd: string[];
|
|
207
|
+
if (isMac) {
|
|
208
|
+
cmd = ["cliclick", `t:${action.text}`];
|
|
209
|
+
} else {
|
|
210
|
+
cmd = ["xdotool", "type", action.text];
|
|
211
|
+
}
|
|
212
|
+
const res = await runCommand(cmd);
|
|
213
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
214
|
+
return res.success
|
|
215
|
+
? { success: true, output: `Typed text: ${action.text}` }
|
|
216
|
+
: { success: false, output: "", error: `Type failed: ${res.stderr}` };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
case "keypress": {
|
|
220
|
+
if (action.key === undefined) {
|
|
221
|
+
return { success: false, output: "", error: "Missing key for keypress action." };
|
|
222
|
+
}
|
|
223
|
+
let cmd: string[];
|
|
224
|
+
if (isMac) {
|
|
225
|
+
cmd = ["cliclick", `kp:${action.key}`];
|
|
226
|
+
} else {
|
|
227
|
+
cmd = ["xdotool", "key", action.key];
|
|
228
|
+
}
|
|
229
|
+
const res = await runCommand(cmd);
|
|
230
|
+
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
231
|
+
return res.success
|
|
232
|
+
? { success: true, output: `Pressed key: ${action.key}` }
|
|
233
|
+
: { success: false, output: "", error: `Keypress failed: ${res.stderr}` };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case "wait": {
|
|
237
|
+
const ms = (action.duration ?? 1) * 1000;
|
|
238
|
+
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
239
|
+
await logAudit(action, true);
|
|
240
|
+
return { success: true, output: `Waited for ${action.duration ?? 1} seconds` };
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
case "batch": {
|
|
244
|
+
if (!action.actions || !Array.isArray(action.actions)) {
|
|
245
|
+
return { success: false, output: "", error: "Missing actions array for batch action." };
|
|
246
|
+
}
|
|
247
|
+
const results: string[] = [];
|
|
248
|
+
for (let i = 0; i < action.actions.length; i++) {
|
|
249
|
+
const subAction = action.actions[i];
|
|
250
|
+
const res = await executeComputerAction(subAction);
|
|
251
|
+
if (!res.success) {
|
|
252
|
+
const err = `Batch failed at step ${i}: ${res.error}`;
|
|
253
|
+
await logAudit(action, false, err);
|
|
254
|
+
return { success: false, output: results.join("\n"), error: err };
|
|
255
|
+
}
|
|
256
|
+
results.push(`Step ${i}: ${res.output}`);
|
|
257
|
+
}
|
|
258
|
+
await logAudit(action, true);
|
|
259
|
+
return { success: true, output: results.join("\n") };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
default:
|
|
263
|
+
return { success: false, output: "", error: `Unknown computer action: ${(action as any).action}` };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
export async function runComputerCommand(args: string[]): Promise<void> {
|
|
268
|
+
const actionName = args[0];
|
|
269
|
+
if (!actionName) {
|
|
270
|
+
console.log("Usage: jeo computer <action> [args]");
|
|
271
|
+
console.log("Actions: screenshot, click, double_click, move, drag, scroll, type, keypress, wait, batch");
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const action: ComputerAction = { action: actionName as any };
|
|
276
|
+
|
|
277
|
+
// Parse arguments based on action
|
|
278
|
+
if (actionName === "click" || actionName === "double_click" || actionName === "move" || actionName === "drag") {
|
|
279
|
+
const x = parseInt(args[1] ?? "");
|
|
280
|
+
const y = parseInt(args[2] ?? "");
|
|
281
|
+
if (isNaN(x) || isNaN(y)) {
|
|
282
|
+
console.error(`Error: ${actionName} requires x and y coordinates.`);
|
|
283
|
+
process.exit(1);
|
|
284
|
+
}
|
|
285
|
+
action.x = x;
|
|
286
|
+
action.y = y;
|
|
287
|
+
} else if (actionName === "type") {
|
|
288
|
+
const text = args.slice(1).join(" ");
|
|
289
|
+
if (!text) {
|
|
290
|
+
console.error("Error: type requires text.");
|
|
291
|
+
process.exit(1);
|
|
292
|
+
}
|
|
293
|
+
action.text = text;
|
|
294
|
+
} else if (actionName === "keypress") {
|
|
295
|
+
const key = args[1];
|
|
296
|
+
if (!key) {
|
|
297
|
+
console.error("Error: keypress requires a key name.");
|
|
298
|
+
process.exit(1);
|
|
299
|
+
}
|
|
300
|
+
action.key = key;
|
|
301
|
+
} else if (actionName === "scroll") {
|
|
302
|
+
const dx = parseInt(args[1] ?? "0");
|
|
303
|
+
const dy = parseInt(args[2] ?? "0");
|
|
304
|
+
action.deltaX = dx;
|
|
305
|
+
action.deltaY = dy;
|
|
306
|
+
} else if (actionName === "wait") {
|
|
307
|
+
const duration = parseFloat(args[1] ?? "1");
|
|
308
|
+
action.duration = duration;
|
|
309
|
+
} else if (actionName === "batch") {
|
|
310
|
+
const jsonStr = args.slice(1).join(" ");
|
|
311
|
+
try {
|
|
312
|
+
action.actions = JSON.parse(jsonStr);
|
|
313
|
+
} catch (err: any) {
|
|
314
|
+
console.error(`Error: batch requires a valid JSON array of actions. ${err.message}`);
|
|
315
|
+
process.exit(1);
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Heartbeat to supervisor for CLI execution
|
|
320
|
+
computerSupervisor.setKillSwitchLive(true);
|
|
321
|
+
computerSupervisor.heartbeat();
|
|
322
|
+
|
|
323
|
+
const res = await executeComputerAction(action);
|
|
324
|
+
if (res.success) {
|
|
325
|
+
if (actionName === "screenshot") {
|
|
326
|
+
console.log(res.output); // base64 output
|
|
327
|
+
} else {
|
|
328
|
+
console.log(res.output);
|
|
329
|
+
}
|
|
330
|
+
} else {
|
|
331
|
+
console.error(`Error: ${res.error}`);
|
|
332
|
+
process.exit(1);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { verticalCursorOffset } from "../../tui/components/input-box";
|
|
2
|
+
|
|
1
3
|
export interface InFlightAbortHarness {
|
|
2
4
|
controller: AbortController;
|
|
3
5
|
handleSigint(): void;
|
|
@@ -24,6 +26,8 @@ interface AbortHarnessOptions {
|
|
|
24
26
|
* Without this hook the byte would be swallowed into the buffered input queue,
|
|
25
27
|
* which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
|
|
26
28
|
onDetailKey?: () => void;
|
|
29
|
+
/** Invoked when Ctrl+\\ (\u001c) is pressed mid-turn — the safety kill-switch binding. */
|
|
30
|
+
onKillSwitch?: () => void;
|
|
27
31
|
/** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
|
|
28
32
|
* open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
|
|
29
33
|
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
@@ -65,16 +69,51 @@ export function isGenuineMultilineDraft(line: string): boolean {
|
|
|
65
69
|
return line.includes(MULTILINE_SENTINEL);
|
|
66
70
|
}
|
|
67
71
|
|
|
68
|
-
/** Whether an Up/Down keystroke should
|
|
69
|
-
* (textarea feel) instead of
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
72
|
+
/** Whether an Up/Down keystroke should be CONSIDERED for moving the caret between the
|
|
73
|
+
* box's visual rows (textarea feel) instead of going straight to readline. True for any
|
|
74
|
+
* non-empty draft when no slash dropdown or Ctrl+O history panel owns the arrows.
|
|
75
|
+
*
|
|
76
|
+
* This is a gate, NOT the final decision: the caller still consults
|
|
77
|
+
* `verticalCursorOffset`, which returns a target offset only when there is a visual row
|
|
78
|
+
* to move to. So ↑/↓ edit a MULTI-ROW draft — whether it spans rows via explicit
|
|
79
|
+
* Shift+Enter breaks OR a long line the box soft-wraps — yet still fall through to
|
|
80
|
+
* input-history recall at the boundaries: ↑ on the TOP visual row, ↓ on the BOTTOM row,
|
|
81
|
+
* and any single-visual-row draft (where `verticalCursorOffset` yields null). That
|
|
82
|
+
* boundary-aware split gives full arrow-key cursor movement for editing multi-row input
|
|
83
|
+
* while preserving "↑ recalls the previous prompt" once the caret can climb no higher. */
|
|
73
84
|
export function shouldBoxVerticalNav(
|
|
74
85
|
line: string,
|
|
75
86
|
opts: { slashMatchCount: number; historyPanelOpen: boolean },
|
|
76
87
|
): boolean {
|
|
77
|
-
return
|
|
88
|
+
return line.length > 0 && opts.slashMatchCount === 0 && !opts.historyPanelOpen;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** The action an Up/Down keystroke should take on the boxed prompt, given the draft and
|
|
92
|
+
* caret. `expandedLine` has explicit breaks expanded to "\n" (the wrapped row model the
|
|
93
|
+
* box renders); `rawLine` keeps the sentinels so a genuine multi-line draft is detectable.
|
|
94
|
+
*
|
|
95
|
+
* - "move": a visual row exists in `dir` → reposition the caret to `cursor` (textarea feel).
|
|
96
|
+
* - "swallow": no row to move to AND the draft is a GENUINE multi-line message → keep the
|
|
97
|
+
* keystroke INSIDE the box. Falling through to readline at the top/bottom edge
|
|
98
|
+
* would recall input history and WIPE the multi-line draft the user is composing
|
|
99
|
+
* (the "↓ cuts the lower text" bug) — so the boundary key is a deliberate no-op.
|
|
100
|
+
* - "history": no row to move to on a SOFT-WRAPPED single line → fall through to readline so
|
|
101
|
+
* ↑/↓ recall input history at the edges (the dominant one-liner REPL expectation). */
|
|
102
|
+
export type BoxVerticalNavAction =
|
|
103
|
+
| { kind: "move"; cursor: number }
|
|
104
|
+
| { kind: "swallow" }
|
|
105
|
+
| { kind: "history" };
|
|
106
|
+
|
|
107
|
+
export function boxVerticalNavAction(
|
|
108
|
+
expandedLine: string,
|
|
109
|
+
rawLine: string,
|
|
110
|
+
cursor: number,
|
|
111
|
+
width: number,
|
|
112
|
+
dir: "up" | "down",
|
|
113
|
+
): BoxVerticalNavAction {
|
|
114
|
+
const next = verticalCursorOffset(expandedLine, cursor, width, dir);
|
|
115
|
+
if (next != null) return { kind: "move", cursor: next };
|
|
116
|
+
return isGenuineMultilineDraft(rawLine) ? { kind: "swallow" } : { kind: "history" };
|
|
78
117
|
}
|
|
79
118
|
|
|
80
119
|
|
|
@@ -372,6 +411,10 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
372
411
|
opts.onDetailKey?.();
|
|
373
412
|
return;
|
|
374
413
|
}
|
|
414
|
+
if (text === "\u001c") {
|
|
415
|
+
opts.onKillSwitch?.();
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
375
418
|
if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
|
|
376
419
|
if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
|
|
377
420
|
if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
|
package/src/commands/launch.ts
CHANGED
|
@@ -62,7 +62,7 @@ import { liveModelPicker, renderLiveModelPicker, type ModelAssignmentBadge } fro
|
|
|
62
62
|
import { loginPicker, renderLoginPicker, onboardingPicker, renderOnboardingPicker, apiKeyPicker, renderApiKeyPicker, subscriptionLoginPicker, type OnboardingAction } from "../tui/components/provider-picker";
|
|
63
63
|
import { detectLanguage, languageLabel, parseLineRange, sliceLines, formatCodeBlock, formatDiff, sanitizeForTerminal } from "../tui/components/code-view";
|
|
64
64
|
import { categoryBadge } from "../tui/components/category-index";
|
|
65
|
-
import { renderInputFrame,
|
|
65
|
+
import { renderInputFrame, type HighlightRange } from "../tui/components/input-box";
|
|
66
66
|
|
|
67
67
|
import { renderStatusBar } from "../tui/components/status";
|
|
68
68
|
import { detectColorLevel, ColorLevel, visibleWidth } from "../tui/components/color";
|
|
@@ -139,6 +139,7 @@ import {
|
|
|
139
139
|
MULTILINE_SENTINEL,
|
|
140
140
|
isGenuineMultilineDraft,
|
|
141
141
|
shouldBoxVerticalNav,
|
|
142
|
+
boxVerticalNavAction,
|
|
142
143
|
queuePromptInputChunk,
|
|
143
144
|
captureLivePromptInputChunk,
|
|
144
145
|
restoreQueuedLinesToPrefill,
|
|
@@ -209,6 +210,7 @@ export {
|
|
|
209
210
|
MULTILINE_SENTINEL,
|
|
210
211
|
isGenuineMultilineDraft,
|
|
211
212
|
shouldBoxVerticalNav,
|
|
213
|
+
boxVerticalNavAction,
|
|
212
214
|
queuePromptInputChunk,
|
|
213
215
|
captureLivePromptInputChunk,
|
|
214
216
|
restoreQueuedLinesToPrefill,
|
|
@@ -250,11 +252,11 @@ function providerDefaultModel(p: ProviderName): string {
|
|
|
250
252
|
* empty.
|
|
251
253
|
*
|
|
252
254
|
* Live discovery yields ids only for a logged-in, reachable provider, and
|
|
253
|
-
* `catalogOr` backfills the static catalog
|
|
254
|
-
*
|
|
255
|
-
*
|
|
256
|
-
* capability catalog; else its single known default model (all
|
|
257
|
-
* providers carry one) so the user always sees at least one
|
|
255
|
+
* `catalogOr` backfills the static catalog for OAuth sources and for API-key
|
|
256
|
+
* providers whose models-list endpoint is absent (HTTP 404, e.g. Tencent MaaS).
|
|
257
|
+
* A not-yet-configured provider still has an empty live list, so prefer live ids;
|
|
258
|
+
* else the provider's capability catalog; else its single known default model (all
|
|
259
|
+
* 24 OpenAI-compat providers carry one) so the user always sees at least one id.
|
|
258
260
|
*/
|
|
259
261
|
export function providerPickEntries(live: ProviderModelsResult[], want: ProviderName): PickEntry[] {
|
|
260
262
|
const fromLive = flattenModels(live.filter(r => r.provider === want));
|
|
@@ -1469,14 +1471,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1469
1471
|
// Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
|
|
1470
1472
|
const combo = matchCursorCombo(data, i);
|
|
1471
1473
|
if (combo) { out += combo[1]; i += combo[0].length; continue; }
|
|
1472
|
-
// Up/Down
|
|
1473
|
-
//
|
|
1474
|
-
//
|
|
1475
|
-
//
|
|
1476
|
-
//
|
|
1477
|
-
//
|
|
1478
|
-
//
|
|
1479
|
-
//
|
|
1474
|
+
// Up/Down move the caret BETWEEN the box's visual rows (textarea feel) for any
|
|
1475
|
+
// MULTI-ROW draft — explicit Shift+Enter breaks (→ SENTINEL) OR a long line the
|
|
1476
|
+
// box soft-wraps. The decision is boundary-aware: an in-box visual row exists →
|
|
1477
|
+
// move the caret there. With NO row to move to (↑ on the TOP row, ↓ on the BOTTOM
|
|
1478
|
+
// row), a SOFT-WRAPPED one-liner falls through to readline to recall input history
|
|
1479
|
+
// (the dominant REPL expectation), but a GENUINE multi-line draft SWALLOWS the key
|
|
1480
|
+
// instead — falling through there would let readline's history nav WIPE the
|
|
1481
|
+
// multi-line message being composed (the "↓ cuts the lower text" bug). Skipped
|
|
1482
|
+
// entirely when a slash list or Ctrl+O history panel owns ↑/↓.
|
|
1483
|
+
|
|
1480
1484
|
if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
|
|
1481
1485
|
const dir = data[i + 2] === "A" ? "up" : "down";
|
|
1482
1486
|
const line = activeRl?.line ?? "";
|
|
@@ -1484,8 +1488,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
1484
1488
|
const winCols = Math.max(24, (process.stdout.columns ?? 80) - 1);
|
|
1485
1489
|
const textWidth = Math.max(1, Math.max(24, winCols) - 6);
|
|
1486
1490
|
const cur = typeof activeRl.cursor === "number" ? activeRl.cursor : line.length;
|
|
1487
|
-
const
|
|
1488
|
-
if (
|
|
1491
|
+
const action = boxVerticalNavAction(expandSentinel(line), line, cur, textWidth, dir);
|
|
1492
|
+
if (action.kind === "move") { activeRl.cursor = action.cursor; i += 3; continue; }
|
|
1493
|
+
if (action.kind === "swallow") { i += 3; continue; } // keep the multi-line draft intact
|
|
1489
1494
|
}
|
|
1490
1495
|
out += data.slice(i, i + 3); i += 3; continue;
|
|
1491
1496
|
}
|
|
@@ -2167,10 +2172,13 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2167
2172
|
process.stdin.setRawMode(true);
|
|
2168
2173
|
}
|
|
2169
2174
|
process.stdin.resume();
|
|
2170
|
-
const cols = Math.max(40, terminalSize().cols - 2);
|
|
2171
|
-
const rows = Math.max(6, terminalSize().rows - 6);
|
|
2172
2175
|
let rendered = 0;
|
|
2173
2176
|
const repaint = () => {
|
|
2177
|
+
// Re-measure on EVERY paint so the picker tracks live terminal resizes. The old code
|
|
2178
|
+
// captured cols/rows ONCE at open, so a picker left open across a resize never reflowed
|
|
2179
|
+
// and stayed pinned to its opening geometry — it never recovered after a shrink→grow.
|
|
2180
|
+
const cols = Math.max(40, terminalSize().cols - 2);
|
|
2181
|
+
const rows = Math.max(6, terminalSize().rows - 6);
|
|
2174
2182
|
const lines = render(cols, rows).map(line => truncateAnsi(line, cols));
|
|
2175
2183
|
const total = Math.max(rendered, lines.length);
|
|
2176
2184
|
// First paint: drop down ONCE to start on a fresh row; subsequent paints:
|
|
@@ -2205,6 +2213,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2205
2213
|
rendered = 0;
|
|
2206
2214
|
};
|
|
2207
2215
|
repaint();
|
|
2216
|
+
// Reflow the picker on terminal resize. The terminal has already reflowed the old
|
|
2217
|
+
// block's rows, so `rendered` no longer maps 1:1 to physical rows: drop the stale
|
|
2218
|
+
// accounting, wipe the visible screen (scrollback kept), and repaint at the freshly
|
|
2219
|
+
// measured geometry. Debounced so a drag-resize coalesces into a single clean repaint.
|
|
2220
|
+
let resizeTimer: ReturnType<typeof setTimeout> | undefined;
|
|
2221
|
+
const onPickerResize = () => {
|
|
2222
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
2223
|
+
resizeTimer = setTimeout(() => {
|
|
2224
|
+
resizeTimer = undefined;
|
|
2225
|
+
try { rendered = 0; out.write(clearVisible()); repaint(); } catch { /* resize render race */ }
|
|
2226
|
+
}, 16);
|
|
2227
|
+
};
|
|
2228
|
+
process.stdout.on("resize", onPickerResize);
|
|
2208
2229
|
try {
|
|
2209
2230
|
await new Promise<void>(resolve => {
|
|
2210
2231
|
const handler = (ch: string, key: { name?: string; ctrl?: boolean; meta?: boolean } | undefined) => {
|
|
@@ -2220,6 +2241,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
2220
2241
|
process.stdin.on("keypress", handler);
|
|
2221
2242
|
});
|
|
2222
2243
|
} finally {
|
|
2244
|
+
process.stdout.off("resize", onPickerResize);
|
|
2245
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
2223
2246
|
if (process.stdin.setRawMode && !wasRaw) {
|
|
2224
2247
|
process.stdin.setRawMode(false);
|
|
2225
2248
|
}
|