jeo-code 0.9.9 → 0.9.11

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.
@@ -13,6 +13,8 @@ import type { Message } from "../../agent/loop";
13
13
  import { createSession, deleteSession, renameSession, loadSession, listSessions, sessionPath, resolveSessionRef } from "../../agent/session";
14
14
  import { SessionPicker, renderSessionPicker } from "../../tui/components/session-picker";
15
15
  import { formatTranscript } from "../../tui/components/transcript";
16
+ import { importGjcSession, listGjcSessions, type GjcSessionLeaf } from "../../agent/gjc-session-import";
17
+ import { SelectList, renderSelectList, type SelectItem } from "../../tui/components/select-list";
16
18
 
17
19
  export interface SessionSlashCtx {
18
20
  cwd: string;
@@ -54,6 +56,56 @@ export interface SessionSlashResult {
54
56
 
55
57
  }
56
58
 
59
+ interface GjcResumeRequest {
60
+ sessionId?: string;
61
+ leafId?: string;
62
+ anyCwd: boolean;
63
+ error?: string;
64
+ }
65
+
66
+ function parseGjcResumeArgument(arg: string): GjcResumeRequest | undefined {
67
+ const tokens = arg.trim().split(/\s+/).filter(Boolean);
68
+ if (!tokens.some(token => token.toLowerCase().startsWith("gajae:"))) return undefined;
69
+
70
+ let refToken: string | undefined;
71
+ let anyCwd = false;
72
+ for (const token of tokens) {
73
+ if (token === "--any-cwd") {
74
+ anyCwd = true;
75
+ continue;
76
+ }
77
+ if (refToken !== undefined) {
78
+ return { anyCwd, error: `Unexpected argument '${token}'.` };
79
+ }
80
+ refToken = token;
81
+ }
82
+
83
+ if (!refToken || !refToken.toLowerCase().startsWith("gajae:")) {
84
+ return { anyCwd, error: "Use /resume gajae:<session-id>[#<leaf-entry-id>] [--any-cwd]." };
85
+ }
86
+
87
+ const reference = refToken.slice("gajae:".length);
88
+ if (!reference) return { anyCwd };
89
+ const pieces = reference.split("#");
90
+ if (pieces.length > 2 || !pieces[0]) {
91
+ return { anyCwd, error: "Malformed GJC reference. Use gajae:<session-id>[#<leaf-entry-id>]." };
92
+ }
93
+
94
+ return {
95
+ sessionId: pieces[0],
96
+ ...(pieces[1] ? { leafId: pieces[1] } : {}),
97
+ anyCwd,
98
+ };
99
+ }
100
+
101
+ function gjcResumeItems(pool: GjcSessionLeaf[]): SelectItem<GjcSessionLeaf>[] {
102
+ return pool.map(item => ({
103
+ value: item,
104
+ label: `${item.sessionId}#${item.leafId || "(no-leaf)"}`,
105
+ hint: item.selected ? "selected leaf" : item.cwd,
106
+ }));
107
+ }
108
+
57
109
  /**
58
110
  * Handle `/session [new|drop|delete|rename <title>|resume [id]|list|info]`
59
111
  * (default: list + current-session summary). Extracted verbatim from
@@ -126,9 +178,13 @@ export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Prom
126
178
  }
127
179
  if (sub === "resume") {
128
180
  const arg = tokens.slice(1).join(" ").trim();
129
- const applyResume = async (rid: string): Promise<void> => {
181
+ const applyResume = async (
182
+ rid: string,
183
+ loaded?: Awaited<ReturnType<typeof loadSession>>,
184
+ ): Promise<void> => {
130
185
  try {
131
- const { header, messages } = await loadSession(rid, cwd);
186
+ const loadedSession = loaded ?? await loadSession(rid, cwd);
187
+ const { header, messages } = loadedSession;
132
188
  history.length = 1;
133
189
  for (const m of messages) history.push(m);
134
190
  sessionId = rid;
@@ -181,6 +237,94 @@ export async function runSessionSlash(input: string, ctx: SessionSlashCtx): Prom
181
237
  console.log(`! ${(err as Error).message}`);
182
238
  }
183
239
  };
240
+ const gjcRequest = parseGjcResumeArgument(arg);
241
+ if (gjcRequest) {
242
+ if (gjcRequest.error) {
243
+ console.log(`! ${gjcRequest.error}`);
244
+ return result();
245
+ }
246
+
247
+ const importOne = async (source: GjcSessionLeaf | undefined): Promise<void> => {
248
+ if (!source) return;
249
+ try {
250
+ const imported = await importGjcSession({
251
+ sessionId: gjcRequest.sessionId ?? source.sessionId,
252
+ ...(gjcRequest.leafId || source.leafId ? { leafId: gjcRequest.leafId ?? source.leafId } : {}),
253
+ cwd,
254
+ anyCwd: gjcRequest.anyCwd,
255
+ });
256
+ console.log(
257
+ `(imported GJC v5 session ${imported.sourceSessionId}#${imported.sourceLeafId} as Jeo session ${imported.sessionId}${imported.reused ? " (reused)" : ""})`,
258
+ );
259
+ await applyResume(imported.sessionId);
260
+ } catch (err) {
261
+ console.log(`! GJC /resume failed: ${(err as Error).message}`);
262
+ }
263
+ };
264
+
265
+ if (gjcRequest.sessionId) {
266
+ await importOne({
267
+ rootPath: "",
268
+ sourcePath: "",
269
+ sessionId: gjcRequest.sessionId,
270
+ leafId: gjcRequest.leafId ?? "",
271
+ cwd,
272
+ selected: false,
273
+ });
274
+ return result();
275
+ }
276
+
277
+ let sourcePool: GjcSessionLeaf[];
278
+ try {
279
+ sourcePool = await listGjcSessions({ cwd, anyCwd: gjcRequest.anyCwd });
280
+ } catch (err) {
281
+ console.log(`! GJC /resume listing failed: ${(err as Error).message}`);
282
+ return result();
283
+ }
284
+ if (sourcePool.length === 0) {
285
+ console.log("(no GJC v5 sessions for this workspace)");
286
+ console.log("Usage: /resume gajae:<session-id>[#<leaf-entry-id>] [--any-cwd]");
287
+ return result();
288
+ }
289
+
290
+ if (process.stdin.isTTY && process.stdout.isTTY) {
291
+ const picker = new SelectList(gjcResumeItems(sourcePool));
292
+ let selected: SelectItem<GjcSessionLeaf> | undefined;
293
+ await runSelectPicker(
294
+ (cols, rows) => renderSelectList(picker, {
295
+ title: "Import a GJC v5 session",
296
+ cols,
297
+ rows: Math.max(8, rows),
298
+ unicode: true,
299
+ color: true,
300
+ }),
301
+ (ch, key) => {
302
+ if (key?.name === "up") { picker.up(); return false; }
303
+ if (key?.name === "down") { picker.down(); return false; }
304
+ if (key?.name === "pageup") { picker.page(-1); return false; }
305
+ if (key?.name === "pagedown") { picker.page(1); return false; }
306
+ if (key?.name === "backspace") { picker.backspace(); return false; }
307
+ if (key?.name === "escape" || (key?.ctrl && key.name === "c")) return true;
308
+ if (key?.name === "return" || key?.name === "enter") {
309
+ selected = picker.selected();
310
+ return true;
311
+ }
312
+ if (ch && ch >= " " && !key?.ctrl && !key?.meta) picker.typeChar(ch);
313
+ return false;
314
+ },
315
+ );
316
+ await importOne(selected?.value);
317
+ return result();
318
+ }
319
+
320
+ console.log("GJC v5 sessions — resume with /resume gajae:<session-id>[#<leaf-entry-id>]:");
321
+ for (const source of sourcePool.slice(0, 20)) {
322
+ const selected = source.selected ? " *" : "";
323
+ console.log(` ${source.sessionId}#${source.leafId || "(no-leaf)"}${selected} ${source.cwd}`);
324
+ }
325
+ console.log("Add --any-cwd to include sessions from other workspaces.");
326
+ return result();
327
+ }
184
328
  if (arg) {
185
329
  // gjc-parity: resolve `arg` as a full id OR a short id prefix before resuming.
186
330
  const resolved = await resolveSessionRef(arg, cwd);
@@ -46,6 +46,7 @@ import { renderWelcome, playWelcomeSweep } from "../tui/components/welcome";
46
46
  import { jeoEnv } from "../util/env";
47
47
  import { renderUpdateBox } from "../tui/components/update-box";
48
48
  import { consumeLaunchWhatsNew } from "../util/whats-new";
49
+ import { runWhatsNewCommand } from "./whats-new";
49
50
  import { maybeBell } from "../util/notify";
50
51
  import { supportsUnicode } from "../tui/components/capability";
51
52
  import pkg from "../../package.json";
@@ -225,6 +226,7 @@ import {
225
226
  } from "./launch/code-slash";
226
227
  import { handleUndoSlash } from "./launch/git-slash";
227
228
  import { runSessionSlash } from "./launch/session-slash";
229
+ import { runJobsSlash } from "./launch/jobs-slash";
228
230
  import { runModelSlash } from "./launch/model-slash";
229
231
  import { runRouteSlash } from "./launch/route-slash";
230
232
  import { runComputerSlash } from "./launch/computer-slash";
@@ -2416,7 +2418,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2416
2418
  // protocol nor xterm modifyOtherKeys. JEO_NO_MULTILINE=1 fully disables the
2417
2419
  // filter (reads stdin directly).
2418
2420
  const multilineInput = !!process.stdin.isTTY && jeoEnv("NO_MULTILINE") !== "1";
2419
- const loneLfShiftEnter = jeoEnv("MULTILINE") !== "0";
2421
+ // tmux-owned panes can translate the plain Enter key to LF after the launcher's
2422
+ // keyboard modes are enabled. In that mode LF must remain readline's submit key;
2423
+ // explicit Shift+Enter escape sequences still become MULTILINE_SENTINEL below.
2424
+ const loneLfShiftEnter =
2425
+ jeoEnv("MULTILINE") !== "0" && jeoEnv("TMUX_LAUNCHED") !== "1";
2420
2426
  const expandSentinel = (s: string): string => (multilineInput ? s.split(SENTINEL).join("\n") : s);
2421
2427
  // Prompt-scoped process listeners (stdin data/keypress, stdout resize). Registered
2422
2428
  // once per launch but previously anonymous and never removed — benign for a single
@@ -4323,6 +4329,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4323
4329
  logLines(handoffLines(res, process.stdout.columns));
4324
4330
  continue;
4325
4331
  }
4332
+ if (input === "/changelog" || input.startsWith("/changelog ")) {
4333
+ const args = input.slice("/changelog".length).trim().split(/\s+/).filter(Boolean)
4334
+ .map(arg => arg === "--full" ? "--all" : arg);
4335
+ await runWhatsNewCommand(args);
4336
+ continue;
4337
+ }
4338
+ if (input === "/jobs" || input.startsWith("/jobs ")) {
4339
+ await runJobsSlash(input, jobRegistry, cwd, lines => logLines(lines));
4340
+ continue;
4341
+ }
4326
4342
  if (input === "/session" || input.startsWith("/session ")) {
4327
4343
  const result = await runSessionSlash(input, {
4328
4344
  cwd, history, noSession: flags.noSession, sessionId, sessionModel, rl,
@@ -43,6 +43,36 @@ export interface UpdateDeps {
43
43
  activeVersion?: () => string | null;
44
44
  }
45
45
 
46
+ /**
47
+ * Ordered install attempts for `target` (`jeo-code@<version>`).
48
+ *
49
+ * `bun install -g` alone is not enough in practice: right after a publish bun can
50
+ * still hold a stale registry manifest and fail with `No version matching "<v>"
51
+ * found ... (but package exists)`. `--force` re-resolves against the registry and
52
+ * is what actually recovers that state. npm remains the last resort for
53
+ * npm-installed globals. Pure so the policy is unit-testable without spawning.
54
+ */
55
+ export function installCandidates(target: string): string[][] {
56
+ return [
57
+ ["bun", "install", "-g", target],
58
+ // Cache-buster: bun's resolver held a stale manifest for a just-published version.
59
+ ["bun", "install", "-g", target, "--force"],
60
+ ["npm", "install", "-g", target],
61
+ ];
62
+ }
63
+
64
+ /** Version of the `jeo` binary PATH actually resolves, or null when unreadable. */
65
+ function readActiveVersion(): string | null {
66
+ try {
67
+ const proc = Bun.spawnSync(["jeo", "--version"], { stdout: "pipe", stderr: "pipe" });
68
+ if (!proc.success) return null;
69
+ const out = new TextDecoder().decode(proc.stdout);
70
+ const m = out.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
71
+ return m ? m[1] : null;
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
46
76
  export const defaultDeps: UpdateDeps = {
47
77
  fetchJson: async (url: string, options?: { signal?: AbortSignal }) => {
48
78
  const res = await fetch(url, options);
@@ -65,17 +95,20 @@ export const defaultDeps: UpdateDeps = {
65
95
  // Self-update the global install via the SAME toolchain it was installed with. jeo
66
96
  // ships as a `#!/usr/bin/env bun` script so bun is the common case, but npm-installed
67
97
  // globals exist too — try bun first, fall back to npm (and tolerate either missing).
68
- // `@<version>` (the resolved latest) forces the newest publish past a stale global cache.
69
98
  const target = `jeo-code@${version ?? "latest"}`;
70
- const managers: string[][] = [
71
- ["bun", "install", "-g", target],
72
- ["npm", "install", "-g", target],
73
- ];
74
99
  let lastErr = "";
75
- for (const cmd of managers) {
100
+ for (const cmd of installCandidates(target)) {
76
101
  try {
77
102
  const proc = Bun.spawnSync(cmd, { stdout: "inherit", stderr: "inherit" });
78
- if (proc.success) return { success: true };
103
+ if (proc.success) {
104
+ // A zero exit is NOT proof the user's `jeo` moved: npm's global prefix is
105
+ // often a different directory than the bun prefix PATH actually resolves,
106
+ // so an npm fallback can "succeed" while the active binary is untouched.
107
+ // Only stop once the binary on PATH really reports the requested version.
108
+ if (!version || readActiveVersion() === version) return { success: true };
109
+ lastErr = `${cmd[0]} reported success but the active 'jeo' is still ${readActiveVersion() ?? "unknown"}`;
110
+ continue;
111
+ }
79
112
  lastErr = `${cmd[0]} exited with code ${proc.exitCode}`;
80
113
  } catch (err: any) {
81
114
  lastErr = `${cmd[0]} unavailable: ${err?.message ?? String(err)}`;
@@ -84,20 +117,10 @@ export const defaultDeps: UpdateDeps = {
84
117
  return { success: false, stderr: lastErr };
85
118
  }
86
119
  ,
87
- activeVersion: () => {
88
- // Read the version of the `jeo` actually on PATH (may differ from this process's bundled
89
- // version after a self-update, e.g. when bun installed to ~/.bun/bin but PATH prefers an
90
- // npm global). Used to detect a silent "installed but PATH unchanged" no-op.
91
- try {
92
- const proc = Bun.spawnSync(["jeo", "--version"], { stdout: "pipe", stderr: "pipe" });
93
- if (!proc.success) return null;
94
- const out = new TextDecoder().decode(proc.stdout);
95
- const m = out.match(/(\d+\.\d+\.\d+(?:-[\w.]+)?)/);
96
- return m ? m[1] : null;
97
- } catch {
98
- return null;
99
- }
100
- }
120
+ // Version of the `jeo` actually on PATH — may differ from this process's bundled
121
+ // version after a self-update (e.g. npm installed into a prefix PATH does not
122
+ // resolve). Detects the silent "installed but PATH unchanged" no-op.
123
+ activeVersion: readActiveVersion
101
124
  ,
102
125
  showWhatsNew: () => {
103
126
  try {
@@ -279,25 +302,31 @@ export async function runUpdateCommandWith(args: string[], deps: UpdateDeps): Pr
279
302
  try {
280
303
  const result = await deps.install(latest ?? undefined);
281
304
  if (result.success) {
305
+ // A package manager's zero exit does not prove the user's `jeo` moved: npm's
306
+ // global prefix is frequently a different directory than the one PATH resolves,
307
+ // so the install lands somewhere the user never runs. Verify the ACTIVE binary
308
+ // and treat a still-stale one as a failed update (exit 1) rather than printing
309
+ // a success line the machine contradicts.
310
+ const active = deps.activeVersion?.();
311
+ const stale = !!(active && latest && compareVersions(active, latest) < 0);
282
312
  if (hasJson) {
283
313
  console.log(JSON.stringify({
284
314
  current,
285
315
  latest,
286
316
  upToDate: false,
287
- installed: true
317
+ installed: !stale,
318
+ ...(stale ? { activeVersion: active, error: "active jeo still reports an older version" } : {}),
288
319
  }));
320
+ if (stale) process.exitCode = 1;
321
+ } else if (stale) {
322
+ console.error(`[FAILED] Installed jeo-code@${latest}, but the active 'jeo' on PATH still reports ${active}.`);
323
+ console.error(`The install landed in a prefix your PATH does not use. Fix it with one of:`);
324
+ console.error(` bun install -g jeo-code@${latest} --force`);
325
+ console.error(` npm install -g jeo-code@${latest}`);
326
+ console.error(`Then confirm with: jeo --version`);
327
+ process.exitCode = 1;
289
328
  } else {
290
329
  console.log(`Successfully installed jeo-code@${latest}`);
291
- // Verify the `jeo` on PATH actually picked up the new install — a bun-vs-npm or
292
- // PATH mismatch can leave an older binary in front, which looks like "update did
293
- // nothing". Surface that loudly with the manual fix instead of failing silently.
294
- const active = deps.activeVersion?.();
295
- if (active && latest && compareVersions(active, latest) < 0) {
296
- console.warn(`Warning: installed ${latest}, but the active 'jeo' on PATH still reports ${active}.`);
297
- console.warn(`Your PATH points at a different install. Fix it with one of:`);
298
- console.warn(` npm install -g jeo-code@${latest}`);
299
- console.warn(` bun install -g jeo-code@${latest}`);
300
- }
301
330
  deps.showWhatsNew?.();
302
331
  }
303
332
  } else {
@@ -27,7 +27,8 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
27
27
  { command: "/drop", usage: "/drop", description: "Delete the current session and start a new one", group: "session" },
28
28
  { command: "/session", usage: "/session [info|delete]", description: "Show current session info, or delete it", group: "session" },
29
29
  { command: "/rename", usage: "/rename <title>", description: "Rename the current session", group: "session" },
30
- { command: "/resume", usage: "/resume [id]", description: "Resume a saved session in this REPL", group: "session" },
30
+ { command: "/resume", usage: "/resume [id|gajae:<session-id>[#<leaf>]] [--any-cwd]", description: "Resume a saved session or import a read-only GJC v5 session", group: "session" },
31
+ { command: "/changelog", usage: "/changelog [--full]", description: "Show recent release notes (use --full for the complete changelog)", group: "system" },
31
32
  { command: "/retry", usage: "/retry", description: "Retry the last request", group: "session" },
32
33
  { command: "/undo", usage: "/undo", description: "Undo the last jeo auto-commit and restore the working tree", group: "session" },
33
34
  { command: "/history", usage: "/history [n|all]", description: "Re-print the worked turn history (prompts, tool steps, replies) into scrollback", group: "session" },
@@ -53,6 +54,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
53
54
  { command: "/search", usage: "/search <pat> [glob]", description: "Search the repo for a pattern", group: "code" },
54
55
 
55
56
  { command: "/sessions", usage: "/sessions", description: "List saved sessions", group: "session" },
57
+ { command: "/jobs", usage: "/jobs [list|tail|await|cancel]", description: "List, inspect, await, or cancel background jobs in this session", group: "system" },
56
58
  { command: "/usage", usage: "/usage", description: "Show cumulative token usage for this session", group: "system" },
57
59
  { command: "/context", usage: "/context", description: "Show context token usage breakdown", group: "system" },
58
60
  { command: "/tools", usage: "/tools", description: "Show the tools currently visible to the agent", group: "system" },
@@ -9,8 +9,8 @@ import { parseChangelogSections, changelogText } from "../../util/whats-new";
9
9
  function getLatestChangelogItems(): { version: string; items: string[] } {
10
10
  try {
11
11
  const sections = parseChangelogSections(changelogText);
12
- if (sections.length > 0) {
13
- const latest = sections[0];
12
+ const latest = sections.find(section => section.groups.some(group => group.items.length > 0));
13
+ if (latest) {
14
14
  const items: string[] = [];
15
15
  for (const g of latest.groups) {
16
16
  for (const item of g.items) {