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.
- package/CHANGELOG.md +21 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/package.json +1 -1
- package/src/agent/config-schema.ts +15 -1
- package/src/agent/notify/AGENTS.md +30 -10
- package/src/agent/notify/config-commands.ts +50 -0
- package/src/agent/notify/paths.ts +7 -0
- package/src/agent/notify/rate-limit-pool.ts +217 -0
- package/src/agent/notify/session-endpoint.ts +167 -29
- package/src/agent/notify/telegram-api.ts +71 -0
- package/src/agent/notify/telegram-daemon.ts +371 -38
- package/src/agent/notify/telegram-html.ts +414 -0
- package/src/agent/notify/topic-registry.ts +136 -0
- package/src/agent/state.ts +7 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/launch.ts +141 -4
package/src/commands/launch.ts
CHANGED
|
@@ -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
|
-
|
|
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();
|