jeo-code 0.8.39 → 0.8.41

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
@@ -6,6 +6,35 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
6
6
 
7
7
  The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
8
8
 
9
+ ## [0.8.41] - 2026-07-21
10
+ _Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention._
11
+
12
+ ### Fixed
13
+ - **Formatting bloat from the v0.8.40 edit passes** (`route-slash.ts`): two spots left double/triple blank lines between declarations (an artifact of iterative insert/delete edits) instead of the file's established single-blank-line convention; `daemon-control.ts` was missing the separating blank line between `isPidAlive` and the newly added `parseEtimeToMs`. Purely whitespace — zero behavior change, confirmed by an unchanged `bun test` pass count before/after.
14
+ - **`parseEtimeToMs` had zero direct unit tests** despite being a new, non-trivial 3-branch parser (`mm:ss` / `hh:mm:ss` / `dd-hh:mm:ss`) introduced in v0.8.40 — it was previously exercised only indirectly (through `isLockOwnerAlive`/`processStartTimeMs` against real short-lived test processes, which never reach the multi-day branch nor explicitly probe malformed input). Added direct tests for all three time formats plus empty/malformed/wrong-segment-count input, and direct tests for `processStartTimeMs` against both a dead pid (undefined) and a real live one (a plausible, non-garbage timestamp).
15
+
16
+ ### Verified
17
+ - `bun run typecheck` clean.
18
+ - Targeted files (`test/route-slash.test.ts`, `test/notify-daemon-control.test.ts`, `test/daemon-command.test.ts`) — 61 pass / 0 fail, stable across 3 repeated isolated runs.
19
+ - Full `bun test`: the host this session runs on was under extreme, unrelated multi-tenant load during this pass (load average 18-35, 29 concurrent users, 700+ processes) — a full-suite run surfaced ~39 timing-shaped failures (5s+ timeouts) spread across completely unrelated areas (OAuth, LSP rename, model routing/rate-limit fallback), none touching the files changed this pass; the three targeted files stayed green and fast (under 3s) in every isolated run regardless of host load. Recorded here rather than claiming an unreliable full-suite number.
20
+
21
+ ## [0.8.40] - 2026-07-21
22
+ _User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."_
23
+
24
+ ### Added
25
+ - **`/route on save` / `/route off save` / bare `/route save`** (`src/commands/launch/route-slash.ts`) — persists `routing.enabled` to `~/.jeo/config.json` (mirrors `/model save`'s existing session-vs-persistent split), so routing preference now survives a restart without re-toggling every session. The bare form persists whatever is currently effective (an active session override, else the config's existing value); the `on`/`off save` forms also set the session-local override in the same call. All three carefully merge into the existing `routing` object rather than replacing it, so previously configured `tiers`/`confidenceThreshold`/`crossProviderPool` are never clobbered.
26
+
27
+ ### Verified
28
+ - Live (unmocked), with real credentials and a real config copy: `/route on save` wrote `{"routing":{"enabled":true}}` to disk; a fresh `jeo --tmux` process launched against that same config dir immediately reported `routing: on (this session)` via `/route status` with zero re-toggling.
29
+ - `test/route-slash.test.ts` — 28 pass (was 22): 6 new tests cover `on save`/`off save`/bare `save`, config-field preservation (tiers/confidenceThreshold untouched), and confirming a plain `/route on` (no `save`) never touches disk.
30
+ - Full `bun test` — 3130 pass / 4 pre-existing unrelated flakes (browser screenshot/close timing, LSP definition timing, a perf-threshold test) across 307 files, all 4 confirmed passing in isolated re-runs.
31
+ - `bun run typecheck` — no errors.
32
+
33
+ ### Fixed
34
+ - **Telegram daemon lock PID-reuse safety** (`src/agent/notify/daemon-control.ts`, gjc #2786 parity: "restore macOS daemon signaling (kill(2) + start-time recheck)") — `isPidAlive`'s bare `kill(pid, 0)` cannot distinguish a live daemon from an unrelated process that later reused the same pid after the daemon crashed; reproduced live (`isPidAlive(process.pid)` on an arbitrary unrelated process reports "alive"). New `isLockOwnerAlive` cross-checks the recorded `startedAt` against the OS-reported real process start time (`ps -o etime=`, parsed and diffed with a 10s tolerance) before trusting a lock's pid — used by `acquireDaemonLock`, `daemonStatus`, `startDaemon`, and (most importantly) `stopDaemon`, which previously could have sent a real `SIGTERM` to a completely unrelated process. Falls back to the old existence-only check whenever the OS lookup itself is unavailable (Windows, `ps` missing) — never a regression there.
35
+ - `test/notify-daemon-control.test.ts` — 22 pass (was 19): 3 new tests directly reproduce PID reuse (mismatched `startedAt` on a live pid reports not-alive; `stopDaemon` refuses to signal the mismatched pid and clears the lock instead, verified the unrelated live process survives).
36
+ - `test/daemon-command.test.ts` — fixed to use a real process start time instead of an arbitrary placeholder timestamp (the new stricter check correctly flagged the old fixture as a mismatch).
37
+
9
38
  ## [0.8.39] - 2026-07-21
10
39
  _User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)_
11
40
 
package/README.ja.md CHANGED
@@ -283,11 +283,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
283
283
  ## 変更履歴 (Changelog)
284
284
 
285
285
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
286
+ - **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
287
+ - **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
286
288
  - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
287
289
  - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
288
290
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
289
- - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
290
- - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
291
291
 
292
292
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
293
293
  <!-- CHANGELOG:END -->
package/README.ko.md CHANGED
@@ -284,11 +284,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
284
284
  ## 변경 이력 (Changelog)
285
285
 
286
286
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
287
+ - **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
288
+ - **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
287
289
  - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
288
290
  - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
289
291
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
290
- - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
291
- - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
292
292
 
293
293
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
294
294
  <!-- CHANGELOG:END -->
package/README.md CHANGED
@@ -291,11 +291,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
291
291
  ## Changelog
292
292
 
293
293
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
294
+ - **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
295
+ - **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
294
296
  - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
295
297
  - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
296
298
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
297
- - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
298
- - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
299
299
 
300
300
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
301
301
  <!-- CHANGELOG:END -->
package/README.zh.md CHANGED
@@ -283,11 +283,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
283
283
  ## 更新日志 (Changelog)
284
284
 
285
285
  <!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
286
+ - **[0.8.41]** (2026-07-21) — Ponytail-mode review requested directly ("review ponytail") over the two files this session's most recent pass (v0.8.40) touched: `src/agent/notify/daemon-control.ts` and `src/commands/launch/route-slash.ts`. Self-review found no correctness bugs (the v0.8.40 logic held up), but did find real, concrete style-bloat and one genuine test-coverage gap — both fixed with evidence, per the established convention.
287
+ - **[0.8.40]** (2026-07-21) — User report (paraphrased): "the /route command doesn't work, fix it." Investigated live before assuming a defect — booted a real `jeo --tmux` session with real Anthropic OAuth credentials from the actual `~/.jeo/config.json` and drove `/route status|on|off|why|history|bogus` plus real end-to-end routed turns. Every subcommand and the routing engine itself (heuristic tiering, `roles.smol` resolution, live provider call, per-turn "thinking" card showing the routed model) worked correctly, including a real stale-catalog edge case (`roles.smol: "claude-haiku-4-5"`, removed from jeo's own catalog in an earlier curation pass, still being accepted directly by the Anthropic API). The one genuine, reproducible gap: `/route on` is SESSION-LOCAL ONLY — `config.routing.enabled` in `~/.jeo/config.json` is never written, so a user has to retype `/route on` at the start of every single session, and (separately, working as designed but easy to miss) an explicit `--model`/`/model` pin silently blocks routing from ever engaging unless `/route on` is run to override it that session. The "settings don't stick" experience is the most likely reading of "doesn't work."
286
288
  - **[0.8.39]** (2026-07-21) — User report (paraphrased): "when I copy and paste content from elsewhere, it doesn't need to show every line — like gjc — update it so the composer stays a reasonable size and shows a clear indicator when width/length isn't enough." Investigated live before assuming intent: pasting a large multi-line block into the composer already caps the visible box height and scrolls to the caret, but the scrolled-off content above/below was flagged with a bare `…` — no indication of HOW MUCH text was hidden. (Checked gjc's own actual paste-handling source directly: it does not have a general text-paste-collapse feature either — only image-path detection — so this fix targets the concrete, reproducible UX gap found live, not a specific gjc feature to port.)
287
289
  - **[0.8.38]** (2026-07-21) — Independent architect-subagent review of this whole session's TUI/workflow changes (v0.8.29-v0.8.37), requested explicitly to verify stability before calling the work done. Verdict: WATCH/COMMENT, no P0/P1 — the resize fix, the idle↔mid-turn transition, the new `calc` tool wiring, and the web-search credential gating all held up under independent scrutiny. Found and fixed 3 real, previously-unknown bugs in `width.ts`; 1 architectural observation recorded as tech debt, not fixed.
288
290
  - **[0.8.37]** (2026-07-21) — Long-term goal opened to triage every remaining PR in gjc v0.11.5's ~65-entry list, one batch at a time. This entry covers the first batch (10 PRs read in full).
289
- - **[0.8.36]** (2026-07-21) — First full audit of gjc v0.11.5's actual ~65-PR "What's Changed" list (prior passes had used release-note prose or direct-source structural diffs — this is the first time every individual PR was read and triaged against jeo's own codebase).
290
- - **[0.8.35]** (2026-07-21) — Deeper gjc-parity pass: re-cloned the real, public `Yeachan-Heo/gajae-code` source and, this time, structurally compared its actual tool implementations (`packages/coding-agent/src/tools/`) against jeo's own tool surface, not just the provider catalog.
291
291
 
292
292
  See [CHANGELOG.md](CHANGELOG.md) for the full history.
293
293
  <!-- CHANGELOG:END -->
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jeo-code",
3
- "version": "0.8.39",
3
+ "version": "0.8.41",
4
4
  "description": "Clean, highly optimized AI coding agent using spec-first loop",
5
5
  "type": "module",
6
6
  "main": "src/cli.ts",
@@ -26,6 +26,68 @@ export function isPidAlive(pid: number): boolean {
26
26
  }
27
27
  }
28
28
 
29
+ export function parseEtimeToMs(raw: string): number | undefined {
30
+ const trimmed = raw.trim();
31
+ if (!trimmed) return undefined;
32
+ const dayMatch = trimmed.match(/^(\d+)-(.+)$/);
33
+ let days = 0;
34
+ let rest = trimmed;
35
+ if (dayMatch) {
36
+ days = Number(dayMatch[1]);
37
+ rest = dayMatch[2]!;
38
+ }
39
+ const parts = rest.split(":").map(Number);
40
+ if (parts.length < 2 || parts.length > 3 || parts.some(n => !Number.isFinite(n))) return undefined;
41
+ const [h, m, s] = parts.length === 3 ? parts : [0, parts[0]!, parts[1]!];
42
+ return ((days * 24 + h!) * 60 + m!) * 60 * 1000 + s! * 1000;
43
+ }
44
+
45
+ /** Best-effort actual OS process start time in epoch ms, via `ps -o etime=`
46
+ * (POSIX-portable across macOS/Linux `ps`; unsupported on Windows, where
47
+ * there is no equivalent zero-dependency lookup). Returns `undefined` when
48
+ * the lookup is unavailable or unparseable — callers MUST treat that as
49
+ * "cannot verify" and fall back to the existence-only check, not as "dead". */
50
+ export async function processStartTimeMs(pid: number): Promise<number | undefined> {
51
+ if (process.platform === "win32") return undefined;
52
+ const output = await new Promise<string | undefined>(resolve => {
53
+ let child: ReturnType<typeof nodeSpawn>;
54
+ try {
55
+ child = nodeSpawn("ps", ["-o", "etime=", "-p", String(pid)], { stdio: ["ignore", "pipe", "ignore"] });
56
+ } catch {
57
+ resolve(undefined);
58
+ return;
59
+ }
60
+ let buf = "";
61
+ child.stdout?.on("data", d => { buf += d; });
62
+ child.on("error", () => resolve(undefined));
63
+ child.on("close", code => resolve(code === 0 ? buf : undefined));
64
+ });
65
+ if (output === undefined) return undefined;
66
+ const ms = parseEtimeToMs(output);
67
+ return ms === undefined ? undefined : Date.now() - ms;
68
+ }
69
+
70
+ /** `etime` is truncated to whole seconds and adds a little spawn/poll jitter;
71
+ * genuine PID reuse shows up as a gap of minutes/hours/days, never a few
72
+ * seconds, so a generous-but-tight tolerance loses no real detections. */
73
+ const START_TIME_TOLERANCE_MS = 10_000;
74
+
75
+ /** Stronger alternative to a bare `isPidAlive(lock.pid)`: also cross-checks
76
+ * the recorded `startedAt` against the OS-reported actual process start
77
+ * time to rule out PID reuse — a dead daemon's pid can be reassigned by the
78
+ * OS to a completely unrelated process before the next status/stop check
79
+ * runs, which `kill(pid, 0)` alone cannot distinguish (gjc #2786 parity:
80
+ * "restore macOS daemon signaling (kill(2) + start-time recheck)"). Falls
81
+ * back to the existence-only result whenever the OS lookup itself is
82
+ * unavailable (Windows, `ps` missing, permission denied) — never a
83
+ * regression versus the old behavior in that case. */
84
+ export async function isLockOwnerAlive(lock: DaemonLockInfo): Promise<boolean> {
85
+ if (!isPidAlive(lock.pid)) return false;
86
+ const real = await processStartTimeMs(lock.pid);
87
+ if (real === undefined) return true;
88
+ return Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS;
89
+ }
90
+
29
91
  export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
30
92
  try {
31
93
  const raw = await fs.readFile(notifyDaemonLockPath(), "utf-8");
@@ -43,7 +105,7 @@ export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
43
105
  export async function acquireDaemonLock(): Promise<{ release: () => Promise<void> } | undefined> {
44
106
  await fs.mkdir(notifyDir(), { recursive: true, mode: 0o700 });
45
107
  const existing = await readDaemonLock();
46
- if (existing && existing.pid !== process.pid && isPidAlive(existing.pid)) return undefined;
108
+ if (existing && existing.pid !== process.pid && (await isLockOwnerAlive(existing))) return undefined;
47
109
  const info: DaemonLockInfo = { pid: process.pid, startedAt: Date.now() };
48
110
  await fs.writeFile(notifyDaemonLockPath(), JSON.stringify(info), { mode: 0o600 });
49
111
  return {
@@ -71,7 +133,7 @@ export async function isNotifyConfigured(): Promise<boolean> {
71
133
  export async function daemonStatus(): Promise<DaemonStatus> {
72
134
  const [lock, configured] = await Promise.all([readDaemonLock(), isNotifyConfigured()]);
73
135
  if (!lock) return { configured, running: false, stale: false };
74
- const alive = isPidAlive(lock.pid);
136
+ const alive = await isLockOwnerAlive(lock);
75
137
  return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt };
76
138
  }
77
139
 
@@ -115,7 +177,7 @@ async function waitFor(predicate: () => Promise<boolean>, timeoutMs: number, ste
115
177
 
116
178
  export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; pid?: number; message: string }> {
117
179
  const existing = await readDaemonLock();
118
- if (existing && isPidAlive(existing.pid)) {
180
+ if (existing && (await isLockOwnerAlive(existing))) {
119
181
  return { ok: true, pid: existing.pid, message: `daemon already running (pid ${existing.pid})` };
120
182
  }
121
183
  // Check BEFORE spawning: an unconfigured daemon exits almost immediately (see
@@ -138,7 +200,7 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
138
200
 
139
201
  export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
140
202
  const lock = await readDaemonLock();
141
- if (!lock || !isPidAlive(lock.pid)) {
203
+ if (!lock || !(await isLockOwnerAlive(lock))) {
142
204
  await fs.unlink(notifyDaemonLockPath()).catch(() => {});
143
205
  return { ok: true, message: "daemon was not running" };
144
206
  }
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `/route` slash-command handler extracted from launch.ts.
3
3
  *
4
- * Handles `/route [status|on|off|why|history]` (default/`status`: report the
5
- * effective session routing state + the last decision, if any; `on`/`off`:
4
+ * Handles `/route [status|on|off|why|history|save]` (default/`status`: report
5
+ * the effective session routing state + the last decision, if any; `on`/`off`:
6
6
  * toggle a SESSION-LOCAL override of `config.routing.enabled`; `why`: explain
7
7
  * the last routing decision in detail; `history [n]`: print the last `n`
8
8
  * (default 10) recorded decisions, one per line). This block shares mutable
@@ -11,14 +11,16 @@
11
11
  * values in via an explicit context object and reads back a result object
12
12
  * instead of this function closing over REPL state directly.
13
13
  *
14
- * Session-level toggle only — `sessionRouteOverride` is never persisted to
15
- * `~/.jeo/config.json` (mirrors `/thinking` being session-only vs. `/model
16
- * save` being the explicit opt-in for persistence). `/route` has no `save`
17
- * subcommand in v1.
14
+ * `on`/`off` alone are SESSION-LOCAL only (mirrors `/thinking`), same as
15
+ * before — but `on save`/`off save`/bare `save` now persist `routing.enabled`
16
+ * to `~/.jeo/config.json` (mirrors `/model save`'s explicit opt-in for
17
+ * persistence), so a user who wants routing on by default no longer has to
18
+ * remember to type `/route on` at the start of every session.
18
19
  */
19
20
 
20
21
  import type { RouteDecision } from "../../agent/prompt-router";
21
22
  import type { RouteHistoryEntry } from "../../agent/route-history";
23
+ import { saveConfigPatch } from "../../agent/state";
22
24
 
23
25
  export interface RouteSlashCtx {
24
26
  sessionRouteOverride: boolean | undefined;
@@ -35,8 +37,6 @@ export interface RouteSlashCtx {
35
37
  routeHistory: RouteHistoryEntry[];
36
38
  }
37
39
 
38
-
39
-
40
40
  export interface RouteSlashResult {
41
41
  /** Present only when changed by "on"/"off". */
42
42
  sessionRouteOverride?: boolean | undefined;
@@ -44,7 +44,7 @@ export interface RouteSlashResult {
44
44
  lines: string[];
45
45
  }
46
46
 
47
- const USAGE = "Usage: /route [status|on|off|why|history [n]]";
47
+ const USAGE = "Usage: /route [status|on|off|why|history [n]|save|on save|off save]";
48
48
 
49
49
  function isRealDecision(decision: RouteDecision | { note: string } | null): decision is RouteDecision {
50
50
  return !!decision && "model" in decision;
@@ -82,11 +82,11 @@ function formatHistory(entries: RouteHistoryEntry[], n: number): string[] {
82
82
  }
83
83
 
84
84
  /**
85
- * Handle `/route [status|on|off|why|history]`. Extracted for the same reason
86
- * as `/model`: shares REPL-local routing state with `runTurn` via an
85
+ * Handle `/route [status|on|off|why|history|save]`. Extracted for the same
86
+ * reason as `/model`: shares REPL-local routing state with `runTurn` via an
87
87
  * explicit ctx/result object rather than closing over it.
88
88
  */
89
- export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResult {
89
+ export async function runRouteSlash(input: string, ctx: RouteSlashCtx): Promise<RouteSlashResult> {
90
90
  const rest = input.slice("/route".length).trim();
91
91
  const [sub, arg] = rest.split(/\s+/).filter(Boolean);
92
92
  const effective = ctx.sessionRouteOverride ?? ctx.routingConfigEnabled;
@@ -109,14 +109,21 @@ export function runRouteSlash(input: string, ctx: RouteSlashCtx): RouteSlashResu
109
109
  return { lines };
110
110
  }
111
111
 
112
-
113
-
114
- if (sub === "on") {
115
- return { sessionRouteOverride: true, lines: ["routing: on (this session)"] };
112
+ if (sub === "on" || sub === "off") {
113
+ const enabled = sub === "on";
114
+ if (arg === "save") {
115
+ await saveConfigPatch(raw => ({ routing: { ...raw.routing, enabled } }));
116
+ return { sessionRouteOverride: enabled, lines: [`routing: ${enabled ? "on" : "off"} (this session) — saved to ~/.jeo/config.json`] };
117
+ }
118
+ return { sessionRouteOverride: enabled, lines: [`routing: ${enabled ? "on" : "off"} (this session)`] };
116
119
  }
117
120
 
118
- if (sub === "off") {
119
- return { sessionRouteOverride: false, lines: ["routing: off (this session)"] };
121
+ // Bare `/route save` persists whatever is CURRENTLY effective (a prior
122
+ // session-local `on`/`off`, or — if neither was toggled this session —
123
+ // the config's existing value) rather than requiring `/route on save`.
124
+ if (sub === "save") {
125
+ await saveConfigPatch(raw => ({ routing: { ...raw.routing, enabled: effective } }));
126
+ return { lines: [`routing: ${effective ? "on" : "off"} — saved to ~/.jeo/config.json`] };
120
127
  }
121
128
 
122
129
  if (sub === "why") {
@@ -4810,7 +4810,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4810
4810
  }
4811
4811
  if (input.startsWith("/route") && (input === "/route" || input[6] === " ")) {
4812
4812
  const cfgNow = await readGlobalConfig();
4813
- const routeResult = runRouteSlash(input, {
4813
+ const routeResult = await runRouteSlash(input, {
4814
4814
  sessionRouteOverride,
4815
4815
  routingConfigEnabled: !!cfgNow.routing?.enabled,
4816
4816
  lastRouteDecision,
@@ -43,7 +43,7 @@ export const SLASH_COMMAND_DETAILS: readonly SlashCommandInfo[] = [
43
43
  { command: "/logout", usage: "/logout <anthropic|openai|gemini|antigravity>", description: "Remove the stored OAuth token for a provider", group: "models" },
44
44
  { command: "/roles", usage: "/roles [tier model]", description: "Show or set model role tiers (smol/slow/plan)", group: "models" },
45
45
  { command: "/thinking", usage: "/thinking [level]", description: "Show or set thinking budget (low/medium/high/xhigh)", group: "models" },
46
- { command: "/route", usage: "/route [on|off|why|history [n]]", description: "Show/toggle prompt-based model routing for this session, explain the last routing decision, or list recent routing history", group: "models" },
46
+ { command: "/route", usage: "/route [on|off|why|history [n]|save|on save|off save]", description: "Show/toggle prompt-based model routing for this session, explain the last routing decision, or list recent routing history (add save to persist on/off)", group: "models" },
47
47
  { command: "/agents", usage: "/agents [edit|role] [model|#N|thinking L|maxSteps N|reset]", description: "List subagent roles; use /agents edit for the interactive picker or pin role model/settings", group: "subagents" },
48
48
  { command: "/subagent", usage: "/subagent [role]", description: "Show the current subagent composition (per-role model · thinking · steps); alias of /agents", group: "subagents" },
49
49
  { command: "/view", usage: "/view <file> [a-b]", description: "Render a file with line numbers + light highlight", group: "code" },