jeo-code 0.6.40 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- if (result.refreshed && result.credential.kind === "oauth") {
171
- return result.credential;
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
  }
@@ -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
- const parsed = PlanSchema.safeParse(normalizePlanShape(parseYaml(await fs.readFile(resolvedInputPath, "utf-8"))));
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
+ }
@@ -24,6 +24,8 @@ interface AbortHarnessOptions {
24
24
  * Without this hook the byte would be swallowed into the buffered input queue,
25
25
  * which is why Ctrl+O historically "did nothing" while the TUI owned stdin. */
26
26
  onDetailKey?: () => void;
27
+ /** Invoked when Ctrl+\\ (\u001c) is pressed mid-turn — the safety kill-switch binding. */
28
+ onKillSwitch?: () => void;
27
29
  /** Invoked when an arrow / PageUp / PageDown key arrives mid-turn — scrolls the
28
30
  * open Ctrl+O detail panel. dir -1 = up/back, +1 = down/forward; page = full jump. */
29
31
  onScrollKey?: (dir: -1 | 1, page: boolean) => void;
@@ -65,16 +67,23 @@ export function isGenuineMultilineDraft(line: string): boolean {
65
67
  return line.includes(MULTILINE_SENTINEL);
66
68
  }
67
69
 
68
- /** Whether an Up/Down keystroke should move the caret BETWEEN the box's visual rows
69
- * (textarea feel) instead of falling through to readline's input-history recall. True
70
- * only for a genuinely multi-line draft with no slash dropdown or history panel owning
71
- * the arrows. A soft-wrapped one-liner returns false, so ↑ recalls the previous prompt
72
- * rather than dragging the wrapped tail up a visual row. */
70
+ /** Whether an Up/Down keystroke should be CONSIDERED for moving the caret between the
71
+ * box's visual rows (textarea feel) instead of going straight to readline. True for any
72
+ * non-empty draft when no slash dropdown or Ctrl+O history panel owns the arrows.
73
+ *
74
+ * This is a gate, NOT the final decision: the caller still consults
75
+ * `verticalCursorOffset`, which returns a target offset only when there is a visual row
76
+ * to move to. So ↑/↓ edit a MULTI-ROW draft — whether it spans rows via explicit
77
+ * Shift+Enter breaks OR a long line the box soft-wraps — yet still fall through to
78
+ * input-history recall at the boundaries: ↑ on the TOP visual row, ↓ on the BOTTOM row,
79
+ * and any single-visual-row draft (where `verticalCursorOffset` yields null). That
80
+ * boundary-aware split gives full arrow-key cursor movement for editing multi-row input
81
+ * while preserving "↑ recalls the previous prompt" once the caret can climb no higher. */
73
82
  export function shouldBoxVerticalNav(
74
83
  line: string,
75
84
  opts: { slashMatchCount: number; historyPanelOpen: boolean },
76
85
  ): boolean {
77
- return isGenuineMultilineDraft(line) && opts.slashMatchCount === 0 && !opts.historyPanelOpen;
86
+ return line.length > 0 && opts.slashMatchCount === 0 && !opts.historyPanelOpen;
78
87
  }
79
88
 
80
89
 
@@ -372,6 +381,10 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
372
381
  opts.onDetailKey?.();
373
382
  return;
374
383
  }
384
+ if (text === "\u001c") {
385
+ opts.onKillSwitch?.();
386
+ return;
387
+ }
375
388
  if (text === "\u001b[A") { opts.onScrollKey?.(-1, false); return; }
376
389
  if (text === "\u001b[B") { opts.onScrollKey?.(1, false); return; }
377
390
  if (text === "\u001b[5~") { opts.onScrollKey?.(-1, true); return; }
@@ -1025,6 +1025,31 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1025
1025
  }
1026
1026
  sessionUsage.turns++;
1027
1027
  const usage = result.usage ? ` (${result.usage.inputTokens} in / ${result.usage.outputTokens} out tokens)` : "";
1028
+
1029
+ if (turnConfig.gitAutoCommit) {
1030
+ try {
1031
+ const statusRes = Bun.spawnSync(["git", "status", "--porcelain"], { cwd, stdout: "pipe", stderr: "ignore" });
1032
+ if (statusRes.exitCode === 0 && statusRes.stdout.toString().trim().length > 0) {
1033
+ Bun.spawnSync(["git", "add", "."], { cwd });
1034
+ const diffRes = Bun.spawnSync(["git", "diff", "--cached", "--name-status"], { cwd, stdout: "pipe", stderr: "ignore" });
1035
+ const changedFiles = diffRes.stdout.toString().trim().split("\n").map(line => line.split("\t")[1]).filter(Boolean);
1036
+ const fileSummary = changedFiles.length > 0 ? ` (modified: ${changedFiles.slice(0, 3).join(", ")}${changedFiles.length > 3 ? ", ..." : ""})` : "";
1037
+ const commitMsg = `[jeo] auto-commit${fileSummary}\n\nPrompt: ${userInput.slice(0, 100)}${userInput.length > 100 ? "..." : ""}`;
1038
+ const commitRes = Bun.spawnSync(["git", "commit", "-m", commitMsg], { cwd });
1039
+ if (commitRes.exitCode === 0) {
1040
+ if (tui) tui.events().onNotice?.(`(auto-committed changes to git)`);
1041
+ else console.log(`(auto-committed changes to git)`);
1042
+ } else {
1043
+ if (tui) tui.events().onNotice?.(`(auto-commit failed: git commit returned non-zero)`);
1044
+ else console.error(`(auto-commit failed: git commit returned non-zero)`);
1045
+ }
1046
+ }
1047
+ } catch (e) {
1048
+ if (tui) tui.events().onNotice?.(`(auto-commit failed: ${e instanceof Error ? e.message : String(e)})`);
1049
+ else console.error(`(auto-commit failed: ${e instanceof Error ? e.message : String(e)})`);
1050
+ }
1051
+ }
1052
+
1028
1053
  return { done: result.done, steps: result.steps, reply, rendered: !!tui, usage };
1029
1054
  };
1030
1055
 
@@ -1444,14 +1469,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1444
1469
  // Option/Cmd+Backspace) into the canonical control bytes it DOES act on.
1445
1470
  const combo = matchCursorCombo(data, i);
1446
1471
  if (combo) { out += combo[1]; i += combo[0].length; continue; }
1447
- // Up/Down inside a GENUINELY multi-line draft (explicit Shift+Enter breaks →
1448
- // SENTINEL) move the caret between the box's visual rows (textarea feel). A
1449
- // single line that merely SOFT-WRAPS is NOT treated as multi-line: ↑/↓ fall
1450
- // through to readline so they recall input history — the dominant REPL
1451
- // expectation. (Without this gate, ↑ on a wrapped one-liner jumped the caret up
1452
- // a visual row instead of recalling the previous prompt, so the last wrapped
1453
- // word appeared to "follow the caret up".) Skipped when a slash list or history
1454
- // panel owns ↑/↓, and at the top/bottom edge the keys still reach history.
1472
+ // Up/Down move the caret BETWEEN the box's visual rows (textarea feel) for any
1473
+ // MULTI-ROW draft — explicit Shift+Enter breaks (→ SENTINEL) OR a long line the
1474
+ // box soft-wraps. The decision is boundary-aware: verticalCursorOffset returns a
1475
+ // target only when a visual row exists to move to, so ↑ on the TOP row, ↓ on the
1476
+ // BOTTOM row, and any single-visual-row draft fall through to readline and recall
1477
+ // input history — the dominant REPL expectation once the caret can climb no
1478
+ // higher. Skipped when a slash list or Ctrl+O history panel owns ↑/↓.
1479
+
1455
1480
  if ((data.startsWith("\u001b[", i) || data.startsWith("\u001bO", i)) && (data[i + 2] === "A" || data[i + 2] === "B")) {
1456
1481
  const dir = data[i + 2] === "A" ? "up" : "down";
1457
1482
  const line = activeRl?.line ?? "";
@@ -3349,6 +3374,29 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3349
3374
  }
3350
3375
  continue;
3351
3376
  }
3377
+ if (input === "/undo") {
3378
+ try {
3379
+ const logRes = Bun.spawnSync(["git", "log", "-1", "--pretty=%B"], { cwd, stdout: "pipe", stderr: "ignore" });
3380
+ if (logRes.exitCode === 0) {
3381
+ const msg = logRes.stdout.toString().trim();
3382
+ if (msg.startsWith("[jeo] auto-commit:")) {
3383
+ const resetRes = Bun.spawnSync(["git", "reset", "--hard", "HEAD~1"], { cwd, stdout: "pipe", stderr: "pipe" });
3384
+ if (resetRes.exitCode === 0) {
3385
+ console.log("(undid the last jeo auto-commit and restored the working tree)");
3386
+ } else {
3387
+ console.log(`! undo failed: ${resetRes.stderr.toString().trim()}`);
3388
+ }
3389
+ } else {
3390
+ console.log("! the last commit was not made by jeo (no '[jeo] auto-commit:' prefix). Use git manually to revert.");
3391
+ }
3392
+ } else {
3393
+ console.log("! undo failed: could not read git log (not a git repo?)");
3394
+ }
3395
+ } catch (err) {
3396
+ console.log(`! undo failed: ${(err as Error).message}`);
3397
+ }
3398
+ continue;
3399
+ }
3352
3400
  if (input === "/goal" || input.startsWith("/goal ")) {
3353
3401
  const arg = input.substring(5).trim();
3354
3402
  if (!arg) {
@@ -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 { callLlm, type Message } from "../agent/loop";
4
5
  import {
5
6
  readWorkflowState,
@@ -268,6 +269,9 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
268
269
  ralplanState.plan_path = planPath;
269
270
  ralplanState.consensus = gate.verdict;
270
271
  ralplanState.consensus_detail = gate.detail.slice(0, 600);
272
+ if (gate.verdict === "okay") {
273
+ ralplanState.consensus_hash = createHash("sha256").update(cleanPlan).digest("hex");
274
+ }
271
275
  if (gate.verdict !== "okay") {
272
276
  ralplanState.approved = false;
273
277
  await writeWorkflowState("ralplan", ralplanState, cwd);
@@ -1,4 +1,5 @@
1
1
  import * as fs from "node:fs/promises";
2
+ import { createHash } from "node:crypto";
2
3
  import chalk from "chalk";
3
4
  import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
4
5
  import {
@@ -221,6 +222,18 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
221
222
  return { ok: false, reason: err.message };
222
223
  }
223
224
 
225
+ // Round-13: verify the plan's hash matches the consensus hash to prevent silent edits after approval
226
+ if (planState.consensus_hash) {
227
+ const currentHash = createHash("sha256").update(planContent).digest("hex");
228
+ if (currentHash !== planState.consensus_hash) {
229
+ log(
230
+ `[ERROR] Plan file has been modified since it was reviewed by the consensus critic.\n` +
231
+ ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve and execute again.`
232
+ );
233
+ return { ok: false, reason: "Plan file modified since consensus review" };
234
+ }
235
+ }
236
+
224
237
  let rawPlan: any;
225
238
  try {
226
239
  rawPlan = parseYaml(planContent);
package/src/index.ts CHANGED
@@ -29,3 +29,4 @@ export * from "./commands/deep-interview";
29
29
  export * from "./commands/ralplan";
30
30
  export * from "./commands/team";
31
31
  export * from "./commands/ultragoal";
32
+ export * from "./commands/computer";
@@ -16,10 +16,12 @@ Turn a bounded task into a working, verified outcome with the smallest correct c
16
16
 
17
17
  <execution_loop>
18
18
  1. Inspect the relevant files and conventions.
19
- 2. Make the minimum change that satisfies the assigned task.
20
- 3. Run focused verification with the available tools.
21
- 4. Remove debug leftovers.
22
- 5. Call `done` only after verification evidence is available.
19
+ 2. Decompose a large task into ordered subgoals; finish and verify one before starting the next.
20
+ 3. Make the minimum change that satisfies the current subgoal.
21
+ 4. Run focused verification with the available tools.
22
+ 5. When a step fails, extract the lesson, feed it into the next attempt, and split a stuck subgoal into a smaller one rather than retrying unchanged.
23
+ 6. Remove debug leftovers.
24
+ 7. Call `done` only after verification evidence is available.
23
25
  </execution_loop>
24
26
 
25
27
  <tool_protocol>