jeo-code 0.7.48 → 0.7.50

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.
@@ -8,7 +8,8 @@ import { memoryPromptSection, spawnDetachedDistill, recordFailedAttempt } from "
8
8
  import { createTaskTool, taskToolProtocolLine, type TaskSubEvent } from "../agent/task-tool";
9
9
  import { createSubagentTool, SUBAGENT_TOOL_PROTOCOL_LINE } from "../agent/subagent-tool";
10
10
  import { SubagentRegistry } from "../agent/subagent-registry";
11
- import { stopSessionNotifyEndpoint } from "../agent/notify/session-endpoint";
11
+ import { stopSessionNotifyEndpoint, startSessionNotifyEndpoint, type SessionNotifyEndpoint } from "../agent/notify/session-endpoint";
12
+ import { parseInThreadConfigCommand } from "../agent/notify/config-commands";
12
13
 
13
14
  import { createTodoTool, TODO_TOOL_PROTOCOL_LINE } from "../agent/todo-tool";
14
15
  import { createJobTool, JOB_TOOL_PROTOCOL_LINE } from "../agent/job-tool";
@@ -47,7 +48,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
47
48
  import { describeModel, describeAllProviders, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, resolveRoleModel, CODEX_MODELS, qualifyModelId } from "../ai";
48
49
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
49
50
  import { readGoalState, writeGoalState, clearGoalState, verifyGoal } from "../agent/goal-verifier";
50
- import { routePrompt, type RouteDecision } from "../agent/prompt-router";
51
+ import { routePrompt, deriveCacheSessionKey, type RouteDecision } from "../agent/prompt-router";
51
52
 
52
53
  import { listAliases } from "../ai/model-registry";
53
54
  import { openaiCompatDef, SUBSCRIPTION_PROVIDER_NAMES } from "../ai/providers/openai-compatible-catalog";
@@ -289,6 +290,10 @@ export function normalizeSlashAlias(input: string): string {
289
290
 
290
291
  // Per-provider starting model for `--provider <name>` / role pinning. Catalog
291
292
  // OpenAI-compatible providers supply their own default; built-ins use this map.
293
+ // Sentinel returned by an aborted `rl.question()` (remote-inject or stdin-close
294
+ // won the race first) — a unique object identity so it can never collide with
295
+ // real user input, however unlikely a matching string would be.
296
+ const REMOTE_ABORT_SENTINEL = Symbol("remote-abort");
292
297
  const STATIC_PROVIDER_DEFAULT: Partial<Record<ProviderName, string>> = { anthropic: "sonnet", openai: "gpt-5.5", gemini: "flash", antigravity: "antigravity/gemini-3-pro-high", ollama: "fast", lmstudio: "lmstudio/local-model", xai: "grok-4.3", kimi: "kimi-k2-0711-preview" };
293
298
  function providerDefaultModel(p: ProviderName): string {
294
299
  return openaiCompatDef(p)?.defaultModel ?? STATIC_PROVIDER_DEFAULT[p] ?? "";
@@ -801,6 +806,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
801
806
  }
802
807
  }
803
808
 
809
+ // Remote subagent visibility/control AND main-session mirroring over Telegram
810
+ // (gjc per-session-thread parity, see `src/agent/notify/`): best-effort, a
811
+ // cheap no-op unless `notifications.enabled`. Created ONCE for the whole REPL
812
+ // lifetime (not per-turn) so a Telegram forum topic, once created, persists
813
+ // across turns AND across `--resume` (keyed by jeo's real `sessionId`, not a
814
+ // random one — see `SessionNotifyEndpoint`'s header doc).
815
+ const sessionNotifyEndpoint = await startSessionNotifyEndpoint(cwd, sessionId);
816
+ if (sessionNotifyEndpoint) {
817
+ sessionNotifyEndpoint.sendIdentity({ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd, branch, cwd });
818
+ }
819
+
804
820
  // Persist the active per-session model into the session header so `/resume` restores
805
821
  // it (each session can carry its own model independent of the global default).
806
822
  // Best-effort: a header-rewrite failure must never abort the turn.
@@ -830,6 +846,23 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
830
846
  // instead of being steered into the model as literal text.
831
847
  let queueBusyCommand: ((line: string) => void) | undefined;
832
848
  let interactiveTurnActive = false;
849
+ // Set by `promptInput` while a question is in flight; cleared in its
850
+ // `finally`. `handleRemoteUserMessage` calls this to inject a Telegram
851
+ // free-text reply as the next submitted line WITHOUT waiting for a local
852
+ // Enter — returns `false` (caller then falls back to queuing) if no
853
+ // question is currently pending or wiring already fired.
854
+ let pendingRemoteInject: ((text: string) => boolean) | undefined;
855
+ // Set at the start of each `runTurn` to THIS turn's steerInbox/flushSteerCard;
856
+ // cleared at turn end. `handleRemoteUserMessage` steers the running turn
857
+ // through this when set, mirroring the local mid-turn-Enter path exactly
858
+ // (same steerInbox, same scrollback card) instead of a parallel mechanism.
859
+ let currentTurnSteer: { push: (line: string) => void; flushCard: (line: string) => void } | undefined;
860
+ // Session-local notify settings (gjc `/verbose`/`/lean`/`/redact` in-thread
861
+ // command parity): mutable for the REPL's lifetime, defaulted from config,
862
+ // never persisted — a config_command from Telegram changes THIS session
863
+ // only, exactly like gjc's own session-local semantics.
864
+ let notifyVerbosity: "lean" | "verbose" = cfg.notifications?.verbosity ?? "lean";
865
+ let notifyRedact = cfg.notifications?.redact ?? false;
833
866
 
834
867
 
835
868
  // Run one conversational turn: compact, persist user msg, run the loop, persist + return the reply.
@@ -858,6 +891,16 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
858
891
  // Explicit /model always wins: `routed` is only ever computed when `!sessionModel`.
859
892
  const activeModel = routed?.model ?? (sessionModel || defaultModel);
860
893
  const activeThinking = routed?.thinking ?? sessionThinking;
894
+ // Provider-side prompt caches are keyed PER MODEL — reusing the bare `sessionId`
895
+ // across a mid-session model switch (routePrompt changing activeModel turn to
896
+ // turn) sends the same cache-correlation key to a different model/provider,
897
+ // guaranteeing a cache miss on every switch and silently undermining routing's
898
+ // own cost/latency goal (design doc §7 risk #4). Scope the key to the resolved
899
+ // model so each model keeps its own stable cache lineage within the session.
900
+ // `sessionId` is undefined only under `--no-session` — in that case there is no
901
+ // provider-side cache lineage to protect anyway, so forward `undefined` unchanged
902
+ // (matches the prior behavior of `sessionKey: sessionId` for that mode).
903
+ const turnSessionKey = sessionId ? deriveCacheSessionKey(sessionId, activeModel) : undefined;
861
904
  const contextTokens = catalogMetadata(activeModel)?.contextTokens;
862
905
 
863
906
  // Resolve provider + dirty count up front — both are cheap and feed the live
@@ -1074,6 +1117,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1074
1117
  // `subagent` tool controls them and cancelAll() in finally prevents orphans.
1075
1118
  const subagentRegistry = new SubagentRegistry();
1076
1119
  const jobRegistry = new JobRegistry();
1120
+ sessionNotifyEndpoint?.attachRegistry(subagentRegistry);
1121
+ currentTurnSteer = { push: line => steerInbox.push(line), flushCard: line => tui?.flushSteerCard(line) };
1122
+ if (sessionNotifyEndpoint && !notifyRedact) {
1123
+ const preview = userInput.length > 200 ? `${userInput.slice(0, 200)}…` : userInput;
1124
+ sessionNotifyEndpoint.sendContextUpdate("turn_start", preview, { model: activeModel });
1125
+ } else if (sessionNotifyEndpoint) {
1126
+ sessionNotifyEndpoint.sendContextUpdate("turn_start", "[redacted]", { model: activeModel });
1127
+ }
1077
1128
  try {
1078
1129
  // Per-turn todo snapshot: drives the done-time reconciliation gate (the
1079
1130
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
@@ -1129,6 +1180,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1129
1180
  signal: ac.signal,
1130
1181
  steer: drainSteer,
1131
1182
  registry: subagentRegistry,
1183
+ sessionEndpoint: sessionNotifyEndpoint,
1132
1184
  onEvent: useTui
1133
1185
  ? (e => tui?.onSubagentEvent(e))
1134
1186
  : (e => logTaskSubEvent(e)),
@@ -1159,7 +1211,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1159
1211
  maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
1160
1212
  reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
1161
1213
  signal: ac.signal,
1162
- sessionKey: sessionId,
1214
+ sessionKey: turnSessionKey,
1163
1215
  steer: drainSteer,
1164
1216
  events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1165
1217
  });
@@ -1179,7 +1231,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1179
1231
  maxTokens: resolveMaxOutputTokens(activeModel, activeThinking),
1180
1232
  reasoningEffort: activeThinking ? thinkingToReasoningEffort(activeThinking) : undefined,
1181
1233
  signal: ac.signal,
1182
- sessionKey: sessionId,
1234
+ sessionKey: turnSessionKey,
1183
1235
  steer: drainSteer,
1184
1236
  events: wrapEvents(withToolDetailCapture(tui ? tui.events() : streamEvents), opik),
1185
1237
  });
@@ -1197,7 +1249,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1197
1249
  } finally {
1198
1250
  harness.dispose();
1199
1251
  subagentRegistry.cancelAll(); // #9: no detached run leaks past the turn
1200
- await stopSessionNotifyEndpoint(subagentRegistry); // tear down the Telegram remote-control endpoint, if one was started
1252
+ sessionNotifyEndpoint?.detachRegistry(); // this turn's registry is gone — subagent frames stop applying to it
1253
+ currentTurnSteer = undefined; // a remote message after this point queues instead of steering a dead turn
1254
+ await stopSessionNotifyEndpoint(subagentRegistry); // tear down the FALLBACK lazy endpoint, if one was started (no-op when sessionNotifyEndpoint owns the registry)
1201
1255
  jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1202
1256
 
1203
1257
  // Steering typed but never drained (e.g. entered just after the final step)
@@ -1221,6 +1275,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1221
1275
  || (result.done
1222
1276
  ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1223
1277
  : `(reached the ${result.steps}-step limit without signaling done)`);
1278
+ if (sessionNotifyEndpoint) {
1279
+ const usageStr = result.usage ? formatUsage(result.usage) : undefined;
1280
+ if (notifyRedact) {
1281
+ sessionNotifyEndpoint.sendContextUpdate("turn_end", "[redacted]", { model: activeModel, usage: usageStr });
1282
+ sessionNotifyEndpoint.sendTurnStream("[redacted]");
1283
+ } else {
1284
+ const preview = reply.length > 200 ? `${reply.slice(0, 200)}…` : reply;
1285
+ sessionNotifyEndpoint.sendContextUpdate("turn_end", preview, { model: activeModel, usage: usageStr });
1286
+ // `notifyVerbosity` currently gates ONLY whether tool/step detail would be
1287
+ // mirrored (a future extension point) — the finalized reply text itself is
1288
+ // always sent in full at both verbosity levels, matching gjc's own
1289
+ // lean-vs-verbose semantics (lean omits tool noise, never the answer).
1290
+ sessionNotifyEndpoint.sendTurnStream(reply);
1291
+ }
1292
+ }
1224
1293
  // Persist any messages this turn produced that onStep hasn't flushed yet (the
1225
1294
  // final step's tool-call/result + any retry-loop messages), then the reply.
1226
1295
  // Incremental onStep flushes already wrote the prompt and completed steps, so
@@ -1931,16 +2000,44 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1931
2000
  }
1932
2001
  promptActive = true;
1933
2002
  pasteLineFired = false;
2003
+ // Remote (Telegram) free-text injection while idle at THIS prompt: a fresh
2004
+ // AbortController per call lets `handleRemoteUserMessage` cancel the
2005
+ // in-flight `rl.question()` cleanly (verified empirically: AbortSignal
2006
+ // rejects with AbortError, no zombie resolution stealing the NEXT prompt's
2007
+ // keystroke — unlike an abandoned, never-aborted `rl.question()` promise).
2008
+ // Injection is gated on `rl.line === ""` by the caller (never hijacks a
2009
+ // user actively typing) — `pendingRemoteInject` (module-scope-ish, set here
2010
+ // and cleared in `finally`) is what a remote message actually calls: it
2011
+ // aborts the stale question AND resolves this race with the injected text
2012
+ // in one atomic step, so there is exactly one winner and no zombie.
2013
+ const questionAbort = new AbortController();
2014
+ let remoteInjectResolve: ((text: string) => void) | undefined;
2015
+ pendingRemoteInject = (text: string) => {
2016
+ if (!remoteInjectResolve) return false; // already fired or the race already settled
2017
+ questionAbort.abort();
2018
+ remoteInjectResolve(text);
2019
+ remoteInjectResolve = undefined;
2020
+ return true;
2021
+ };
1934
2022
  try {
1935
- const value = await Promise.race([
1936
- rl.question(prompt),
2023
+ const value = await Promise.race<string | typeof REMOTE_ABORT_SENTINEL>([
2024
+ rl.question(prompt, { signal: questionAbort.signal }).catch(err => {
2025
+ if (err instanceof Error && err.name === "AbortError") return REMOTE_ABORT_SENTINEL;
2026
+ throw err;
2027
+ }),
1937
2028
  new Promise<string>(resolve => {
1938
2029
  notifyStdinClosed = () => {
1939
2030
  if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
1940
2031
  resolve(pendingStdinLines.shift() ?? "/exit");
1941
2032
  };
1942
2033
  }),
2034
+ new Promise<string>(resolve => {
2035
+ remoteInjectResolve = resolve;
2036
+ }),
1943
2037
  ]);
2038
+ // The aborted `rl.question()` lost the race (a remote message or stdin-close
2039
+ // won it first) — its sentinel must never be treated as real submitted input.
2040
+ if (value === REMOTE_ABORT_SENTINEL) return promptInput(prompt);
1944
2041
  // PASTE-MERGE: if the resolving line came from inside a bracketed paste, the rest of
1945
2042
  // the paste (buffered in pasteMerge.buf) plus the trailing residual still in the line
1946
2043
  // buffer belong to the SAME message. Wait for paste-end, then join them with newlines
@@ -1981,6 +2078,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1981
2078
  } finally {
1982
2079
  promptActive = false;
1983
2080
  notifyStdinClosed = undefined;
2081
+ pendingRemoteInject = undefined;
1984
2082
  }
1985
2083
  };
1986
2084
 
@@ -2434,6 +2532,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2434
2532
  } catch {
2435
2533
  // Best-effort terminal restore; process exit is the contract.
2436
2534
  }
2535
+ // Fire-and-forget — see the identical rationale at the main hard-exit path.
2536
+ void sessionNotifyEndpoint?.stop();
2437
2537
  process.exit(130);
2438
2538
  };
2439
2539
  // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
@@ -3280,6 +3380,47 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3280
3380
  if (pickResult.lastReply !== undefined) lastReply = pickResult.lastReply;
3281
3381
  }
3282
3382
 
3383
+ // Remote (Telegram) free-text reply / config command routing — wired here,
3384
+ // right before the REPL loop starts, since it needs `pendingImages`,
3385
+ // `rl`/`promptInput`'s `pendingRemoteInject`, and `pendingStdinLines`, all
3386
+ // established by now. See the Tier 2 contract's "Session-side routing".
3387
+ if (sessionNotifyEndpoint) {
3388
+ sessionNotifyEndpoint.onUserMessage = (msg): void => {
3389
+ void (async () => {
3390
+ // Resolve any attached photo/document paths (already downloaded to
3391
+ // local temp files by the daemon) into real ImageAttachments via the
3392
+ // SAME path `attachImagePaths` uses for local drag-drop — one image
3393
+ // ingestion mechanism, not a parallel one. Bare paths never contain
3394
+ // whitespace here (daemon-generated temp names), so simple whitespace
3395
+ // joining is a safe token boundary for the path-token scanner.
3396
+ const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
3397
+ const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
3398
+ const cleanedText = attached.images.length ? attached.text : msg.text;
3399
+ if (attached.images.length) pendingImages.push(...attached.images);
3400
+
3401
+ if (interactiveTurnActive && currentTurnSteer) {
3402
+ // Busy: steer the running turn exactly like a local mid-turn Enter.
3403
+ currentTurnSteer.push(cleanedText);
3404
+ currentTurnSteer.flushCard(cleanedText);
3405
+ return;
3406
+ }
3407
+ // Idle: never hijack text the user is actively typing locally — only
3408
+ // abort+inject when the readline buffer is confirmed empty (verified
3409
+ // empirically: aborting mid-partial-type corrupts the buffer).
3410
+ const rli = rl as unknown as { line?: string };
3411
+ if (rli.line === "" && pendingRemoteInject?.(cleanedText)) return;
3412
+ // No question pending, or the user is mid-type: queue for the NEXT
3413
+ // prompt cycle instead (same queue `pendingMidTurnCommands`/paste-merge
3414
+ // already feed — consumed at the top of the next `promptInput` call).
3415
+ pendingStdinLines.push(cleanedText);
3416
+ })();
3417
+ };
3418
+ sessionNotifyEndpoint.onConfigCommand = (cmd): void => {
3419
+ if (cmd.verbosity !== undefined) notifyVerbosity = cmd.verbosity;
3420
+ if (cmd.redact !== undefined) notifyRedact = cmd.redact;
3421
+ };
3422
+ }
3423
+
3283
3424
  while (true) {
3284
3425
  drainPendingTtyInput();
3285
3426
  // "New input first": queued FULL lines from the previous turn are folded
@@ -4129,6 +4270,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4129
4270
  disarmPreview();
4130
4271
  out.write("\x1b[?25h\n");
4131
4272
  } catch { /* best effort */ }
4273
+ // Fire-and-forget: process.exit() below does not wait for this, but the OS
4274
+ // tears down the socket on process death regardless, and a leaked discovery
4275
+ // file is self-healing (the daemon's scanSessions() reaps dead-pid entries
4276
+ // on its next 3s poll — see notify-telegram-daemon.test.ts).
4277
+ void sessionNotifyEndpoint?.stop();
4132
4278
  process.removeListener("SIGINT", handleCtrlC);
4133
4279
  process.stdin.off("data", handleCtrlCByte);
4134
4280
  drainPromptListeners();
@@ -4145,6 +4291,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4145
4291
  // gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
4146
4292
  // resume command in scrollback on exit, mirroring the --list handler's convention.
4147
4293
  if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
4294
+ await sessionNotifyEndpoint?.stop();
4148
4295
  process.removeListener("SIGINT", handleCtrlC);
4149
4296
  process.stdin.off("data", handleCtrlCByte);
4150
4297
  drainPromptListeners();