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.
@@ -1,24 +1,44 @@
1
1
  /**
2
- * Per-process loopback WebSocket endpoint exposing ONE `SubagentRegistry` for
3
- * remote monitoring/control (gjc "loopback WebSocket SDK" parity, scoped to
4
- * subagents — jeo does not replicate gjc's full session-lifecycle/ask-prompt
5
- * notification surface, see CHANGELOG 0.7.34). The endpoint:
2
+ * Per-SESSION loopback WebSocket endpoint (gjc per-session-thread parity —
3
+ * see the Tier 2 contract in CHANGELOG history). One endpoint lives for the
4
+ * whole interactive REPL lifetime (created once `sessionId` is established,
5
+ * stopped at process exit), multiplexing TWO frame families over the SAME
6
+ * connection:
6
7
  *
8
+ * - subagent visibility/control (unchanged since 0.7.34): `snapshot` on
9
+ * connect + on change, `steer`/`cancel`/`list` requests — applied to
10
+ * whichever `SubagentRegistry` is currently ATTACHED (see below);
11
+ * - main-session mirroring (new): `identity_header` (once, at start),
12
+ * `context_update` (turn start/end), `turn_stream` (finalized reply) sent
13
+ * OUT, and `user_message`/`config_command` received IN via callbacks the
14
+ * caller registers.
15
+ *
16
+ * `SubagentRegistry` instances are themselves per-turn (a fresh one every
17
+ * `runTurn` call, see launch.ts), so this endpoint does NOT own one at
18
+ * construction — `attachRegistry`/`detachRegistry` swap the live registry in
19
+ * and out at turn boundaries. Between turns (or before the first turn),
20
+ * `snapshot`/`list` report an empty subagent list and `steer`/`cancel` ack
21
+ * `ok:false` — there is no registry to apply them to, not an error.
22
+ *
23
+ * The endpoint:
7
24
  * - binds a random free port on 127.0.0.1 only (never exposed off-box; the
8
25
  * ONLY internet-facing hop is the separate `notify-daemon-run` process
9
26
  * relaying through Telegram — see `telegram-daemon.ts`);
10
- * - publishes a discovery file (`notifySessionEndpointPath`) with the ws url,
11
- * a random auth token, and `pid`/`cwd` so the daemon can find and connect;
12
- * - pushes a `snapshot` frame on connect and whenever the registry's subagent
13
- * list changes (cheap poll — bounded by the small list size, only runs
14
- * while a client is connected);
15
- * - accepts `steer`/`cancel`/`list` requests and applies them to the SAME
16
- * registry instance the interactive turn's `task`/`subagent` tools use.
27
+ * - publishes a discovery file (`notifySessionEndpointPath`) keyed by jeo's
28
+ * REAL session id (not a random one) so a Telegram forum topic, once
29
+ * created for a session, is found and reused across `--resume` instead of
30
+ * minting a duplicate topic every relaunch;
31
+ * - pushes a `snapshot` frame on connect and whenever the attached
32
+ * registry's subagent list changes (cheap poll — bounded by the small
33
+ * list size, only runs while a client is connected).
17
34
  *
18
- * Lifecycle is opt-in and lazy: `ensureSessionNotifyEndpoint` is a no-op unless
19
- * `notifications.enabled` is set in the global config, and only actually binds
20
- * once a detached subagent is launched (never pays a port/socket cost for a
21
- * plain turn). `stopSessionNotifyEndpoint` tears it down (turn boundary).
35
+ * Lifecycle: `startSessionNotifyEndpoint` is a no-op unless
36
+ * `notifications.enabled` is set in the global config. A context lacking a
37
+ * real jeo session (e.g. a bare `task-tool.ts` detached run outside any
38
+ * interactive REPL) falls back to the OLD lazy, per-registry, random-id
39
+ * behavior via `ensureSessionNotifyEndpoint` — but when an interactive
40
+ * session's endpoint is already running in this process, that same path
41
+ * ATTACHES to it instead of creating a second, competing endpoint.
22
42
  */
23
43
  import * as fs from "node:fs/promises";
24
44
  import type { Server, ServerWebSocket } from "bun";
@@ -28,25 +48,74 @@ import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
28
48
 
29
49
  const SNAPSHOT_POLL_MS = 1_200;
30
50
 
51
+ /** Session identity, pushed once as an `identity_header` frame right after
52
+ * `start()` — lets the daemon name/rename the session's forum topic. */
53
+ export interface SessionIdentity {
54
+ repo: string;
55
+ branch?: string;
56
+ cwd: string;
57
+ }
58
+
59
+ /** A free-text reply arriving in the session's topic, already resolved to
60
+ * local temp-file paths for any attached photos/documents (the daemon owns
61
+ * the download; this endpoint only relays paths onward). */
62
+ export interface RemoteUserMessage {
63
+ text: string;
64
+ imagePaths?: string[];
65
+ }
66
+
67
+ /** A parsed `/verbose`, `/lean`, `/verbosity`, or `/redact` in-thread command. */
68
+ export interface RemoteConfigCommand {
69
+ verbosity?: "lean" | "verbose";
70
+ redact?: boolean;
71
+ }
72
+
31
73
  export class SessionNotifyEndpoint {
32
74
  private server: Server<undefined> | undefined;
33
75
 
34
76
  private readonly sockets = new Set<ServerWebSocket<undefined>>();
35
77
  private pollTimer: ReturnType<typeof setInterval> | undefined;
36
78
  private lastSnapshotJson = "";
79
+ /** `undefined` between turns — subagent requests report an empty registry. */
80
+ private registry: SubagentRegistry | undefined;
37
81
  readonly sessionId: string;
38
82
  private readonly token: string;
39
83
 
84
+ /** Registered by the caller (launch.ts) to route inbound frames; both are
85
+ * no-ops (frame silently dropped) until wired — matches the existing
86
+ * best-effort posture of this whole module. */
87
+ onUserMessage: ((msg: RemoteUserMessage) => void) | undefined;
88
+ onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
89
+
40
90
  constructor(
41
- private readonly registry: SubagentRegistry,
42
91
  private readonly cwd: string,
92
+ /** jeo's real, persistent session id when one exists (so a resumed
93
+ * session reconnects to the SAME discovery file / Telegram topic);
94
+ * falls back to a random id for session-less contexts. */
95
+ sessionId: string = crypto.randomUUID(),
43
96
  ) {
44
- this.sessionId = crypto.randomUUID();
97
+ this.sessionId = sessionId;
45
98
  this.token = crypto.randomUUID();
46
99
  }
47
100
 
101
+ /** Attach the registry backing THIS turn's subagents; `list`/`snapshot`
102
+ * immediately reflect it. Call once per `runTurn`. */
103
+ attachRegistry(registry: SubagentRegistry): void {
104
+ this.registry = registry;
105
+ this.lastSnapshotJson = ""; // force a fresh push even if shapes coincide
106
+ this.broadcastIfChanged();
107
+ }
108
+
109
+ /** Detach at turn end — a completed turn's registry must never keep
110
+ * receiving `steer`/`cancel` after `cancelAll()` already ran on it. */
111
+ detachRegistry(): void {
112
+ this.registry = undefined;
113
+ this.lastSnapshotJson = "";
114
+ this.broadcastIfChanged();
115
+ }
116
+
48
117
  private snapshot(): { type: "snapshot"; sessionId: string; cwd: string; pid: number; subagents: SubagentRecord[] } {
49
- return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents: this.registry.list() };
118
+ return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents: this.registry?.list() ?? [] };
50
119
  }
51
120
 
52
121
  private broadcastIfChanged(): void {
@@ -55,6 +124,11 @@ export class SessionNotifyEndpoint {
55
124
  const json = JSON.stringify(snap);
56
125
  if (json === this.lastSnapshotJson) return;
57
126
  this.lastSnapshotJson = json;
127
+ this.broadcast(json);
128
+ }
129
+
130
+ /** Send a pre-serialized frame to every connected socket, best-effort. */
131
+ private broadcast(json: string): void {
58
132
  for (const ws of this.sockets) {
59
133
  try {
60
134
  ws.send(json);
@@ -62,6 +136,27 @@ export class SessionNotifyEndpoint {
62
136
  }
63
137
  }
64
138
 
139
+ /** Push the one-time identity header. Safe to call even with no socket
140
+ * connected yet — silently dropped, matching every other push here (the
141
+ * daemon reconnects and gets a fresh `snapshot`, but identity is a single
142
+ * point-in-time fact with no polling fallback, so a session started before
143
+ * the daemon connects will not retroactively announce itself; acceptable
144
+ * since the common case is the daemon already scanning when a session
145
+ * starts). */
146
+ sendIdentity(identity: SessionIdentity): void {
147
+ this.broadcast(JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity }));
148
+ }
149
+
150
+ /** Push a turn-boundary summary (`phase: "turn_start" | "turn_end"`). */
151
+ sendContextUpdate(phase: "turn_start" | "turn_end", summary: string, extra?: { model?: string; usage?: string }): void {
152
+ this.broadcast(JSON.stringify({ type: "context_update", sessionId: this.sessionId, phase, summary, ...extra }));
153
+ }
154
+
155
+ /** Push the finalized reply text for a completed turn. */
156
+ sendTurnStream(text: string): void {
157
+ this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
158
+ }
159
+
65
160
  private handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): void {
66
161
  let msg: Record<string, unknown>;
67
162
  try {
@@ -78,7 +173,7 @@ export class SessionNotifyEndpoint {
78
173
  if (msg.type === "steer") {
79
174
  const id = String(msg.id ?? "");
80
175
  const message = String(msg.message ?? "");
81
- const ok = id && message ? this.registry.steer(id, message) : false;
176
+ const ok = id && message ? (this.registry?.steer(id, message) ?? false) : false;
82
177
  try {
83
178
  ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
84
179
  } catch {}
@@ -86,13 +181,24 @@ export class SessionNotifyEndpoint {
86
181
  }
87
182
  if (msg.type === "cancel") {
88
183
  const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === "string") : [];
89
- const cancelled = ids.length > 0 ? this.registry.cancel(ids) : [];
184
+ const cancelled = ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
90
185
  try {
91
186
  ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
92
187
  ws.send(JSON.stringify(this.snapshot()));
93
188
  } catch {}
94
189
  return;
95
190
  }
191
+ if (msg.type === "user_message" && typeof msg.text === "string") {
192
+ const imagePaths = Array.isArray(msg.imagePaths) ? msg.imagePaths.filter((x): x is string => typeof x === "string") : undefined;
193
+ this.onUserMessage?.({ text: msg.text, imagePaths });
194
+ return;
195
+ }
196
+ if (msg.type === "config_command") {
197
+ const verbosity = msg.verbosity === "lean" || msg.verbosity === "verbose" ? msg.verbosity : undefined;
198
+ const redact = typeof msg.redact === "boolean" ? msg.redact : undefined;
199
+ if (verbosity !== undefined || redact !== undefined) this.onConfigCommand?.({ verbosity, redact });
200
+ return;
201
+ }
96
202
  }
97
203
 
98
204
  /** Bind the server, start the change-poll, and publish the discovery file.
@@ -154,23 +260,50 @@ export class SessionNotifyEndpoint {
154
260
  }
155
261
  }
156
262
 
157
- // ── Lazy per-registry singleton wiring (task-tool.ts detached path) ────────────
263
+ // ── Session-scoped lifecycle (launch.ts's interactive REPL) ─────────────────
264
+
265
+ /** Best-effort, fire-and-forget: creates + starts a session-scoped endpoint IF
266
+ * `notifications.enabled`, otherwise returns `undefined` (cheap no-op — the
267
+ * common case). Never throws. Call once, right after `sessionId` is
268
+ * established; keep the result for the REPL's lifetime (`attachRegistry`
269
+ * per turn, `stop()` at exit). */
270
+ export async function startSessionNotifyEndpoint(cwd: string, sessionId: string | undefined): Promise<SessionNotifyEndpoint | undefined> {
271
+ try {
272
+ const config = await readGlobalConfig();
273
+ if (!config.notifications?.enabled) return undefined;
274
+ const endpoint = new SessionNotifyEndpoint(cwd, sessionId);
275
+ await endpoint.start();
276
+ return endpoint;
277
+ } catch {
278
+ return undefined; // best-effort: a bind/config failure must never break the session
279
+ }
280
+ }
281
+
282
+ // ── Lazy per-registry fallback (task-tool.ts's detached path OUTSIDE any
283
+ // interactive session — e.g. a bare `jeo run`/non-REPL invocation with no
284
+ // session-scoped endpoint to attach to) ───────────────────────────────────
158
285
  const endpointsByRegistry = new WeakMap<SubagentRegistry, SessionNotifyEndpoint>();
159
286
  const startingRegistries = new WeakSet<SubagentRegistry>();
160
287
 
161
- /** Best-effort, fire-and-forget: starts (once) a `SessionNotifyEndpoint` bound to
162
- * `registry` IF `notifications.enabled` is set, otherwise a cheap no-op. Never
163
- * throws and never blocks the caller — matches the `spawnDetachedDistill`
164
- * "background best-effort, never slows the turn" convention. */
165
- export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: string): void {
288
+ /** Best-effort, fire-and-forget: attaches `registry` to `sessionEndpoint` when
289
+ * given (the common interactive-session case — no new endpoint, no discovery
290
+ * file, just a registry swap), otherwise falls back to the OLD lazy
291
+ * per-registry endpoint (own random session id, own discovery file) IF
292
+ * `notifications.enabled`. Never throws and never blocks the caller. */
293
+ export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: string, sessionEndpoint?: SessionNotifyEndpoint): void {
294
+ if (sessionEndpoint) {
295
+ sessionEndpoint.attachRegistry(registry);
296
+ return;
297
+ }
166
298
  if (endpointsByRegistry.has(registry) || startingRegistries.has(registry)) return;
167
299
  startingRegistries.add(registry);
168
300
  void (async () => {
169
301
  try {
170
302
  const config = await readGlobalConfig();
171
303
  if (!config.notifications?.enabled) return;
172
- const endpoint = new SessionNotifyEndpoint(registry, cwd);
304
+ const endpoint = new SessionNotifyEndpoint(cwd);
173
305
  await endpoint.start();
306
+ endpoint.attachRegistry(registry);
174
307
  endpointsByRegistry.set(registry, endpoint);
175
308
  } catch {
176
309
  // best-effort: a bind/config failure must never break the parent turn.
@@ -180,11 +313,16 @@ export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: str
180
313
  })();
181
314
  }
182
315
 
183
- /** Turn-boundary teardown (mirrors `registry.cancelAll()`); a no-op if no endpoint
184
- * was ever started for this registry (the common case — notifications off). */
316
+ /** Turn-boundary teardown for the FALLBACK path only: detaches + stops the
317
+ * lazily-created endpoint, if one was ever started for this registry (the
318
+ * common case — either notifications are off, or an interactive session's
319
+ * endpoint was attached instead and owns its own `stop()` at process exit).
320
+ * A no-op when `registry` was attached to a `sessionEndpoint` above — that
321
+ * endpoint outlives the turn and the caller detaches it separately. */
185
322
  export async function stopSessionNotifyEndpoint(registry: SubagentRegistry): Promise<void> {
186
323
  const endpoint = endpointsByRegistry.get(registry);
187
324
  if (!endpoint) return;
188
325
  endpointsByRegistry.delete(registry);
189
326
  await endpoint.stop();
190
327
  }
328
+
@@ -49,6 +49,14 @@ export interface TelegramUpdate {
49
49
  /** Forum-topic thread this message belongs to (supergroups with topics on). */
50
50
  message_thread_id?: number;
51
51
  is_topic_message?: boolean;
52
+ /** One entry per generated size (largest last); the daemon downloads the
53
+ * largest for inbound relay. Absent unless the message attached a photo. */
54
+ photo?: { file_id: string; width: number; height: number }[];
55
+ /** A non-photo file attachment (image sent "as file", or any document). */
56
+ document?: { file_id: string; file_name?: string; mime_type?: string };
57
+ /** Caption text on a photo/document message (Telegram never sets `text`
58
+ * alongside media — the caption is the media message's own text field). */
59
+ caption?: string;
52
60
  };
53
61
  callback_query?: TelegramCallbackQuery;
54
62
  }
@@ -75,6 +83,8 @@ export interface SendMessageOptions {
75
83
  messageThreadId?: number;
76
84
  replyMarkup?: InlineKeyboardMarkup;
77
85
  disableWebPagePreview?: boolean;
86
+ /** `"HTML"` to enable Telegram's bounded HTML tag subset (see `telegram-html.ts`). */
87
+ parseMode?: "HTML";
78
88
  }
79
89
 
80
90
  export interface SendPhotoOptions {
@@ -126,6 +136,7 @@ export class TelegramApi {
126
136
  };
127
137
  if (options.messageThreadId !== undefined) body.message_thread_id = options.messageThreadId;
128
138
  if (options.replyMarkup) body.reply_markup = options.replyMarkup;
139
+ if (options.parseMode) body.parse_mode = options.parseMode;
129
140
  return this.postJson<TelegramSendResult>("sendMessage", body);
130
141
  }
131
142
 
@@ -162,6 +173,66 @@ export class TelegramApi {
162
173
  const res = await this.fetchImpl(`${this.url("getUpdates")}?${params.toString()}`);
163
174
  return (await res.json()) as TelegramGetUpdatesResult;
164
175
  }
176
+
177
+ /** Create a forum topic in a chat with Threaded Mode enabled (gjc per-session topic parity). */
178
+ async createForumTopic(chatId: string | number, name: string): Promise<{ ok: boolean; result?: { message_thread_id: number } }> {
179
+ return this.postJson<{ ok: boolean; result?: { message_thread_id: number } }>("createForumTopic", {
180
+ chat_id: chatId,
181
+ name,
182
+ });
183
+ }
184
+
185
+ /** Rename an existing forum topic, e.g. once a session's real title is known (gjc identity-rename parity). */
186
+ async editForumTopic(chatId: string | number, messageThreadId: number, name: string): Promise<TelegramSendResult> {
187
+ return this.postJson<TelegramSendResult>("editForumTopic", {
188
+ chat_id: chatId,
189
+ message_thread_id: messageThreadId,
190
+ name,
191
+ });
192
+ }
193
+
194
+ /** Resolve a `file_id` to a short-lived `file_path` for use with {@link downloadFile}. */
195
+ async getFile(fileId: string): Promise<{ ok: boolean; result?: { file_path?: string } }> {
196
+ return this.postJson<{ ok: boolean; result?: { file_path?: string } }>("getFile", { file_id: fileId });
197
+ }
198
+
199
+ /**
200
+ * Download a file resolved via {@link getFile}. `filePath` is untrusted
201
+ * remote metadata from a Bot API response, not something jeo controls —
202
+ * reject any `..`, leading `/`, or `\` segment before building the URL
203
+ * (guards against path-traversal/SSRF via a malicious `file_path`) and
204
+ * percent-encode each path segment. Best-effort: returns `undefined` on
205
+ * rejection, fetch failure, or a non-ok response instead of throwing,
206
+ * since this is new network I/O every caller must treat as fallible
207
+ * (gjc `downloadTelegramFile` parity, minus the retry logic gjc layers on
208
+ * top at the daemon level).
209
+ */
210
+ async downloadFile(filePath: string): Promise<Uint8Array | undefined> {
211
+ if (filePath.includes("..") || filePath.startsWith("/") || filePath.includes("\\")) return undefined;
212
+ const encodedPath = filePath.split("/").map(encodeURIComponent).join("/");
213
+ const url = `https://api.telegram.org/file/bot${this.token}/${encodedPath}`;
214
+ try {
215
+ const res = await this.fetchImpl(url);
216
+ if (!res.ok) return undefined;
217
+ return new Uint8Array(await res.arrayBuffer());
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ }
222
+
223
+ /** Look up a chat's type; callers gate per-session topic creation on `result.type === "private"` (gjc `pairedChatIsPrivate` fail-closed parity). */
224
+ async getChat(chatId: string | number): Promise<{ ok: boolean; result?: { type?: string } }> {
225
+ return this.postJson<{ ok: boolean; result?: { type?: string } }>("getChat", { chat_id: chatId });
226
+ }
227
+
228
+ /** Attach an emoji reaction to a message (gjc queued/consumed delivery double-check parity). */
229
+ async setMessageReaction(chatId: string | number, messageId: number, emoji: string): Promise<TelegramSendResult> {
230
+ return this.postJson<TelegramSendResult>("setMessageReaction", {
231
+ chat_id: chatId,
232
+ message_id: messageId,
233
+ reaction: [{ type: "emoji", emoji }],
234
+ });
235
+ }
165
236
  }
166
237
 
167
238
  /** Mask a secret token for safe display: first 4 chars + length, never the rest