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.
- package/CHANGELOG.md +37 -0
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- 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/prompt-router.ts +18 -0
- package/src/agent/state.ts +7 -0
- package/src/agent/task-tool.ts +6 -2
- package/src/commands/doctor.ts +23 -0
- package/src/commands/launch.ts +154 -7
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";
|
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
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();
|