jeo-code 0.9.12 → 0.9.14

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 CHANGED
@@ -8,6 +8,36 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.9.14] - 2026-08-04
12
+ _A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt._
13
+
14
+ ### Fixed
15
+ - **`$a $b …` skill-chain workflow invocations hung silently** (`src/commands/launch.ts`) — the chain path (`runOneSkillShot`) wired `io.output` to a bundled workflow engine but omitted `io.input`. `runDeepInterviewEngine` (and the other workflow engines) only spin up their own `readline.Interface` on `process.stdin` when `opts.io?.input` is absent; the interactive REPL already owns a `readline.Interface` on the same stdin via `promptInput`, so the two interfaces silently fought over raw mode — no prompt was ever drawn and no input was ever delivered, so the workflow just hung forever. The sibling single-invocation path (`runSkillInvocation`) already wired `promptInput` through `io.input`; the chain path now does the same (including the preview-arm/disarm dance around the prompt).
16
+
17
+ ### Verified
18
+ - `bun run typecheck` clean.
19
+ - `test/deep-interview-noninteractive.test.ts`, `test/launch-flags.test.ts` pass.
20
+ - `test/launch-repl-eof.test.ts`'s 2 failures are pre-existing (reproduce identically on the unmodified branch).
21
+
22
+
23
+ ## [0.9.13] - 2026-08-03
24
+ _The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else._
25
+
26
+ ### Fixed
27
+ - **The OAuth login tab stayed open after "Login complete"** (`src/auth/callback-server.ts`, new `src/auth/browser-tab.ts`) — two separate defects:
28
+ - The page decided whether the close worked from `window.close()`'s return value, which is `undefined` on EVERY browser. The failure branch therefore always ran and hid the "Close" button on the first countdown tick, so the tab was left open with its only manual control gone. Success is now inferred from the document actually going away (`pagehide` / `document.hidden`); the button stays visible and re-attempts on every press, and the countdown hint is replaced by an explicit "press ⌘W / Ctrl+W" instruction only when the browser refuses.
29
+ - `window.close()` cannot close a tab the page's own script did not open, and jeo hands the auth URL to the OS (`open` / `xdg-open` / `start`), so the request was always ignored. jeo now closes the tab from the OUTSIDE on macOS: after the callback page is served, it lists running processes (no Automation permission needed), builds an AppleScript naming ONLY the browsers that are actually running, and closes just the tabs whose URL contains this login's `localhost:<port>/callback`. `window.open("","_self")` is also tried first for the engines that still honor the legacy self-adopt, and the countdown is 3s instead of 5s.
30
+ - **A single missing browser silently disabled the whole close script** — AppleScript resolves app terminology at COMPILE time, which `try` cannot guard, so one static `tell application "Arc"` block on a machine without Arc made the entire script fail to compile and close nothing (`osacompile`: "Expected class name but found property"). The script is now generated only for browsers found in the live process list.
31
+
32
+ ### Added
33
+ - `JEO_AUTH_TAB_CLOSE=0` opts out of the OS-level tab close entirely; the attempt is hard-capped at 2s, never surfaces an error, and is skipped when the login finished through a manual code paste (no tab of ours was ever rendered).
34
+
35
+ ### Verified
36
+ - Real Chromium (Playwright) against the actual served page: the scripted close is refused exactly as it is for a real OAuth tab, and the page survives intact — URL unchanged (`window.open("","_self")` does not blank it), "Login complete ✓" still rendered, countdown hint swapped for the manual instruction, Close button still visible; `window.close()` is invoked once by the countdown and again on every button press (the old build stopped after the first).
37
+ - `osacompile` on the generated AppleScript: fails on the full browser list (reproducing the compile bug) and compiles cleanly for the running-process-derived list.
38
+ - `closeAuthTab` against a live loopback callback URL returns in ~2s without hanging when macOS withholds Automation permission, so a login can never stall behind it.
39
+ - Suites: oauth, browser-tab, openai-oauth, antigravity-login, kimi-oauth, oauth-lock, auth-matrix, doctor — 53 pass / 0 fail. `bun run typecheck` clean, `bun run pack:check` 283 files.
40
+
11
41
  ## [0.9.12] - 2026-08-02
12
42
  _Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`._
13
43
 
package/README.ja.md CHANGED
@@ -287,10 +287,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
287
287
 
288
288
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
289
289
  - **[Unreleased]**
290
+ - **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
291
+ - **[0.9.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
290
292
  - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
291
293
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
292
- - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
293
- - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
294
294
 
295
295
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
296
296
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -288,10 +288,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
288
288
 
289
289
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
290
290
  - **[Unreleased]**
291
+ - **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
292
+ - **[0.9.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
291
293
  - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
292
294
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
293
- - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
294
- - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
295
295
 
296
296
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
297
297
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -295,10 +295,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
295
295
 
296
296
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
297
297
  - **[Unreleased]**
298
+ - **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
299
+ - **[0.9.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
298
300
  - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
299
301
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
300
- - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
301
- - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
302
302
 
303
303
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
304
304
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -287,10 +287,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
287
287
 
288
288
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
289
289
  - **[Unreleased]**
290
+ - **[0.9.14]** (2026-08-04) — A `$a $b … [intent]` skill-chain invocation of a bundled workflow skill (deep-interview/ralplan/team/ultragoal) hung forever with no error and no prompt.
291
+ - **[0.9.13]** (2026-08-03) — The provider-login browser tab now actually goes away — by itself on macOS, by a working button everywhere else.
290
292
  - **[0.9.12]** (2026-08-02) — Fixes the boxed prompt's stolen keystrokes, drifting caret, and paste/attachment spacing under `jeo --tmux`.
291
293
  - **[0.9.11]** (2026-07-30) — Imports GJC v5 sessions safely, exposes bounded slash controls, and gates the release artifact before publication.
292
- - **[0.9.10]** (2026-07-27) — `jeo update` now verifies the binary you actually run, and recovers from bun's stale registry cache.
293
- - **[0.9.9]** (2026-07-27) — Root-causes the frozen `jeo --tmux` TUI: the window was pinned to its launch size, so resizing the terminal only cut the view.
294
294
 
295
295
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
296
296
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.9.12",
3
+ "version": "0.9.14",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Best-effort closing of the OAuth callback TAB the login opened.
3
+ *
4
+ * Why this exists: the callback page already asks the browser to close itself
5
+ * (`window.close()` after a short countdown), but every current browser REFUSES
6
+ * that for a tab the page's own script did not open. jeo hands the auth URL to
7
+ * the OS (`open` / `xdg-open` / `start`), so the tab is user-opened by that rule
8
+ * and the request is silently ignored — the login succeeds in the terminal while
9
+ * a dead "Login complete" tab lingers in the browser (the reported bug).
10
+ *
11
+ * The only reliable way to close such a tab is to drive the browser from the
12
+ * outside. macOS exposes exactly that through AppleScript, so this module builds
13
+ * a targeted script that closes ONLY tabs whose URL contains the loopback
14
+ * callback (e.g. `localhost:1455/callback`) — never any other tab — in whichever
15
+ * of the common browsers is ALREADY running (`is running` never launches one).
16
+ *
17
+ * Everything here is best effort and failure-silent: no automation permission, a
18
+ * browser without an AppleScript dictionary (Firefox), Linux/Windows, or a user
19
+ * who already closed the tab all end as a no-op. `JEO_AUTH_TAB_CLOSE=0` opts out.
20
+ */
21
+ import { jeoEnv } from "../util/env";
22
+
23
+ /** Chromium-family browsers whose AppleScript dictionary exposes `windows`/`tabs`
24
+ * with a `URL` property and a `close` command. Safari uses the same shape and is
25
+ * appended by {@link closeAuthTabScript}. Firefox has no tab-level dictionary at
26
+ * all, so it cannot be driven this way — its tab keeps the page's own fallback. */
27
+ export const APPLESCRIPT_BROWSERS: readonly string[] = [
28
+ "Google Chrome",
29
+ "Google Chrome Beta",
30
+ "Google Chrome Canary",
31
+ "Chromium",
32
+ "Brave Browser",
33
+ "Microsoft Edge",
34
+ "Vivaldi",
35
+ "Arc",
36
+ "Safari",
37
+ ];
38
+
39
+ /** Escape a string for embedding inside an AppleScript double-quoted literal. */
40
+ function escapeAppleScript(value: string): string {
41
+ return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
42
+ }
43
+
44
+ /**
45
+ * The AppleScript that closes every tab whose URL contains `fragment`.
46
+ *
47
+ * One STATIC `tell` block per browser (never a dynamic `tell application appName`):
48
+ * app-specific terms like `URL of t` only compile inside a literal tell block, so a
49
+ * variable-driven loop would fail to compile and close nothing.
50
+ *
51
+ * Pass ONLY browsers that are actually running (see {@link runningBrowserApps}). A
52
+ * block naming an app that is not INSTALLED cannot resolve its terminology and
53
+ * fails at COMPILE time — which `try` cannot catch, since `try` guards runtime
54
+ * errors only — so one missing browser would sink the whole script and close
55
+ * nothing (verified with `osacompile`: "Expected class name but found property").
56
+ * Each block keeps its own `try` + `is running` guard for the runtime races: a
57
+ * browser quitting mid-script, or a window that refuses the query.
58
+ */
59
+ export function closeAuthTabScript(fragment: string, browsers: readonly string[] = APPLESCRIPT_BROWSERS): string {
60
+ const frag = escapeAppleScript(fragment);
61
+ const blocks = browsers.map(
62
+ app => `try
63
+ if application "${escapeAppleScript(app)}" is running then
64
+ tell application "${escapeAppleScript(app)}"
65
+ repeat with w in (every window)
66
+ repeat with t in (every tab of w)
67
+ try
68
+ if (URL of t) contains "${frag}" then close t
69
+ end try
70
+ end repeat
71
+ end repeat
72
+ end tell
73
+ end if
74
+ end try`,
75
+ );
76
+ return blocks.join("\n");
77
+ }
78
+
79
+ /** The subset of {@link APPLESCRIPT_BROWSERS} currently running, derived from
80
+ * `ps -Ax -o comm=` output (each line is an executable path such as
81
+ * `/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`). Reading the
82
+ * process list needs no Automation permission, so this narrows the generated
83
+ * script — and with it the compile surface and the number of permission prompts —
84
+ * to the browsers that could actually be holding the callback tab. */
85
+ export function runningBrowserApps(psOutput: string, browsers: readonly string[] = APPLESCRIPT_BROWSERS): string[] {
86
+ const lines = psOutput.split("\n");
87
+ return browsers.filter(app => lines.some(line => line.includes(`/${app}.app/`)));
88
+ }
89
+
90
+ /** The command to run for `platform`, or null when the platform has no reliable
91
+ * way to close a tab it did not open (Linux/Windows: no scriptable, browser-
92
+ * agnostic tab API — the callback page's own message is the fallback there). */
93
+ export function browserTabCloseCommand(
94
+ platform: NodeJS.Platform,
95
+ fragment: string,
96
+ browsers: readonly string[] = APPLESCRIPT_BROWSERS,
97
+ ): string[] | null {
98
+ if (platform !== "darwin") return null;
99
+ if (!fragment) return null;
100
+ return ["osascript", "-e", closeAuthTabScript(fragment, browsers)];
101
+ }
102
+
103
+ /** The `host:port/path` slice of a redirect URI — the narrowest fragment that still
104
+ * identifies THIS login's callback tab (the port is unique per flow/run), so no
105
+ * unrelated tab can ever match. Returns "" when the URI cannot be parsed. */
106
+ export function callbackUrlFragment(redirectUri: string): string {
107
+ try {
108
+ const url = new URL(redirectUri);
109
+ return `${url.host}${url.pathname}`;
110
+ } catch {
111
+ return "";
112
+ }
113
+ }
114
+
115
+ export interface CloseAuthTabOptions {
116
+ platform?: NodeJS.Platform;
117
+ env?: Record<string, string | undefined>;
118
+ /** Hard cap so a hung/permission-prompting osascript can never stall the login.
119
+ * The FIRST close on a machine raises macOS's "control Google Chrome" Automation
120
+ * prompt; if the user does not answer in time this kills the helper and the page's
121
+ * own fallback message takes over. Answering it once makes later logins instant. */
122
+ timeoutMs?: number;
123
+ /** Injected for tests; defaults to the real spawn. */
124
+ spawn?: (cmd: string[]) => { exited: Promise<number>; kill: () => void };
125
+ /** Running-process listing (`ps -Ax -o comm=`); injected for tests. Returning ""
126
+ * means "cannot tell", which is treated as "no known browser running". */
127
+ listProcesses?: () => string;
128
+ }
129
+
130
+ /**
131
+ * Close the browser tab still showing this login's callback page. Resolves `true`
132
+ * only when the OS accepted the request; every other outcome (unsupported
133
+ * platform, opt-out, spawn failure, timeout, non-zero exit) resolves `false` and
134
+ * is silent — the terminal already reported the login result, so this must never
135
+ * surface an error of its own.
136
+ */
137
+ export async function closeAuthTab(redirectUri: string, opts: CloseAuthTabOptions = {}): Promise<boolean> {
138
+ const env = opts.env ?? process.env;
139
+ if (jeoEnv("AUTH_TAB_CLOSE", env) === "0") return false;
140
+ const platform = opts.platform ?? process.platform;
141
+ if (platform !== "darwin") return false;
142
+ const fragment = callbackUrlFragment(redirectUri);
143
+ if (!fragment) return false;
144
+ const listProcesses =
145
+ opts.listProcesses ??
146
+ (() => {
147
+ try {
148
+ return Bun.spawnSync(["ps", "-Ax", "-o", "comm="], { stdout: "pipe", stderr: "ignore" }).stdout.toString();
149
+ } catch {
150
+ return "";
151
+ }
152
+ });
153
+ // Never script a browser that is not running: an uninstalled app breaks the whole
154
+ // script at compile time, and a running-only list keeps the Automation prompt to
155
+ // the browser the user actually has open.
156
+ const browsers = runningBrowserApps(listProcesses());
157
+ if (browsers.length === 0) return false;
158
+ const cmd = browserTabCloseCommand(platform, fragment, browsers);
159
+ if (!cmd) return false;
160
+ const timeoutMs = opts.timeoutMs ?? 2000;
161
+ const spawn =
162
+ opts.spawn ??
163
+ ((c: string[]) => {
164
+ const proc = Bun.spawn(c, { stdout: "ignore", stderr: "ignore", stdin: "ignore" });
165
+ return { exited: proc.exited, kill: () => proc.kill() };
166
+ });
167
+ try {
168
+ const proc = spawn(cmd);
169
+ let timer: ReturnType<typeof setTimeout> | undefined;
170
+ const timeout = new Promise<number>(resolve => {
171
+ timer = setTimeout(() => {
172
+ try { proc.kill(); } catch { /* already gone */ }
173
+ resolve(-1);
174
+ }, timeoutMs);
175
+ });
176
+ const code = await Promise.race([proc.exited, timeout]);
177
+ if (timer) clearTimeout(timer);
178
+ return code === 0;
179
+ } catch {
180
+ return false;
181
+ }
182
+ }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { OAuthController, OAuthCredentials } from "./types";
11
11
  import { generateState } from "./pkce";
12
+ import { closeAuthTab } from "./browser-tab";
12
13
 
13
14
  const DEFAULT_TIMEOUT_MS = 300_000;
14
15
  // Loopback callback host. `localhost` (not the 127.0.0.1 IP literal) is the intentional
@@ -64,34 +65,58 @@ button:hover{background:#1b1c27}
64
65
  <img class="wordmark" alt="jeo" src="${JEO_WORDMARK_DATA_URI}">
65
66
  <h1 class="__STATUS__">__TITLE__</h1>
66
67
  <p>__MSG__</p>
67
- <button id="jeo-close" type="button">Close</button>
68
- <p class="hint">Closing automatically in <span id="jeo-countdown">__SECONDS__</span>s…</p>
69
- <p class="hint closed" id="jeo-closed-msg">Window closed. You can close this tab.</p>
68
+ <button id="jeo-close" type="button">Close this tab</button>
69
+ <p class="hint" id="jeo-countdown-msg">Closing automatically in <span id="jeo-countdown">__SECONDS__</span>s…</p>
70
+ <p class="hint closed" id="jeo-closed-msg">This browser refuses to close a tab it opened itself — press \u2318W (Ctrl+W) to close it.</p>
70
71
  </div>
71
72
  <script>__SCRIPT__</script>
72
73
  </body></html>`;
73
74
 
74
- // Seconds before the tab auto-closes — applies to BOTH the success and the
75
- // failure page now (previously only success auto-closed). Pressing "Close"
76
- // closes immediately without waiting out the countdown. `window.close()` only
77
- // succeeds on tabs the script itself opened; browsers may ignore it for
78
- // OS-launched OAuth tabs, so the button remains as the reliable fallback.
79
- const AUTO_CLOSE_SECONDS = 5;
75
+ // Seconds before the tab tries to close itself — applies to BOTH the success and
76
+ // the failure page (each is a final page that should not linger). Pressing
77
+ // "Close this tab" runs the same attempt immediately, without waiting out the
78
+ // countdown, and stays clickable afterwards.
79
+ const AUTO_CLOSE_SECONDS = 3;
80
80
 
81
81
  function renderHtml(ok: boolean, msg: string): string {
82
+ // Closing contract, in order of what actually works:
83
+ // 1. `window.open("","_self")` before `window.close()` — the legacy self-adopt
84
+ // trick that still lets Firefox (and older engines) close an OS-opened tab.
85
+ // 2. `window.close()` — succeeds only where the tab counts as script-opened.
86
+ // 3. If the tab is still here a moment later, the browser refused: tell the
87
+ // user how to close it and KEEP the button working.
88
+ //
89
+ // The old code branched on `window.close()`'s return value — that call returns
90
+ // `undefined` ALWAYS, so the failure branch fired unconditionally and hid the
91
+ // Close button on the first tick. That left the tab open with its only manual
92
+ // control gone (the "인증 브라우저가 종료되지 않는다" report). Success is now
93
+ // detected by observing that the document is actually going away
94
+ // (`document.hidden` / the `pagehide` unload path), never by a return value.
95
+ //
96
+ // The CLI side additionally closes the tab through the OS on macOS once the
97
+ // callback lands (`closeAuthTab` in ./browser-tab.ts), which is the only path
98
+ // that works when the browser refuses the scripted close outright.
82
99
  const script = `(function(){
83
- var n=${AUTO_CLOSE_SECONDS},el=document.getElementById("jeo-countdown"),btn=document.getElementById("jeo-close"),closedMsg=document.getElementById("jeo-closed-msg"),t;
84
- function closeNow(){
85
- clearInterval(t);
86
- var closed=window.close();
87
- if(!closed){
88
- btn.style.display="none";
89
- el.parentElement.style.display="none";
90
- closedMsg.classList.remove("closed");
91
- }
100
+ var n=${AUTO_CLOSE_SECONDS},
101
+ el=document.getElementById("jeo-countdown"),
102
+ countdownMsg=document.getElementById("jeo-countdown-msg"),
103
+ btn=document.getElementById("jeo-close"),
104
+ closedMsg=document.getElementById("jeo-closed-msg"),
105
+ t,gone=false;
106
+ window.addEventListener("pagehide",function(){gone=true;});
107
+ function showManual(){
108
+ if(gone||document.hidden)return;
109
+ if(countdownMsg)countdownMsg.style.display="none";
110
+ if(closedMsg)closedMsg.classList.remove("closed");
111
+ }
112
+ function attemptClose(){
113
+ if(t){clearInterval(t);t=null;}
114
+ try{window.open("","_self");}catch(e){}
115
+ try{window.close();}catch(e){}
116
+ setTimeout(showManual,250);
92
117
  }
93
- t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0)closeNow();},1000);
94
- if(btn)btn.addEventListener("click",closeNow);
118
+ t=setInterval(function(){n-=1;if(el)el.textContent=String(Math.max(n,0));if(n<=0)attemptClose();},1000);
119
+ if(btn)btn.addEventListener("click",attemptClose);
95
120
  })();`;
96
121
  return PAGE_HTML
97
122
  .replace("__STATUS__", ok ? "ok" : "fail")
@@ -110,6 +135,9 @@ export abstract class OAuthCallbackFlow {
110
135
  protected fixedRedirectUri?: string;
111
136
  #resolve?: (r: CallbackResult) => void;
112
137
  #reject?: (e: Error) => void;
138
+ /** Set for the duration of one `login()`; fired the moment the callback PAGE is
139
+ * served, which is the only proof a browser tab of ours exists to close. */
140
+ #onPageServed?: () => void;
113
141
 
114
142
  constructor(ctrl: OAuthController, opts: number | OAuthCallbackFlowOptions, callbackPath = DEFAULT_CALLBACK_PATH) {
115
143
  this.ctrl = ctrl;
@@ -131,6 +159,11 @@ export abstract class OAuthCallbackFlow {
131
159
  async login(): Promise<OAuthCredentials> {
132
160
  const state = generateState();
133
161
  const { server, redirectUri } = this.#startServer(state);
162
+ // Whether the browser actually reached the callback page. A manual paste (or a
163
+ // cancelled/timed-out flow) never rendered a tab of ours, so there is nothing to
164
+ // close and no reason to pay for an osascript spawn.
165
+ let servedCallbackPage = false;
166
+ this.#onPageServed = () => { servedCallbackPage = true; };
134
167
  try {
135
168
  const { url, instructions } = await this.generateAuthUrl(state, redirectUri);
136
169
  this.ctrl.onAuth?.({ url, instructions });
@@ -140,9 +173,23 @@ export abstract class OAuthCallbackFlow {
140
173
  return await this.exchangeToken(code, returnedState || state, redirectUri);
141
174
  } finally {
142
175
  server.stop();
176
+ this.#onPageServed = undefined;
177
+ // Close the leftover callback tab from the OUTSIDE (macOS only; see
178
+ // ./browser-tab.ts). Awaited — a one-shot `jeo auth login` exits as soon as
179
+ // this returns, so a fire-and-forget spawn would be killed before it ran —
180
+ // but hard-capped, silent, and never able to fail the login it follows.
181
+ if (servedCallbackPage) {
182
+ try { await this.closeBrowserTab(redirectUri); } catch { /* best effort only */ }
183
+ }
143
184
  }
144
185
  }
145
186
 
187
+ /** OS-level close of the callback tab (see ./browser-tab.ts). Overridable so tests
188
+ * can observe the attempt without driving a real browser. */
189
+ protected closeBrowserTab(redirectUri: string): Promise<boolean> {
190
+ return closeAuthTab(redirectUri);
191
+ }
192
+
146
193
  #startServer(expectedState: string): { server: Bun.Server<unknown>; redirectUri: string } {
147
194
  const serve = (port: number) =>
148
195
  Bun.serve({
@@ -186,6 +233,7 @@ export abstract class OAuthCallbackFlow {
186
233
  message = "Authentication succeeded. Return to the terminal — jeo is ready.";
187
234
  }
188
235
 
236
+ this.#onPageServed?.(); // a real browser tab now holds this page — it can be closed
189
237
  const resolve = this.#resolve;
190
238
  const reject = this.#reject;
191
239
  queueMicrotask(() => {
@@ -2093,8 +2093,18 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2093
2093
  io: {
2094
2094
  output: (line: string) => {
2095
2095
  console.log(line);
2096
- }
2096
+ },
2097
+ // Reuse the SAME readline interface the interactive REPL already owns
2098
+ // (via promptInput) instead of letting the workflow engine spin up a
2099
+ // second `readline.Interface` on process.stdin — deep-interview.ts only
2100
+ // creates its own internal rl when `opts.io?.input` is absent, and two
2101
+ // concurrent readline interfaces on one stdin silently fight over raw
2102
+ // mode: no error, no visible prompt, the workflow just hangs forever.
2103
+ // The sibling `runSkillInvocation` path already wires `promptInput`
2104
+ // here; this `$a $b …` chain path must match it.
2105
+ input: () => promptInput("")
2097
2106
  },
2107
+
2098
2108
  args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
2099
2109
  };
2100
2110