jeo-code 0.11.0 → 0.11.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 CHANGED
@@ -9,6 +9,21 @@ The README mirrors the latest 5 entries — regenerate with `bun run changelog:s
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [0.11.1] - 2026-08-25
13
+ _Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited._
14
+
15
+ ### Fixed
16
+ - **A one-shot run never exited when `notifications.enabled` was set** (`src/agent/notify/session-endpoint.ts`, `src/commands/launch.ts`) — `startSessionNotifyEndpoint` binds a `Bun.serve` socket plus a snapshot-poll `setInterval` for the whole launch lifetime, but its teardown lived only on the interactive REPL's exit path. A one-shot run leaves through one of ~17 early `return`s, none of which reach it, so the listening socket and the ref'd timer kept the event loop alive indefinitely. Fixed on both sides: the server and poll timer are now `unref`'d so the endpoint can never be the reason the process stays up, and the entire one-shot block is wrapped in a `try/finally` that stops the endpoint — which also removes the session discovery file, so the Telegram daemon stops dialling a dead socket after a scripted run.
17
+ - This was invisible for two compounding reasons: it only reproduces on a machine with notifications actually configured, and Bun timers/servers do not appear in `process._getActiveHandles()` — so the hung process reported **zero** active handles while refusing to exit. It was found by monkey-patching `setInterval`/`setTimeout` to record creation sites and dumping the still-ref'd ones after `dispatch()` returned.
18
+ - **The `bashTool` orphan-reaping assertions compared raw command output to the string `"0"`** (`test/tools-fs.test.ts`) — `bashTool` merges stdout and stderr, so any warning `pgrep` wrote to stderr false-failed a run in which zero strays actually existed. The count is now parsed rather than string-matched, and the orphan check is gated on process enumeration actually working (hardened sandboxes answer `pgrep: Cannot get process list`). The abort **contract** itself (`success:false` / `"Command aborted"`) is still asserted unconditionally, so a restricted host keeps covering the behaviour that matters.
19
+
20
+ ### Added
21
+ - `test/notify-endpoint-exit.test.ts` — pins the exit contract deterministically by pointing `JEO_CONFIG_DIR` at a temp config with notifications ON, rather than depending on the developer's real `~/.jeo/config.json`. Covers both halves independently: the `finally` teardown (one-shot exits, no leftover discovery file) and the `unref` guard (any future path that forgets `stop()` still cannot pin the process open).
22
+
23
+ ### Verified
24
+ - `bun run typecheck` clean; `bun test` **3348 pass, 0 fail** — down from 22 failures two releases ago and 6 at 0.11.0. Suite wall time also dropped ~148s → ~88s, since the hung specs no longer burn their full 25s/5s timeouts.
25
+ - The unref guard was verified by reverting it and confirming the new unit test fails, then restoring it.
26
+
12
27
  ## [0.11.0] - 2026-08-25
13
28
  _One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside._
14
29
 
package/README.ja.md CHANGED
@@ -319,10 +319,10 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
319
319
 
320
320
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
321
321
  - **[Unreleased]**
322
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
322
323
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
323
324
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
324
325
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
325
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
326
326
 
327
327
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
328
328
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -320,10 +320,10 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
320
320
 
321
321
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
322
322
  - **[Unreleased]**
323
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
323
324
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
324
325
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
325
326
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
326
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
327
327
 
328
328
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
329
329
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -327,10 +327,10 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
327
327
 
328
328
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
329
329
  - **[Unreleased]**
330
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
330
331
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
331
332
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
332
333
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
333
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
334
334
 
335
335
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
336
336
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -317,10 +317,10 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
317
317
 
318
318
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
319
319
  - **[Unreleased]**
320
+ - **[0.11.1]** (2026-08-25) — Every non-interactive `jeo` run hung forever once Telegram notifications were configured — `echo "..." | jeo`, `jeo -p "..."` in CI, any scripted use. The work completed and the command returned; the process just never exited.
320
321
  - **[0.11.0]** (2026-08-25) — One bad boundary check was corrupting agent context, subagent fan-out, and the Telegram daemon's kill safety at the same time — and none of the three looked related from the outside.
321
322
  - **[0.10.0]** (2026-08-25) — jeo could only be pointed at ONE user-supplied endpoint (`config.openaiBaseUrl`), and doing so rebound the built-in `openai` provider: it stole the `openai/` routing prefix, collided with real OpenAI model ids, and could not speak the Anthropic Messages protocol at all. There was no way to run a company LiteLLM proxy and a self-hosted vLLM box at the same time, or to reach either from a script.
322
323
  - **[0.9.17]** (2026-08-18) — Live model discovery already existed (`listProviderModels`/`discoverModels`), but only `jeo auth login openai` ever called it — logging into Anthropic or Antigravity left the account pinned to the maintained static catalog snapshot until the next unrelated live-discovery call happened to run.
323
- - **[0.9.16]** (2026-08-18) — Routing looked broken while it was actually working: the idle status bar and `/compact`/`/handoff` never reflected which model `routePrompt` actually routed to.
324
324
 
325
325
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
326
326
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -229,7 +229,22 @@ export class SessionNotifyEndpoint {
229
229
  },
230
230
  },
231
231
  });
232
+ // Same reasoning as the poll timer below: a listening socket also keeps the Bun
233
+ // event loop alive. This server exists to MIRROR a session, never to outlive it,
234
+ // so it must not be able to decide the process stays up. `stop()` still tears it
235
+ // down explicitly on the paths that have one — this only removes its veto over exit.
236
+ (this.server as { unref?: () => void }).unref?.();
232
237
  this.pollTimer = setInterval(() => this.broadcastIfChanged(), SNAPSHOT_POLL_MS);
238
+ // Unref the poll timer so it can NEVER be the reason the process stays alive.
239
+ // This endpoint mirrors an already-running session; it has no work of its own
240
+ // worth holding the event loop open for. Left ref'd, any code path that starts
241
+ // the endpoint but does not reach an explicit `stop()` hangs the process
242
+ // forever — which is exactly what happened to every non-interactive invocation
243
+ // (`echo "…" | jeo`, `jeo -p "…"` in CI): the one-shot branch returns long
244
+ // before the interactive REPL's exit teardown runs. Bun timers do not appear in
245
+ // `process._getActiveHandles()`, so that hang presented as a process with zero
246
+ // active handles refusing to exit.
247
+ (this.pollTimer as { unref?: () => void }).unref?.();
233
248
  await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
234
249
  const payload = {
235
250
  url: `ws://127.0.0.1:${this.server.port}`,
@@ -1974,239 +1974,250 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1974
1974
  };
1975
1975
 
1976
1976
  if (isOneShot) {
1977
- let messageContent = joinedArgs;
1978
- if (!process.stdin.isTTY && joinedArgs.length === 0) {
1979
- messageContent = (await Bun.stdin.text()).trim();
1980
- }
1981
- if (!messageContent) {
1982
- console.log("No input provided.");
1983
- return;
1984
- }
1985
- // One-shot piped/`-p` input that is a control slash command must be handled
1986
- // HERE — never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
1987
- // a piped command) previously sent the literal "/clear" to the LLM as a prompt:
1988
- // a slow, flaky, and semantically wrong round-trip. These no-arg session/system
1989
- // commands have a meaningful one-shot effect (or are simple no-ops) and exit.
1990
- {
1991
- const cmd = messageContent.trim();
1992
- if (cmd === "/exit" || cmd === "/quit") {
1993
- return;
1994
- }
1995
- if (cmd === "/clear" || cmd === "/session new" || cmd === "/session drop" || cmd === "/session delete") {
1996
- // Reset history to just the system prompt and overwrite the session file so
1997
- // the persisted transcript matches (a fresh session for /session new and /session drop).
1998
- history.length = 1;
1999
- if (sessionId && !flags.noSession) {
2000
- try {
2001
- sessionId = (await createSession(cwd, undefined, sessionModel)).id;
2002
- } catch { /* best-effort: in-memory clear already done */ }
2003
- }
2004
- console.log("(history cleared)");
2005
- return;
2006
- }
2007
- if (cmd === "/" || cmd === "/?" || cmd === "/help") {
2008
- for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
2009
- console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
2010
- return;
2011
- }
2012
- if (cmd === "/usage") {
2013
- const cfg = await readGlobalConfig();
2014
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2015
- const result = handleUsage(ctx, sessionUsage);
2016
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2017
- return;
2018
- }
2019
- if (cmd === "/context") {
2020
- const cfg = await readGlobalConfig();
2021
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2022
- const result = await handleContext(ctx);
2023
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2024
- return;
2025
- }
2026
- if (cmd === "/tools") {
2027
- const cfg = await readGlobalConfig();
2028
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2029
- const result = await handleTools(ctx);
2030
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2031
- return;
2032
- }
2033
- if (cmd === "/hotkeys") {
2034
- const cfg = await readGlobalConfig();
2035
- const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2036
- const result = handleHotkeys(ctx);
2037
- if (result && "lines" in result) for (const line of result.lines) console.log(line);
2038
- return;
1977
+ // A one-shot run has ~17 exit points below. The session notify endpoint (a
1978
+ // listening socket + a discovery file the Telegram daemon polls) is created for
1979
+ // the WHOLE launch lifetime up at `startSessionNotifyEndpoint`, but its teardown
1980
+ // lives on the interactive REPL's exit path — which none of those 17 returns
1981
+ // reach. The endpoint's socket/timer are unref'd so a leak can no longer HANG the
1982
+ // process, but a one-shot run would still leave a stale discovery file behind for
1983
+ // the daemon to keep dialling. One `finally` covers every exit point.
1984
+ try {
1985
+ let messageContent = joinedArgs;
1986
+ if (!process.stdin.isTTY && joinedArgs.length === 0) {
1987
+ messageContent = (await Bun.stdin.text()).trim();
2039
1988
  }
2040
- if (cmd === "/handoff" || cmd.startsWith("/handoff ")) {
2041
- const parsed = parseHandoffCommand(cmd) ?? {};
2042
- const cfg = await readGlobalConfig();
2043
- const activeModel = sessionModel || defaultModel;
2044
- // `Config` (state.ts) predates config-schema.ts's `compaction` field and is
2045
- // out of this change's scope — read the passthrough-preserved value via a
2046
- // narrow structural cast instead of widening the shared interface.
2047
- const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
2048
- const res = await buildHandoffDocument(history, { model: activeModel, focus });
2049
- for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
1989
+ if (!messageContent) {
1990
+ console.log("No input provided.");
2050
1991
  return;
2051
1992
  }
2052
- if (cmd === "/theme" || cmd.startsWith("/theme ")) {
2053
- const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
2054
- for (const line of decision.lines) console.log(line);
2055
- if (decision.kind === "set") {
2056
- process.env.JEO_TUI_THEME = decision.themeName!;
2057
- await saveConfigPatch(raw => ({ theme: decision.themeName }));
2058
- // No refreshUiTheme() here — one-shot mode returns immediately after this
2059
- // block, so there is no live TUI paint state to re-resolve (and
2060
- // refreshUiTheme is declared later in this closure, out of scope here).
1993
+ // One-shot piped/`-p` input that is a control slash command must be handled
1994
+ // HERE — never forwarded to the model. `echo "/clear" | jeo` (and Ctrl-D after
1995
+ // a piped command) previously sent the literal "/clear" to the LLM as a prompt:
1996
+ // a slow, flaky, and semantically wrong round-trip. These no-arg session/system
1997
+ // commands have a meaningful one-shot effect (or are simple no-ops) and exit.
1998
+ {
1999
+ const cmd = messageContent.trim();
2000
+ if (cmd === "/exit" || cmd === "/quit") {
2001
+ return;
2061
2002
  }
2062
- return;
2063
- }
2064
- if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
2065
- const decision = decideWikiSlash(
2066
- cmd,
2067
- resolveWikiRoot(await readGlobalConfig()),
2068
- !!jeoEnv("WIKI_ROOT"),
2069
- );
2070
- for (const line of decision.lines) console.log(line);
2071
- if (decision.kind === "clear") {
2072
- await saveConfigPatch(() => ({ wikiRoot: undefined }));
2073
- delete process.env.JEO_WIKI_ROOT;
2074
- sessionWikiRoot = undefined;
2075
- await refreshSessionMemory("");
2076
- } else if (decision.kind === "set") {
2077
- await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
2078
- process.env.JEO_WIKI_ROOT = decision.root;
2079
- sessionWikiRoot = decision.root;
2080
- await refreshSessionMemory("");
2003
+ if (cmd === "/clear" || cmd === "/session new" || cmd === "/session drop" || cmd === "/session delete") {
2004
+ // Reset history to just the system prompt and overwrite the session file so
2005
+ // the persisted transcript matches (a fresh session for /session new and /session drop).
2006
+ history.length = 1;
2007
+ if (sessionId && !flags.noSession) {
2008
+ try {
2009
+ sessionId = (await createSession(cwd, undefined, sessionModel)).id;
2010
+ } catch { /* best-effort: in-memory clear already done */ }
2011
+ }
2012
+ console.log("(history cleared)");
2013
+ return;
2081
2014
  }
2082
- return;
2083
- }
2084
- if (cmd === "/config" || cmd === "/settings") {
2085
- for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
2086
- return;
2087
- }
2088
- if (cmd === "/evolve") {
2089
- await runEvolveSimulation(animateAsciiArt);
2090
- return;
2091
- }
2092
- }
2093
- // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
2094
- // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
2095
- const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
2096
- const isBundleWorkflow = isWorkflowSkill(inv.skill.name);
2097
- if (isBundleWorkflow) {
2098
- const startMsg: Message = {
2099
- role: "system",
2100
- content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
2101
- };
2102
- history.push(startMsg);
2103
- if (sessionId) {
2104
- await appendMessage(sessionId, startMsg, cwd);
2015
+ if (cmd === "/" || cmd === "/?" || cmd === "/help") {
2016
+ for (const line of formatSlashCommandList("/", skillSlashDetails)) console.log(line);
2017
+ console.log("Tools: read / write / edit / bash / find / search. Sessions persist to .jeo/sessions/.");
2018
+ return;
2105
2019
  }
2020
+ if (cmd === "/usage") {
2021
+ const cfg = await readGlobalConfig();
2022
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2023
+ const result = handleUsage(ctx, sessionUsage);
2024
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2025
+ return;
2026
+ }
2027
+ if (cmd === "/context") {
2028
+ const cfg = await readGlobalConfig();
2029
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2030
+ const result = await handleContext(ctx);
2031
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2032
+ return;
2033
+ }
2034
+ if (cmd === "/tools") {
2035
+ const cfg = await readGlobalConfig();
2036
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2037
+ const result = await handleTools(ctx);
2038
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2039
+ return;
2040
+ }
2041
+ if (cmd === "/hotkeys") {
2042
+ const cfg = await readGlobalConfig();
2043
+ const ctx: SlashContext = { history, sessionModel, sessionId, cwd, config: cfg };
2044
+ const result = handleHotkeys(ctx);
2045
+ if (result && "lines" in result) for (const line of result.lines) console.log(line);
2046
+ return;
2047
+ }
2048
+ if (cmd === "/handoff" || cmd.startsWith("/handoff ")) {
2049
+ const parsed = parseHandoffCommand(cmd) ?? {};
2050
+ const cfg = await readGlobalConfig();
2051
+ const activeModel = sessionModel || defaultModel;
2052
+ // `Config` (state.ts) predates config-schema.ts's `compaction` field and is
2053
+ // out of this change's scope — read the passthrough-preserved value via a
2054
+ // narrow structural cast instead of widening the shared interface.
2055
+ const focus = parsed.focus ?? (cfg as { compaction?: { handoffFocus?: string } }).compaction?.handoffFocus;
2056
+ const res = await buildHandoffDocument(history, { model: activeModel, focus });
2057
+ for (const line of handoffLines(res, process.stdout.columns)) console.log(line);
2058
+ return;
2059
+ }
2060
+ if (cmd === "/theme" || cmd.startsWith("/theme ")) {
2061
+ const decision = decideThemeSlash(cmd, listThemes(), resolveTheme().name);
2062
+ for (const line of decision.lines) console.log(line);
2063
+ if (decision.kind === "set") {
2064
+ process.env.JEO_TUI_THEME = decision.themeName!;
2065
+ await saveConfigPatch(raw => ({ theme: decision.themeName }));
2066
+ // No refreshUiTheme() here — one-shot mode returns immediately after this
2067
+ // block, so there is no live TUI paint state to re-resolve (and
2068
+ // refreshUiTheme is declared later in this closure, out of scope here).
2069
+ }
2070
+ return;
2071
+ }
2072
+ if (cmd === "/wiki" || cmd.startsWith("/wiki ")) {
2073
+ const decision = decideWikiSlash(
2074
+ cmd,
2075
+ resolveWikiRoot(await readGlobalConfig()),
2076
+ !!jeoEnv("WIKI_ROOT"),
2077
+ );
2078
+ for (const line of decision.lines) console.log(line);
2079
+ if (decision.kind === "clear") {
2080
+ await saveConfigPatch(() => ({ wikiRoot: undefined }));
2081
+ delete process.env.JEO_WIKI_ROOT;
2082
+ sessionWikiRoot = undefined;
2083
+ await refreshSessionMemory("");
2084
+ } else if (decision.kind === "set") {
2085
+ await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
2086
+ process.env.JEO_WIKI_ROOT = decision.root;
2087
+ sessionWikiRoot = decision.root;
2088
+ await refreshSessionMemory("");
2089
+ }
2090
+ return;
2091
+ }
2092
+ if (cmd === "/config" || cmd === "/settings") {
2093
+ for (const line of await buildConfigPanelLines({ sessionModel, sessionThinking, sessionId })) console.log(line);
2094
+ return;
2095
+ }
2096
+ if (cmd === "/evolve") {
2097
+ await runEvolveSimulation(animateAsciiArt);
2098
+ return;
2099
+ }
2100
+ }
2101
+ // One skill run (bundle workflow → engine; regular skill → agent turn). Shared by the
2102
+ // single-invocation path and the `$a $b …` chain path so every `$` skill actually runs.
2103
+ const runOneSkillShot = async (inv: SkillInvocation): Promise<void> => {
2104
+ const isBundleWorkflow = isWorkflowSkill(inv.skill.name);
2105
+ if (isBundleWorkflow) {
2106
+ const startMsg: Message = {
2107
+ role: "system",
2108
+ content: `[workflow:${inv.skill.name}:start]${inv.intent ? ` intent: ${inv.intent}` : ""}`
2109
+ };
2110
+ history.push(startMsg);
2111
+ if (sessionId) {
2112
+ await appendMessage(sessionId, startMsg, cwd);
2113
+ }
2106
2114
 
2107
- const harness = createInFlightAbortHarness({
2108
- captureEsc: false,
2109
- onAbortNotice: msg => console.log(msg),
2110
- onHardExit: () => forceExitFromCtrlC(),
2111
- });
2112
- const ac = harness.controller;
2115
+ const harness = createInFlightAbortHarness({
2116
+ captureEsc: false,
2117
+ onAbortNotice: msg => console.log(msg),
2118
+ onHardExit: () => forceExitFromCtrlC(),
2119
+ });
2120
+ const ac = harness.controller;
2113
2121
 
2114
- const opts = {
2115
- cwd,
2116
- signal: ac.signal,
2117
- onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
2118
- io: {
2119
- output: (line: string) => {
2120
- console.log(line);
2122
+ const opts = {
2123
+ cwd,
2124
+ signal: ac.signal,
2125
+ onProgress: (e: { skill: string; phase: string; detail?: string }) => console.log(`[workflow:${e.skill}] ${e.phase}${e.detail ? ` — ${e.detail}` : ""}`),
2126
+ io: {
2127
+ output: (line: string) => {
2128
+ console.log(line);
2129
+ },
2130
+ // This `$a $b …` one-shot chain runs BEFORE the interactive REPL's
2131
+ // `promptInput`/`rl` exist (this whole dispatch is reached and returns
2132
+ // long before the `const promptInput = …` further down the function
2133
+ // executes) — referencing `promptInput` here throws a TDZ
2134
+ // ReferenceError ("Cannot access 'promptInput' before initialization")
2135
+ // the instant the workflow engine asks its first question. That error
2136
+ // is caught and stashed into session history (never printed to the
2137
+ // terminal), so the visible symptom is just a frozen prompt forever
2138
+ // (the "jeo --tmux $deep-interview hangs" / "$deep-interview hangs"
2139
+ // report). Omitting `input` here is safe and correct: no other
2140
+ // readline exists yet at this point, so deep-interview.ts's own
2141
+ // fallback `createInterface({input: process.stdin, ...})` (used
2142
+ // whenever `opts.io?.input` is absent) has nothing to fight over raw
2143
+ // mode with. The INTERACTIVE `runSkillInvocation` sibling path below
2144
+ // runs AFTER `promptInput` is declared and correctly wires it — do not
2145
+ // "fix" this block by copying that wiring here again.
2121
2146
  },
2122
- // This `$a $b …` one-shot chain runs BEFORE the interactive REPL's
2123
- // `promptInput`/`rl` exist (this whole dispatch is reached and returns
2124
- // long before the `const promptInput = …` further down the function
2125
- // executes) — referencing `promptInput` here throws a TDZ
2126
- // ReferenceError ("Cannot access 'promptInput' before initialization")
2127
- // the instant the workflow engine asks its first question. That error
2128
- // is caught and stashed into session history (never printed to the
2129
- // terminal), so the visible symptom is just a frozen prompt forever
2130
- // (the "jeo --tmux $deep-interview hangs" / "$deep-interview hangs"
2131
- // report). Omitting `input` here is safe and correct: no other
2132
- // readline exists yet at this point, so deep-interview.ts's own
2133
- // fallback `createInterface({input: process.stdin, ...})` (used
2134
- // whenever `opts.io?.input` is absent) has nothing to fight over raw
2135
- // mode with. The INTERACTIVE `runSkillInvocation` sibling path below
2136
- // runs AFTER `promptInput` is declared and correctly wires it — do not
2137
- // "fix" this block by copying that wiring here again.
2138
- },
2139
2147
 
2140
- args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
2148
+ args: inv.skill.name === "deep-interview" ? (inv.intent ? inv.intent.split(/\s+/) : []) : undefined
2141
2149
 
2142
- };
2150
+ };
2143
2151
 
2144
- let ok = false;
2145
- let reason: string | undefined;
2146
- try {
2147
- const res: { ok: boolean; reason?: string } = await runWorkflowEngine(inv.skill.name, opts);
2148
- ok = res.ok;
2149
- reason = res.reason;
2150
- } catch (err: any) {
2151
- ok = false;
2152
- reason = err.message;
2153
- } finally {
2154
- harness.dispose();
2155
- }
2152
+ let ok = false;
2153
+ let reason: string | undefined;
2154
+ try {
2155
+ const res: { ok: boolean; reason?: string } = await runWorkflowEngine(inv.skill.name, opts);
2156
+ ok = res.ok;
2157
+ reason = res.reason;
2158
+ } catch (err: any) {
2159
+ ok = false;
2160
+ reason = err.message;
2161
+ } finally {
2162
+ harness.dispose();
2163
+ }
2156
2164
 
2157
- const endMsg: Message = {
2158
- role: "system",
2159
- content: ok
2160
- ? `[workflow:${inv.skill.name}:finish]`
2161
- : `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
2162
- };
2163
- if (sessionId) {
2164
- await appendMessage(sessionId, endMsg, cwd);
2165
+ const endMsg: Message = {
2166
+ role: "system",
2167
+ content: ok
2168
+ ? `[workflow:${inv.skill.name}:finish]`
2169
+ : `[workflow:${inv.skill.name}:abort]${reason ? ` reason: ${reason}` : ""}`
2170
+ };
2171
+ if (sessionId) {
2172
+ await appendMessage(sessionId, endMsg, cwd);
2173
+ }
2174
+ return;
2165
2175
  }
2166
- return;
2167
- }
2168
2176
 
2169
- const useOneShotTui = shouldUseOneShotTui(flags.noTui);
2170
- if (!useOneShotTui) {
2171
- console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
2172
- }
2173
- const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
2174
- const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
2175
- if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2176
- else if (usage) console.log(usage.trim());
2177
- };
2177
+ const useOneShotTui = shouldUseOneShotTui(flags.noTui);
2178
+ if (!useOneShotTui) {
2179
+ console.log(`▶ Running skill: ${inv.skill.name}${inv.intent ? ` — ${inv.intent}` : ""}`);
2180
+ }
2181
+ const task = buildSkillTask(inv.skill, inv.intent, inv.invokedAs);
2182
+ const { reply, rendered, usage } = await runTurn(task, useOneShotTui, undefined, { userCard: null });
2183
+ if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2184
+ else if (usage) console.log(usage.trim());
2185
+ };
2178
2186
 
2179
- // `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
2180
- const skillChain = parseSkillChain(messageContent, resolvedSkills);
2181
- if (skillChain && skillChain.invocations.length) {
2182
- if (skillChain.unresolved.length) {
2183
- console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
2187
+ // `$a $b … [intent]` — run every resolved skill in order; a lone `$skill` is a chain of 1.
2188
+ const skillChain = parseSkillChain(messageContent, resolvedSkills);
2189
+ if (skillChain && skillChain.invocations.length) {
2190
+ if (skillChain.unresolved.length) {
2191
+ console.log(`(skipping unknown skill${skillChain.unresolved.length > 1 ? "s" : ""}: ${skillChain.unresolved.map(u => `$${u}`).join(", ")})`);
2192
+ }
2193
+ if (skillChain.invocations.length > 1) {
2194
+ console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
2195
+ }
2196
+ for (const inv of skillChain.invocations) {
2197
+ await runOneSkillShot(inv);
2198
+ }
2199
+ await maybeDistillOneShot();
2200
+ return;
2184
2201
  }
2185
- if (skillChain.invocations.length > 1) {
2186
- console.log(`▶ Chaining ${skillChain.invocations.length} skills: ${skillChain.invocations.map(i => `$${i.skill.name}`).join(" → ")}`);
2202
+ // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
2203
+ const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
2204
+ if (skillInvocation) {
2205
+ await runOneSkillShot(skillInvocation);
2206
+ await maybeDistillOneShot();
2207
+ return;
2187
2208
  }
2188
- for (const inv of skillChain.invocations) {
2189
- await runOneSkillShot(inv);
2209
+ try {
2210
+ const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
2211
+ if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2212
+ else if (usage) console.log(usage.trim());
2213
+ } catch (err) {
2214
+ console.log(`! ${friendlyProviderError(err)}`);
2190
2215
  }
2191
2216
  await maybeDistillOneShot();
2192
2217
  return;
2218
+ } finally {
2219
+ await sessionNotifyEndpoint?.stop().catch(() => {});
2193
2220
  }
2194
- // A `$skill` invocation resolves to a single skill (by name, declared alias, or unique prefix).
2195
- const skillInvocation = parseSkillInvocation(messageContent, resolvedSkills);
2196
- if (skillInvocation) {
2197
- await runOneSkillShot(skillInvocation);
2198
- await maybeDistillOneShot();
2199
- return;
2200
- }
2201
- try {
2202
- const { reply, rendered, usage } = await runTurn(messageContent, shouldUseOneShotTui(flags.noTui));
2203
- if (!rendered) console.log(stripMarkdown(renderMarkdownTables(reply)) + usage);
2204
- else if (usage) console.log(usage.trim());
2205
- } catch (err) {
2206
- console.log(`! ${friendlyProviderError(err)}`);
2207
- }
2208
- await maybeDistillOneShot();
2209
- return;
2210
2221
  }
2211
2222
 
2212
2223
  // INTERACTIVE mode