jeo-code 0.10.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.
@@ -42,13 +42,52 @@ export function parseEtimeToMs(raw: string): number | undefined {
42
42
  return ((days * 24 + h!) * 60 + m!) * 60 * 1000 + s! * 1000;
43
43
  }
44
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". */
45
+ /**
46
+ * Read a process's start time from `/proc` — Linux only, no subprocess.
47
+ *
48
+ * Field 22 of `/proc/<pid>/stat` is the process start time in clock ticks since
49
+ * boot; `/proc/uptime` gives seconds since boot. Together they yield an epoch
50
+ * timestamp WITHOUT spawning anything. That matters because the `ps` path below is
51
+ * exactly what disappears in the environments a Telegram daemon actually runs in:
52
+ * distroless and scratch containers ship no `ps`, and hardened sandboxes/seccomp
53
+ * profiles routinely block the setuid `/bin/ps`. When the lookup dies there, the
54
+ * PID-reuse guard silently degrades to a bare `kill(pid, 0)` — see
55
+ * {@link isLockOwnerAlive} for why that is the dangerous case.
56
+ *
57
+ * The 22nd field is parsed from the LAST `)` rather than by splitting on spaces:
58
+ * field 2 is the executable name in parentheses and may itself contain spaces.
59
+ */
60
+ function procStartTimeMs(pid: number): number | undefined {
61
+ if (process.platform !== "linux") return undefined;
62
+ try {
63
+ const stat = fsSync.readFileSync(`/proc/${pid}/stat`, "utf-8");
64
+ const afterComm = stat.slice(stat.lastIndexOf(")") + 2);
65
+ const fields = afterComm.split(" ");
66
+ // stat field 22 (1-based) == index 19 of the post-comm remainder (fields 3+).
67
+ const startTicks = Number(fields[19]);
68
+ if (!Number.isFinite(startTicks)) return undefined;
69
+ const uptimeSec = Number(fsSync.readFileSync("/proc/uptime", "utf-8").split(" ")[0]);
70
+ if (!Number.isFinite(uptimeSec)) return undefined;
71
+ // USER_HZ is 100 on every Linux ABI that matters; sysconf(_SC_CLK_TCK) is not
72
+ // reachable without a native addon, and jeo is zero-native-dependency by design.
73
+ const startSecSinceBoot = startTicks / 100;
74
+ const bootEpochMs = Date.now() - uptimeSec * 1000;
75
+ return bootEpochMs + startSecSinceBoot * 1000;
76
+ } catch {
77
+ return undefined;
78
+ }
79
+ }
80
+
81
+ /** Best-effort actual OS process start time in epoch ms. Tries `/proc` first (Linux,
82
+ * no subprocess, works in distroless/sandboxed containers), then `ps -o etime=`
83
+ * (POSIX-portable across macOS/Linux; unsupported on Windows, where there is no
84
+ * equivalent zero-dependency lookup). Returns `undefined` when the lookup is
85
+ * unavailable or unparseable — callers MUST treat that as "cannot verify" and fall
86
+ * back to the existence-only check, not as "dead". */
50
87
  export async function processStartTimeMs(pid: number): Promise<number | undefined> {
51
88
  if (process.platform === "win32") return undefined;
89
+ const viaProc = procStartTimeMs(pid);
90
+ if (viaProc !== undefined) return viaProc;
52
91
  const output = await new Promise<string | undefined>(resolve => {
53
92
  let child: ReturnType<typeof nodeSpawn>;
54
93
  try {
@@ -67,6 +106,13 @@ export async function processStartTimeMs(pid: number): Promise<number | undefine
67
106
  return ms === undefined ? undefined : Date.now() - ms;
68
107
  }
69
108
 
109
+ /** True when this host can actually verify a process's start time. Exported so
110
+ * status output (and tests) can distinguish "the guard says the owner is alive"
111
+ * from "the guard could not check" instead of conflating the two. */
112
+ export async function canVerifyProcessStartTime(): Promise<boolean> {
113
+ return (await processStartTimeMs(process.pid)) !== undefined;
114
+ }
115
+
70
116
  /** `etime` is truncated to whole seconds and adds a little spawn/poll jitter;
71
117
  * genuine PID reuse shows up as a gap of minutes/hours/days, never a few
72
118
  * seconds, so a generous-but-tight tolerance loses no real detections. */
@@ -82,10 +128,23 @@ const START_TIME_TOLERANCE_MS = 10_000;
82
128
  * unavailable (Windows, `ps` missing, permission denied) — never a
83
129
  * regression versus the old behavior in that case. */
84
130
  export async function isLockOwnerAlive(lock: DaemonLockInfo): Promise<boolean> {
85
- if (!isPidAlive(lock.pid)) return false;
131
+ return (await inspectLockOwner(lock)).alive;
132
+ }
133
+
134
+ /** Whether the recorded owner is alive, AND whether that answer was actually
135
+ * verified against the OS start time or merely assumed from `kill(pid, 0)`. */
136
+ export interface LockOwnerCheck {
137
+ alive: boolean;
138
+ /** False when the host could not report the process start time (no `/proc`,
139
+ * no usable `ps`, Windows) — `alive` is then an existence-only guess. */
140
+ verified: boolean;
141
+ }
142
+
143
+ export async function inspectLockOwner(lock: DaemonLockInfo): Promise<LockOwnerCheck> {
144
+ if (!isPidAlive(lock.pid)) return { alive: false, verified: true };
86
145
  const real = await processStartTimeMs(lock.pid);
87
- if (real === undefined) return true;
88
- return Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS;
146
+ if (real === undefined) return { alive: true, verified: false };
147
+ return { alive: Math.abs(real - lock.startedAt) <= START_TIME_TOLERANCE_MS, verified: true };
89
148
  }
90
149
 
91
150
  export async function readDaemonLock(): Promise<DaemonLockInfo | undefined> {
@@ -123,6 +182,10 @@ export interface DaemonStatus {
123
182
  stale: boolean;
124
183
  pid?: number;
125
184
  startedAt?: number;
185
+ /** False when this host cannot report a process start time (no `/proc`, no usable
186
+ * `ps`, Windows), so `running` is an existence-only guess that cannot rule out PID
187
+ * reuse. Present only when a lock file exists. */
188
+ ownerVerified?: boolean;
126
189
  }
127
190
 
128
191
  export async function isNotifyConfigured(): Promise<boolean> {
@@ -133,8 +196,10 @@ export async function isNotifyConfigured(): Promise<boolean> {
133
196
  export async function daemonStatus(): Promise<DaemonStatus> {
134
197
  const [lock, configured] = await Promise.all([readDaemonLock(), isNotifyConfigured()]);
135
198
  if (!lock) return { configured, running: false, stale: false };
136
- const alive = await isLockOwnerAlive(lock);
137
- return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt };
199
+ const { alive, verified } = await inspectLockOwner(lock);
200
+ // Surface the degraded case instead of hiding it: on a host with no `/proc` and no
201
+ // usable `ps`, "running" is an existence-only guess that cannot rule out PID reuse.
202
+ return { configured, running: alive, stale: !alive, pid: lock.pid, startedAt: lock.startedAt, ownerVerified: verified };
138
203
  }
139
204
 
140
205
  /** Self-invocation argv for the daemon child (mirrors `memory.ts`'s
@@ -200,7 +265,8 @@ export async function startDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<
200
265
 
201
266
  export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
202
267
  const lock = await readDaemonLock();
203
- if (!lock || !(await isLockOwnerAlive(lock))) {
268
+ const owner = lock ? await inspectLockOwner(lock) : undefined;
269
+ if (!lock || !owner?.alive) {
204
270
  await fs.unlink(notifyDaemonLockPath()).catch(() => {});
205
271
  return { ok: true, message: "daemon was not running" };
206
272
  }
@@ -212,7 +278,8 @@ export async function stopDaemon(): Promise<{ ok: boolean; message: string }> {
212
278
  const stopped = await waitFor(async () => !isPidAlive(lock.pid), 3_000);
213
279
  if (!stopped) return { ok: false, message: `daemon (pid ${lock.pid}) did not exit within 3s` };
214
280
  await fs.unlink(notifyDaemonLockPath()).catch(() => {});
215
- return { ok: true, message: `daemon stopped (was pid ${lock.pid})` };
281
+ const caveat = owner.verified ? "" : " (start time unverifiable on this host — PID reuse could not be ruled out)";
282
+ return { ok: true, message: `daemon stopped (was pid ${lock.pid})${caveat}` };
216
283
  }
217
284
 
218
285
  export async function reloadDaemon(spawnImpl: SpawnLike = defaultSpawn): Promise<{ ok: boolean; message: string }> {
@@ -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}`,
@@ -111,6 +111,27 @@ export interface TaskToolOptions {
111
111
  * no in-batch peer channel yet, so overlapping-scope tasks MUST be sequential. */
112
112
  export const MAX_FANOUT = 4;
113
113
 
114
+ /** Clamp for {@link resolveFanoutLimit}. Below 1 there is no fan-out at all; above 16
115
+ * the parent turn is monopolized no matter how much provider capacity exists. */
116
+ export const MIN_FANOUT_LIMIT = 1;
117
+ export const MAX_FANOUT_LIMIT = 16;
118
+
119
+ /**
120
+ * Effective fan-out concurrency for this account.
121
+ *
122
+ * {@link MAX_FANOUT} is a reasonable default, not a universal one: four concurrent
123
+ * Anthropic streams trip a Tier-1 rate limit immediately (turning a fan-out into a
124
+ * backoff storm that is SLOWER than running sequentially), while an account with real
125
+ * headroom leaves throughput on the table at four. `config.subagentConcurrency` lets
126
+ * that be tuned per machine; the clamp keeps a typo (`0`, `-1`, `500`) from producing a
127
+ * batch that never runs or never yields.
128
+ */
129
+ export function resolveFanoutLimit(config?: { subagentConcurrency?: number }): number {
130
+ const raw = config?.subagentConcurrency;
131
+ if (raw === undefined || !Number.isFinite(raw)) return MAX_FANOUT;
132
+ return Math.min(MAX_FANOUT_LIMIT, Math.max(MIN_FANOUT_LIMIT, Math.trunc(raw)));
133
+ }
134
+
114
135
  /** Small bounded reroute budget for a SUBAGENT's own 429 (distinct from
115
136
  * launch.ts's `ROUTE_FALLBACK_MAX_ATTEMPTS`, default 3): a subagent is already
116
137
  * bounded by its own step/token budget and the parent turn's patience, so this
@@ -635,9 +656,12 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
635
656
  };
636
657
  }
637
658
  }
638
- // Both roles fan out CONCURRENTLY, bounded at MAX_FANOUT (gjc parity — see
639
- // MAX_FANOUT's docstring for the disjoint-file-scope expectation this relies on).
640
- const limit = Math.min(items.length, MAX_FANOUT);
659
+ // Both roles fan out CONCURRENTLY, bounded at the account's effective limit
660
+ // (gjc parity — see MAX_FANOUT's docstring for the disjoint-file-scope expectation
661
+ // this relies on). `config.subagentConcurrency` tunes it per machine so a
662
+ // rate-limited account can drop below the default instead of backoff-storming.
663
+ const fanoutLimit = resolveFanoutLimit(opts.config as { subagentConcurrency?: number });
664
+ const limit = Math.min(items.length, fanoutLimit);
641
665
  // Load project context ONCE per batch instead of re-scanning AGENTS.md for
642
666
  // every fan-out task (redundant IO + duplicated tokens).
643
667
  const batchContext = await loadProjectContext(cwd);
@@ -0,0 +1,123 @@
1
+ /**
2
+ * Retired model ids and where they went.
3
+ *
4
+ * A model id that a provider has sunset does not fail *nicely*: the id is simply
5
+ * unknown at the API, so the call comes back as a 404 / "model not found" from deep
6
+ * inside the adapter. That is a bad failure for jeo specifically, because model ids get
7
+ * **pinned in three durable places** — `config.defaultModel`, per-role
8
+ * `config.subagents.<role>.model`, and `config.roles.*` — and a pinned id keeps failing
9
+ * every turn until the user works out which of the three is stale. The live catalog
10
+ * refresh does not help: discovery only reports what still EXISTS, so a retired pin has
11
+ * nothing to match against and silently stays broken.
12
+ *
13
+ * This table maps a retired id to its documented successor so the manager can (a) route
14
+ * the call to something that works and (b) tell the user exactly which pin to update.
15
+ * It is deliberately small and hand-curated: only ids jeo itself shipped in its catalog
16
+ * or aliases, so nobody's config can be silently rewritten to a model they never chose.
17
+ *
18
+ * Rules for entries:
19
+ * - `replacement` MUST be a currently-catalogued id, and MUST stay on the SAME provider.
20
+ * Silently moving a user to a different vendor (different pricing, different data
21
+ * handling, different account) is not a substitution jeo gets to make on its own.
22
+ * - `note` is shown to the user once per session, so it should name the successor and
23
+ * read as an explanation, not a warning banner.
24
+ */
25
+
26
+ export interface ModelRetirement {
27
+ /** The retired id, exactly as it appeared in jeo's catalog/aliases. */
28
+ readonly retired: string;
29
+ /** Current catalog id that serves the same role on the SAME provider. */
30
+ readonly replacement: string;
31
+ /** One-line reason surfaced to the user. */
32
+ readonly note: string;
33
+ }
34
+
35
+ export const MODEL_RETIREMENTS: readonly ModelRetirement[] = [
36
+ {
37
+ retired: "antigravity/gemini-3.1-pro-high",
38
+ replacement: "antigravity/gemini-pro-agent",
39
+ note: "Antigravity retired gemini-3.1-pro-high; gemini-pro-agent is its code-agent successor.",
40
+ },
41
+ {
42
+ retired: "gemini-3.1-pro-high",
43
+ replacement: "antigravity/gemini-pro-agent",
44
+ note: "Antigravity retired gemini-3.1-pro-high; gemini-pro-agent is its code-agent successor.",
45
+ },
46
+ ] as const;
47
+
48
+ const BY_RETIRED = new Map(MODEL_RETIREMENTS.map(r => [r.retired.toLowerCase(), r]));
49
+
50
+ /** The retirement record for `model`, or undefined when the id is still current. */
51
+ export function findRetirement(model: string | undefined): ModelRetirement | undefined {
52
+ const key = model?.trim().toLowerCase();
53
+ return key ? BY_RETIRED.get(key) : undefined;
54
+ }
55
+
56
+ /** True when `model` names an id jeo knows has been sunset. */
57
+ export function isRetiredModel(model: string | undefined): boolean {
58
+ return findRetirement(model) !== undefined;
59
+ }
60
+
61
+ /**
62
+ * Map a possibly-retired id onto a working one.
63
+ *
64
+ * Returns the input unchanged when it is current, so this is safe to call on every
65
+ * resolution path. Chains are followed (a successor that was itself later retired)
66
+ * with a hard bound, so a malformed table can never spin here.
67
+ */
68
+ export function resolveRetiredModel(model: string): { model: string; retirement?: ModelRetirement } {
69
+ let current = model;
70
+ let first: ModelRetirement | undefined;
71
+ for (let hop = 0; hop < 4; hop++) {
72
+ const hit = findRetirement(current);
73
+ if (!hit) break;
74
+ first ??= hit;
75
+ if (hit.replacement.toLowerCase() === current.toLowerCase()) break; // self-reference guard
76
+ current = hit.replacement;
77
+ }
78
+ return first ? { model: current, retirement: first } : { model };
79
+ }
80
+
81
+ /**
82
+ * Which durable config pins still name a retired model, and what to change them to.
83
+ * Used by `jeo doctor` and by the launch banner so the user is told *where* the stale
84
+ * id lives rather than just that some call failed.
85
+ */
86
+ export interface RetiredPin {
87
+ /** Human-readable location, e.g. `defaultModel` or `subagents.executor.model`. */
88
+ location: string;
89
+ retired: string;
90
+ replacement: string;
91
+ note: string;
92
+ }
93
+
94
+ export function findRetiredPins(config: {
95
+ defaultModel?: string;
96
+ roles?: Record<string, string | undefined>;
97
+ subagents?: Record<string, { model?: string } | undefined>;
98
+ }): RetiredPin[] {
99
+ const pins: RetiredPin[] = [];
100
+ const add = (location: string, model: string | undefined) => {
101
+ const hit = findRetirement(model);
102
+ if (hit) pins.push({ location, retired: model!, replacement: hit.replacement, note: hit.note });
103
+ };
104
+ add("defaultModel", config.defaultModel);
105
+ for (const [role, model] of Object.entries(config.roles ?? {})) add(`roles.${role}`, model);
106
+ for (const [role, cfg] of Object.entries(config.subagents ?? {})) add(`subagents.${role}.model`, cfg?.model);
107
+ return pins;
108
+ }
109
+
110
+ /** One-time-per-session dedupe so a retired pin explains itself once, not every turn. */
111
+ const announced = new Set<string>();
112
+
113
+ export function shouldAnnounceRetirement(retired: string): boolean {
114
+ const key = retired.toLowerCase();
115
+ if (announced.has(key)) return false;
116
+ announced.add(key);
117
+ return true;
118
+ }
119
+
120
+ /** Test hook — clears the per-session announce dedupe. */
121
+ export function resetRetirementAnnouncements(): void {
122
+ announced.clear();
123
+ }
@@ -12,6 +12,14 @@ async function printStatus(): Promise<void> {
12
12
  }
13
13
  if (status.running) {
14
14
  console.log(`running (pid ${status.pid}, started ${new Date(status.startedAt!).toISOString()})`);
15
+ // Say so when the PID-reuse guard could not actually run. On a host with no `/proc`
16
+ // and no usable `ps` (distroless images, seccomp-hardened sandboxes) "running" is an
17
+ // existence-only guess: the pid is alive, but it may belong to an unrelated process
18
+ // that inherited it. Silently printing "running" there overstates what jeo checked.
19
+ if (status.ownerVerified === false) {
20
+ console.log(" note: this host cannot report process start times, so PID reuse could not be ruled out.");
21
+ console.log(" 'jeo daemon stop' may therefore be signalling a different process than the daemon.");
22
+ }
15
23
  } else if (status.stale) {
16
24
  console.log("stale — a lock file exists but its pid is dead (previous daemon crashed without cleanup). 'jeo daemon start' will reclaim it.");
17
25
  } else {
@@ -1,4 +1,5 @@
1
1
  import { readGlobalConfig } from "../agent/state";
2
+ import { findRetiredPins } from "../ai/model-retirements";
2
3
  import { resolveCredential, snapshotProvider, type AuthProvider, type Credential } from "../auth";
3
4
  import { resolveProvider, describeProvider } from "../ai";
4
5
 
@@ -444,6 +445,19 @@ export async function runDoctorCommand(args: string[] = []): Promise<void> {
444
445
  for (const line of oauthLines) console.log(line);
445
446
  console.log("");
446
447
  }
448
+ // Retired-model pins: informational only, but the ONE diagnostic a user cannot get
449
+ // any other way. Alias expansion silently reroutes a sunset id to its successor, so a
450
+ // stale pin keeps working and the user is never told their config is out of date —
451
+ // until the alias is eventually dropped and every turn starts 404-ing. Name the exact
452
+ // config key so the fix is a one-line edit rather than a hunt through three places.
453
+ const retiredPins = findRetiredPins(config as Parameters<typeof findRetiredPins>[0]);
454
+ if (retiredPins.length) {
455
+ for (const pin of retiredPins) {
456
+ console.log(`${chalk.yellow("[model]")} ${pin.location} pins retired '${pin.retired}' — ${pin.note}`);
457
+ console.log(` update it to '${pin.replacement}'.`);
458
+ }
459
+ console.log("");
460
+ }
447
461
  // Routing diagnostic: informational only — never affects ready/strict exit logic.
448
462
  if (routingEnabled) {
449
463
  if (routingNotes.length) {