jeo-code 0.7.48 → 0.7.49

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";
@@ -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.
@@ -1074,6 +1107,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1074
1107
  // `subagent` tool controls them and cancelAll() in finally prevents orphans.
1075
1108
  const subagentRegistry = new SubagentRegistry();
1076
1109
  const jobRegistry = new JobRegistry();
1110
+ sessionNotifyEndpoint?.attachRegistry(subagentRegistry);
1111
+ currentTurnSteer = { push: line => steerInbox.push(line), flushCard: line => tui?.flushSteerCard(line) };
1112
+ if (sessionNotifyEndpoint && !notifyRedact) {
1113
+ const preview = userInput.length > 200 ? `${userInput.slice(0, 200)}…` : userInput;
1114
+ sessionNotifyEndpoint.sendContextUpdate("turn_start", preview, { model: activeModel });
1115
+ } else if (sessionNotifyEndpoint) {
1116
+ sessionNotifyEndpoint.sendContextUpdate("turn_start", "[redacted]", { model: activeModel });
1117
+ }
1077
1118
  try {
1078
1119
  // Per-turn todo snapshot: drives the done-time reconciliation gate (the
1079
1120
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
@@ -1129,6 +1170,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1129
1170
  signal: ac.signal,
1130
1171
  steer: drainSteer,
1131
1172
  registry: subagentRegistry,
1173
+ sessionEndpoint: sessionNotifyEndpoint,
1132
1174
  onEvent: useTui
1133
1175
  ? (e => tui?.onSubagentEvent(e))
1134
1176
  : (e => logTaskSubEvent(e)),
@@ -1197,7 +1239,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1197
1239
  } finally {
1198
1240
  harness.dispose();
1199
1241
  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
1242
+ sessionNotifyEndpoint?.detachRegistry(); // this turn's registry is gone — subagent frames stop applying to it
1243
+ currentTurnSteer = undefined; // a remote message after this point queues instead of steering a dead turn
1244
+ await stopSessionNotifyEndpoint(subagentRegistry); // tear down the FALLBACK lazy endpoint, if one was started (no-op when sessionNotifyEndpoint owns the registry)
1201
1245
  jobRegistry.cancelAll(); // background jobs are turn-scoped too — no orphaned processes
1202
1246
 
1203
1247
  // Steering typed but never drained (e.g. entered just after the final step)
@@ -1221,6 +1265,21 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1221
1265
  || (result.done
1222
1266
  ? `(done in ${result.steps} step${result.steps === 1 ? "" : "s"} — the model returned no summary)`
1223
1267
  : `(reached the ${result.steps}-step limit without signaling done)`);
1268
+ if (sessionNotifyEndpoint) {
1269
+ const usageStr = result.usage ? formatUsage(result.usage) : undefined;
1270
+ if (notifyRedact) {
1271
+ sessionNotifyEndpoint.sendContextUpdate("turn_end", "[redacted]", { model: activeModel, usage: usageStr });
1272
+ sessionNotifyEndpoint.sendTurnStream("[redacted]");
1273
+ } else {
1274
+ const preview = reply.length > 200 ? `${reply.slice(0, 200)}…` : reply;
1275
+ sessionNotifyEndpoint.sendContextUpdate("turn_end", preview, { model: activeModel, usage: usageStr });
1276
+ // `notifyVerbosity` currently gates ONLY whether tool/step detail would be
1277
+ // mirrored (a future extension point) — the finalized reply text itself is
1278
+ // always sent in full at both verbosity levels, matching gjc's own
1279
+ // lean-vs-verbose semantics (lean omits tool noise, never the answer).
1280
+ sessionNotifyEndpoint.sendTurnStream(reply);
1281
+ }
1282
+ }
1224
1283
  // Persist any messages this turn produced that onStep hasn't flushed yet (the
1225
1284
  // final step's tool-call/result + any retry-loop messages), then the reply.
1226
1285
  // Incremental onStep flushes already wrote the prompt and completed steps, so
@@ -1931,16 +1990,44 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1931
1990
  }
1932
1991
  promptActive = true;
1933
1992
  pasteLineFired = false;
1993
+ // Remote (Telegram) free-text injection while idle at THIS prompt: a fresh
1994
+ // AbortController per call lets `handleRemoteUserMessage` cancel the
1995
+ // in-flight `rl.question()` cleanly (verified empirically: AbortSignal
1996
+ // rejects with AbortError, no zombie resolution stealing the NEXT prompt's
1997
+ // keystroke — unlike an abandoned, never-aborted `rl.question()` promise).
1998
+ // Injection is gated on `rl.line === ""` by the caller (never hijacks a
1999
+ // user actively typing) — `pendingRemoteInject` (module-scope-ish, set here
2000
+ // and cleared in `finally`) is what a remote message actually calls: it
2001
+ // aborts the stale question AND resolves this race with the injected text
2002
+ // in one atomic step, so there is exactly one winner and no zombie.
2003
+ const questionAbort = new AbortController();
2004
+ let remoteInjectResolve: ((text: string) => void) | undefined;
2005
+ pendingRemoteInject = (text: string) => {
2006
+ if (!remoteInjectResolve) return false; // already fired or the race already settled
2007
+ questionAbort.abort();
2008
+ remoteInjectResolve(text);
2009
+ remoteInjectResolve = undefined;
2010
+ return true;
2011
+ };
1934
2012
  try {
1935
- const value = await Promise.race([
1936
- rl.question(prompt),
2013
+ const value = await Promise.race<string | typeof REMOTE_ABORT_SENTINEL>([
2014
+ rl.question(prompt, { signal: questionAbort.signal }).catch(err => {
2015
+ if (err instanceof Error && err.name === "AbortError") return REMOTE_ABORT_SENTINEL;
2016
+ throw err;
2017
+ }),
1937
2018
  new Promise<string>(resolve => {
1938
2019
  notifyStdinClosed = () => {
1939
2020
  if (hardExitOnLoopEnd || process.stdin.isTTY || process.stdout.isTTY) forceExitFromCtrlC();
1940
2021
  resolve(pendingStdinLines.shift() ?? "/exit");
1941
2022
  };
1942
2023
  }),
2024
+ new Promise<string>(resolve => {
2025
+ remoteInjectResolve = resolve;
2026
+ }),
1943
2027
  ]);
2028
+ // The aborted `rl.question()` lost the race (a remote message or stdin-close
2029
+ // won it first) — its sentinel must never be treated as real submitted input.
2030
+ if (value === REMOTE_ABORT_SENTINEL) return promptInput(prompt);
1944
2031
  // PASTE-MERGE: if the resolving line came from inside a bracketed paste, the rest of
1945
2032
  // the paste (buffered in pasteMerge.buf) plus the trailing residual still in the line
1946
2033
  // buffer belong to the SAME message. Wait for paste-end, then join them with newlines
@@ -1981,6 +2068,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1981
2068
  } finally {
1982
2069
  promptActive = false;
1983
2070
  notifyStdinClosed = undefined;
2071
+ pendingRemoteInject = undefined;
1984
2072
  }
1985
2073
  };
1986
2074
 
@@ -2434,6 +2522,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2434
2522
  } catch {
2435
2523
  // Best-effort terminal restore; process exit is the contract.
2436
2524
  }
2525
+ // Fire-and-forget — see the identical rationale at the main hard-exit path.
2526
+ void sessionNotifyEndpoint?.stop();
2437
2527
  process.exit(130);
2438
2528
  };
2439
2529
  // Prompt Ctrl+C: a single press clears a non-empty box; on an empty box it exits.
@@ -3280,6 +3370,47 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3280
3370
  if (pickResult.lastReply !== undefined) lastReply = pickResult.lastReply;
3281
3371
  }
3282
3372
 
3373
+ // Remote (Telegram) free-text reply / config command routing — wired here,
3374
+ // right before the REPL loop starts, since it needs `pendingImages`,
3375
+ // `rl`/`promptInput`'s `pendingRemoteInject`, and `pendingStdinLines`, all
3376
+ // established by now. See the Tier 2 contract's "Session-side routing".
3377
+ if (sessionNotifyEndpoint) {
3378
+ sessionNotifyEndpoint.onUserMessage = (msg): void => {
3379
+ void (async () => {
3380
+ // Resolve any attached photo/document paths (already downloaded to
3381
+ // local temp files by the daemon) into real ImageAttachments via the
3382
+ // SAME path `attachImagePaths` uses for local drag-drop — one image
3383
+ // ingestion mechanism, not a parallel one. Bare paths never contain
3384
+ // whitespace here (daemon-generated temp names), so simple whitespace
3385
+ // joining is a safe token boundary for the path-token scanner.
3386
+ const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
3387
+ const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
3388
+ const cleanedText = attached.images.length ? attached.text : msg.text;
3389
+ if (attached.images.length) pendingImages.push(...attached.images);
3390
+
3391
+ if (interactiveTurnActive && currentTurnSteer) {
3392
+ // Busy: steer the running turn exactly like a local mid-turn Enter.
3393
+ currentTurnSteer.push(cleanedText);
3394
+ currentTurnSteer.flushCard(cleanedText);
3395
+ return;
3396
+ }
3397
+ // Idle: never hijack text the user is actively typing locally — only
3398
+ // abort+inject when the readline buffer is confirmed empty (verified
3399
+ // empirically: aborting mid-partial-type corrupts the buffer).
3400
+ const rli = rl as unknown as { line?: string };
3401
+ if (rli.line === "" && pendingRemoteInject?.(cleanedText)) return;
3402
+ // No question pending, or the user is mid-type: queue for the NEXT
3403
+ // prompt cycle instead (same queue `pendingMidTurnCommands`/paste-merge
3404
+ // already feed — consumed at the top of the next `promptInput` call).
3405
+ pendingStdinLines.push(cleanedText);
3406
+ })();
3407
+ };
3408
+ sessionNotifyEndpoint.onConfigCommand = (cmd): void => {
3409
+ if (cmd.verbosity !== undefined) notifyVerbosity = cmd.verbosity;
3410
+ if (cmd.redact !== undefined) notifyRedact = cmd.redact;
3411
+ };
3412
+ }
3413
+
3283
3414
  while (true) {
3284
3415
  drainPendingTtyInput();
3285
3416
  // "New input first": queued FULL lines from the previous turn are folded
@@ -4129,6 +4260,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4129
4260
  disarmPreview();
4130
4261
  out.write("\x1b[?25h\n");
4131
4262
  } catch { /* best effort */ }
4263
+ // Fire-and-forget: process.exit() below does not wait for this, but the OS
4264
+ // tears down the socket on process death regardless, and a leaked discovery
4265
+ // file is self-healing (the daemon's scanSessions() reaps dead-pid entries
4266
+ // on its next 3s poll — see notify-telegram-daemon.test.ts).
4267
+ void sessionNotifyEndpoint?.stop();
4132
4268
  process.removeListener("SIGINT", handleCtrlC);
4133
4269
  process.stdin.off("data", handleCtrlCByte);
4134
4270
  drainPromptListeners();
@@ -4145,6 +4281,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4145
4281
  // gjc-parity resume pointer (logs/gjc-tui-study analysis Gap C): leave the exact
4146
4282
  // resume command in scrollback on exit, mirroring the --list handler's convention.
4147
4283
  if (sessionId && !flags.noSession) console.log(formatResumeHint(sessionId));
4284
+ await sessionNotifyEndpoint?.stop();
4148
4285
  process.removeListener("SIGINT", handleCtrlC);
4149
4286
  process.stdin.off("data", handleCtrlCByte);
4150
4287
  drainPromptListeners();