jeo-code 0.7.35 → 0.7.38

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.
@@ -0,0 +1,190 @@
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:
6
+ *
7
+ * - binds a random free port on 127.0.0.1 only (never exposed off-box; the
8
+ * ONLY internet-facing hop is the separate `notify-daemon-run` process
9
+ * 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.
17
+ *
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).
22
+ */
23
+ import * as fs from "node:fs/promises";
24
+ import type { Server, ServerWebSocket } from "bun";
25
+ import { readGlobalConfig } from "../state";
26
+ import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
27
+ import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
28
+
29
+ const SNAPSHOT_POLL_MS = 1_200;
30
+
31
+ export class SessionNotifyEndpoint {
32
+ private server: Server<undefined> | undefined;
33
+
34
+ private readonly sockets = new Set<ServerWebSocket<undefined>>();
35
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
36
+ private lastSnapshotJson = "";
37
+ readonly sessionId: string;
38
+ private readonly token: string;
39
+
40
+ constructor(
41
+ private readonly registry: SubagentRegistry,
42
+ private readonly cwd: string,
43
+ ) {
44
+ this.sessionId = crypto.randomUUID();
45
+ this.token = crypto.randomUUID();
46
+ }
47
+
48
+ 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() };
50
+ }
51
+
52
+ private broadcastIfChanged(): void {
53
+ if (this.sockets.size === 0) return;
54
+ const snap = this.snapshot();
55
+ const json = JSON.stringify(snap);
56
+ if (json === this.lastSnapshotJson) return;
57
+ this.lastSnapshotJson = json;
58
+ for (const ws of this.sockets) {
59
+ try {
60
+ ws.send(json);
61
+ } catch {}
62
+ }
63
+ }
64
+
65
+ private handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): void {
66
+ let msg: Record<string, unknown>;
67
+ try {
68
+ msg = JSON.parse(String(raw));
69
+ } catch {
70
+ return;
71
+ }
72
+ if (msg.type === "list") {
73
+ try {
74
+ ws.send(JSON.stringify(this.snapshot()));
75
+ } catch {}
76
+ return;
77
+ }
78
+ if (msg.type === "steer") {
79
+ const id = String(msg.id ?? "");
80
+ const message = String(msg.message ?? "");
81
+ const ok = id && message ? this.registry.steer(id, message) : false;
82
+ try {
83
+ ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
84
+ } catch {}
85
+ return;
86
+ }
87
+ if (msg.type === "cancel") {
88
+ 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) : [];
90
+ try {
91
+ ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
92
+ ws.send(JSON.stringify(this.snapshot()));
93
+ } catch {}
94
+ return;
95
+ }
96
+ }
97
+
98
+ /** Bind the server, start the change-poll, and publish the discovery file.
99
+ * Idempotent no-op if already started. */
100
+ async start(): Promise<void> {
101
+ if (this.server) return;
102
+ this.server = Bun.serve<undefined>({
103
+ hostname: "127.0.0.1",
104
+ port: 0,
105
+ fetch: (req, server) => {
106
+ const url = new URL(req.url);
107
+ if (url.searchParams.get("token") !== this.token) {
108
+ return new Response("unauthorized", { status: 401 });
109
+ }
110
+ if (server.upgrade(req)) return undefined;
111
+ return new Response("upgrade failed", { status: 500 });
112
+ },
113
+ websocket: {
114
+ open: ws => {
115
+ this.sockets.add(ws);
116
+ try {
117
+ ws.send(JSON.stringify(this.snapshot()));
118
+ } catch {}
119
+ },
120
+ message: (ws, raw) => this.handleMessage(ws, raw),
121
+ close: ws => {
122
+ this.sockets.delete(ws);
123
+ },
124
+ },
125
+ });
126
+ this.pollTimer = setInterval(() => this.broadcastIfChanged(), SNAPSHOT_POLL_MS);
127
+ await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
128
+ const payload = {
129
+ url: `ws://127.0.0.1:${this.server.port}`,
130
+ token: this.token,
131
+ pid: process.pid,
132
+ cwd: this.cwd,
133
+ startedAt: Date.now(),
134
+ };
135
+ await fs.writeFile(notifySessionEndpointPath(this.sessionId), JSON.stringify(payload), { mode: 0o600 });
136
+ }
137
+
138
+ async stop(): Promise<void> {
139
+ if (this.pollTimer) {
140
+ clearInterval(this.pollTimer);
141
+ this.pollTimer = undefined;
142
+ }
143
+ for (const ws of this.sockets) {
144
+ try {
145
+ ws.close();
146
+ } catch {}
147
+ }
148
+ this.sockets.clear();
149
+ if (this.server) {
150
+ this.server.stop(true);
151
+ this.server = undefined;
152
+ }
153
+ await fs.unlink(notifySessionEndpointPath(this.sessionId)).catch(() => {});
154
+ }
155
+ }
156
+
157
+ // ── Lazy per-registry singleton wiring (task-tool.ts detached path) ────────────
158
+ const endpointsByRegistry = new WeakMap<SubagentRegistry, SessionNotifyEndpoint>();
159
+ const startingRegistries = new WeakSet<SubagentRegistry>();
160
+
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 {
166
+ if (endpointsByRegistry.has(registry) || startingRegistries.has(registry)) return;
167
+ startingRegistries.add(registry);
168
+ void (async () => {
169
+ try {
170
+ const config = await readGlobalConfig();
171
+ if (!config.notifications?.enabled) return;
172
+ const endpoint = new SessionNotifyEndpoint(registry, cwd);
173
+ await endpoint.start();
174
+ endpointsByRegistry.set(registry, endpoint);
175
+ } catch {
176
+ // best-effort: a bind/config failure must never break the parent turn.
177
+ } finally {
178
+ startingRegistries.delete(registry);
179
+ }
180
+ })();
181
+ }
182
+
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). */
185
+ export async function stopSessionNotifyEndpoint(registry: SubagentRegistry): Promise<void> {
186
+ const endpoint = endpointsByRegistry.get(registry);
187
+ if (!endpoint) return;
188
+ endpointsByRegistry.delete(registry);
189
+ await endpoint.stop();
190
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Minimal Telegram Bot HTTP API client (gjc `telegram-reference.ts`/`telegram-cli.ts`
3
+ * parity, scoped to the three calls the jeo daemon actually needs: identity
4
+ * verification, outbound push, and inbound long-poll). Deliberately thin — no forum
5
+ * topics, no inline keyboards, no rate-limit pool; jeo's daemon sends plain text.
6
+ */
7
+
8
+ export interface TelegramChat {
9
+ id: number;
10
+ type: string;
11
+ title?: string;
12
+ username?: string;
13
+ }
14
+
15
+ export interface TelegramUpdate {
16
+ update_id: number;
17
+ message?: {
18
+ message_id: number;
19
+ date: number;
20
+ chat: TelegramChat;
21
+ text?: string;
22
+ from?: { id: number; username?: string; is_bot: boolean };
23
+ };
24
+ }
25
+
26
+ export interface TelegramGetMeResult {
27
+ ok: boolean;
28
+ result?: { id: number; is_bot: boolean; username: string };
29
+ description?: string;
30
+ }
31
+
32
+ export interface TelegramSendResult {
33
+ ok: boolean;
34
+ description?: string;
35
+ }
36
+
37
+ export interface TelegramGetUpdatesResult {
38
+ ok: boolean;
39
+ result: TelegramUpdate[];
40
+ description?: string;
41
+ }
42
+
43
+ export type FetchLike = typeof fetch;
44
+
45
+ export class TelegramApi {
46
+ constructor(
47
+ private readonly token: string,
48
+ private readonly fetchImpl: FetchLike = fetch,
49
+ ) {}
50
+
51
+ private url(method: string): string {
52
+ return `https://api.telegram.org/bot${this.token}/${method}`;
53
+ }
54
+
55
+ async getMe(): Promise<TelegramGetMeResult> {
56
+ const res = await this.fetchImpl(this.url("getMe"));
57
+ return (await res.json()) as TelegramGetMeResult;
58
+ }
59
+
60
+ async sendMessage(chatId: string | number, text: string): Promise<TelegramSendResult> {
61
+ const res = await this.fetchImpl(this.url("sendMessage"), {
62
+ method: "POST",
63
+ headers: { "content-type": "application/json" },
64
+ body: JSON.stringify({ chat_id: chatId, text, disable_web_page_preview: true }),
65
+ });
66
+ return (await res.json()) as TelegramSendResult;
67
+ }
68
+
69
+ /** Long-poll for new updates. `offset` should be `last_update_id + 1`. */
70
+ async getUpdates(offset?: number, timeoutSec = 25): Promise<TelegramGetUpdatesResult> {
71
+ const params = new URLSearchParams({ timeout: String(timeoutSec) });
72
+ if (offset !== undefined) params.set("offset", String(offset));
73
+ const res = await this.fetchImpl(`${this.url("getUpdates")}?${params.toString()}`);
74
+ return (await res.json()) as TelegramGetUpdatesResult;
75
+ }
76
+ }
77
+
78
+ /** Mask a secret token for safe display: first 4 chars + length, never the rest
79
+ * (gjc `gjc notify status` masking parity — safe to paste into a support thread). */
80
+ export function maskToken(token: string): string {
81
+ if (!token) return "";
82
+ return `${token.slice(0, 4)}…(len ${token.length})`;
83
+ }
@@ -0,0 +1,367 @@
1
+ /**
2
+ * The managed Telegram daemon (gjc `notifications/telegram-daemon.ts` parity,
3
+ * scoped down to jeo's subagent-remote-control surface — no forum topics, no
4
+ * inline keyboards, no image attachments; see CHANGELOG 0.7.34 for what jeo
5
+ * intentionally does not replicate from gjc's full notification stack).
6
+ *
7
+ * Runs as ONE long-lived background process (singleton, enforced by
8
+ * `daemon-control.ts`'s lock file — Telegram allows only one `getUpdates`
9
+ * long-poll owner per bot token). It:
10
+ *
11
+ * - scans `<jeoHome>/notifications/sessions/*.json` for live session
12
+ * discovery files and connects a loopback WebSocket to each
13
+ * (`SessionNotifyEndpoint` on the other end);
14
+ * - sends a Telegram message on each subagent state EDGE (started → a
15
+ * terminal state), not on every poll tick;
16
+ * - long-polls Telegram `getUpdates` and dispatches `/subagents`,
17
+ * `/steer <sessionId> <subagentId> <message>`, `/cancel <sessionId>
18
+ * <subagentId>`, `/help` back into the matching session's WebSocket.
19
+ */
20
+ import * as fs from "node:fs/promises";
21
+ import * as path from "node:path";
22
+ import { readGlobalConfig } from "../state";
23
+ import { notifySessionsDir } from "./paths";
24
+ import { acquireDaemonLock, isPidAlive } from "./daemon-control";
25
+ import { TelegramApi, type TelegramUpdate } from "./telegram-api";
26
+ import type { SubagentRecord } from "../subagent-registry";
27
+
28
+ function sleep(ms: number): Promise<void> {
29
+ const { promise, resolve } = Promise.withResolvers<void>();
30
+ setTimeout(resolve, ms);
31
+ return promise;
32
+ }
33
+
34
+ // ── Pure helpers (unit-testable without network/ws) ────────────────────────────
35
+
36
+ export type NotifyEventKind = "started" | "completed" | "failed" | "cancelled";
37
+
38
+ export interface NotifyEvent {
39
+ kind: NotifyEventKind;
40
+ record: SubagentRecord;
41
+ }
42
+
43
+ /** Edge-triggered diff: only reports a transition INTO running (started) or OUT
44
+ * of running into a terminal state — never re-reports a steady "still running"
45
+ * or "still completed" on every poll tick. */
46
+ export function diffSubagentTransitions(prev: SubagentRecord[], next: SubagentRecord[]): NotifyEvent[] {
47
+ const prevById = new Map(prev.map(r => [r.id, r]));
48
+ const events: NotifyEvent[] = [];
49
+ for (const rec of next) {
50
+ const before = prevById.get(rec.id);
51
+ if (!before) {
52
+ if (rec.status === "running") events.push({ kind: "started", record: rec });
53
+ continue;
54
+ }
55
+ if (before.status === "running" && rec.status !== "running") {
56
+ events.push({ kind: rec.status as NotifyEventKind, record: rec });
57
+ }
58
+ }
59
+ return events;
60
+ }
61
+
62
+ /** One icon per subagent status; a "started" event renders as the running icon. */
63
+ const STATUS_ICON: Record<SubagentRecord["status"], string> = { running: "▶", completed: "✅", failed: "❌", cancelled: "⏹" };
64
+
65
+ function projectName(cwd: string): string {
66
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd;
67
+ }
68
+
69
+ /** Short, human-typeable session id for Telegram commands: first 8 hex chars
70
+ * (dashes stripped) of the session's uuid. Matched by prefix at dispatch time. */
71
+ export function shortSessionId(sessionId: string): string {
72
+ return sessionId.replace(/-/g, "").slice(0, 8);
73
+ }
74
+
75
+ export function formatNotifyEvent(shortId: string, cwd: string, ev: NotifyEvent): string {
76
+ const header = `${STATUS_ICON[ev.kind === "started" ? "running" : ev.kind]} [${shortId}] ${projectName(cwd)} · ${ev.record.role} '${ev.record.id}' ${ev.kind}`;
77
+ if (ev.kind === "started") return `${header}\n${ev.record.task}`;
78
+ const resultPreview = (ev.record.result ?? "").slice(0, 500);
79
+ return resultPreview ? `${header}\n${resultPreview}` : header;
80
+ }
81
+
82
+ export function formatSubagentsList(sessions: Array<{ sessionId: string; cwd: string; records: SubagentRecord[] }>): string {
83
+ const withRecords = sessions.filter(s => s.records.length > 0);
84
+ if (withRecords.length === 0) return "No subagents running.";
85
+ const lines: string[] = [];
86
+ for (const s of withRecords) {
87
+ lines.push(`— ${shortSessionId(s.sessionId)} (${projectName(s.cwd)})`);
88
+ for (const r of s.records) {
89
+ lines.push(` ${STATUS_ICON[r.status]} ${r.role} '${r.id}': ${r.task}`);
90
+ }
91
+ }
92
+ return lines.join("\n");
93
+ }
94
+
95
+ export function parseSteerCommand(text: string): { shortId: string; subagentId: string; message: string } | undefined {
96
+ const m = /^\/steer\s+(\S+)\s+(\S+)\s+([\s\S]+)$/.exec(text.trim());
97
+ if (!m) return undefined;
98
+ return { shortId: m[1]!, subagentId: m[2]!, message: m[3]!.trim() };
99
+ }
100
+
101
+ export function parseCancelCommand(text: string): { shortId: string; subagentId: string } | undefined {
102
+ const m = /^\/cancel\s+(\S+)\s+(\S+)\s*$/.exec(text.trim());
103
+ if (!m) return undefined;
104
+ return { shortId: m[1]!, subagentId: m[2]! };
105
+ }
106
+
107
+ export const HELP_TEXT =
108
+ "jeo notify daemon commands:\n" +
109
+ "/subagents — list running/recent subagents across every connected session\n" +
110
+ "/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
111
+ "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
112
+ "/help — show this message";
113
+
114
+ // ── Stateful daemon (network/ws — DI'd for tests) ───────────────────────────────
115
+
116
+ interface SessionConnection {
117
+ sessionId: string;
118
+ cwd: string;
119
+ pid: number;
120
+ ws: WebSocket;
121
+ lastRecords: SubagentRecord[];
122
+ }
123
+
124
+ export interface TelegramDaemonOptions {
125
+ chatId: string;
126
+ telegram: TelegramApi;
127
+ WebSocketImpl?: typeof WebSocket;
128
+ readdir?: (dir: string) => Promise<string[]>;
129
+ readFile?: (p: string) => Promise<string>;
130
+ unlink?: (p: string) => Promise<void>;
131
+ isPidAlive?: (pid: number) => boolean;
132
+ scanIntervalMs?: number;
133
+ ackTimeoutMs?: number;
134
+ }
135
+
136
+ export class TelegramDaemon {
137
+ private readonly WS: typeof WebSocket;
138
+ readonly sessions = new Map<string, SessionConnection>();
139
+ private readonly pendingAcks = new Map<string, (ok: boolean) => void>();
140
+ private updateOffset: number | undefined;
141
+ private stopped = false;
142
+ private scanTimer: ReturnType<typeof setInterval> | undefined;
143
+
144
+ constructor(private readonly opts: TelegramDaemonOptions) {
145
+ this.WS = opts.WebSocketImpl ?? WebSocket;
146
+ }
147
+
148
+ private async notify(text: string): Promise<void> {
149
+ try {
150
+ await this.opts.telegram.sendMessage(this.opts.chatId, text);
151
+ } catch {
152
+ // best-effort — a failed push must never crash the daemon loop.
153
+ }
154
+ }
155
+
156
+ async scanSessions(): Promise<void> {
157
+ const readdir = this.opts.readdir ?? (d => fs.readdir(d));
158
+ let files: string[];
159
+ try {
160
+ files = await readdir(notifySessionsDir());
161
+ } catch {
162
+ return;
163
+ }
164
+ const readFile = this.opts.readFile ?? (p => fs.readFile(p, "utf-8"));
165
+ const unlink = this.opts.unlink ?? (p => fs.unlink(p));
166
+ const pidAlive = this.opts.isPidAlive ?? isPidAlive;
167
+ for (const file of files.filter(f => f.endsWith(".json"))) {
168
+ const sessionId = path.basename(file, ".json");
169
+ if (this.sessions.has(sessionId)) continue;
170
+ const filePath = path.join(notifySessionsDir(), file);
171
+ try {
172
+ const raw = await readFile(filePath);
173
+ const endpoint = JSON.parse(raw) as { url: string; token: string; pid: number; cwd: string };
174
+ if (!pidAlive(endpoint.pid)) {
175
+ await unlink(filePath).catch(() => {});
176
+ continue;
177
+ }
178
+ this.connectSession(sessionId, endpoint);
179
+ } catch {
180
+ continue;
181
+ }
182
+ }
183
+ }
184
+
185
+ private connectSession(sessionId: string, endpoint: { url: string; token: string; pid: number; cwd: string }): void {
186
+ const ws = new this.WS(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`);
187
+ const conn: SessionConnection = { sessionId, cwd: endpoint.cwd, pid: endpoint.pid, ws, lastRecords: [] };
188
+ this.sessions.set(sessionId, conn);
189
+ ws.addEventListener("message", (ev: MessageEvent) => {
190
+ void this.handleSessionMessage(conn, String(ev.data));
191
+ });
192
+ ws.addEventListener("close", () => {
193
+ if (this.sessions.get(sessionId) === conn) this.sessions.delete(sessionId);
194
+ });
195
+ }
196
+
197
+ async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
198
+ let msg: Record<string, unknown>;
199
+ try {
200
+ msg = JSON.parse(raw);
201
+ } catch {
202
+ return;
203
+ }
204
+ if (msg.type === "snapshot" && Array.isArray(msg.subagents)) {
205
+ const next = msg.subagents as SubagentRecord[];
206
+ const events = diffSubagentTransitions(conn.lastRecords, next);
207
+ conn.lastRecords = next;
208
+ for (const ev of events) {
209
+ await this.notify(formatNotifyEvent(shortSessionId(conn.sessionId), conn.cwd, ev));
210
+ }
211
+ return;
212
+ }
213
+ if (msg.type === "ack" && typeof msg.reqId === "string") {
214
+ const resolve = this.pendingAcks.get(msg.reqId);
215
+ if (resolve) {
216
+ resolve(Boolean(msg.ok));
217
+ this.pendingAcks.delete(msg.reqId);
218
+ }
219
+ }
220
+ }
221
+
222
+ private findSession(shortId: string): SessionConnection | undefined {
223
+ const needle = shortId.toLowerCase();
224
+ for (const conn of this.sessions.values()) {
225
+ if (shortSessionId(conn.sessionId) === needle || conn.sessionId.toLowerCase().startsWith(needle)) return conn;
226
+ }
227
+ return undefined;
228
+ }
229
+
230
+ private sendRequest(conn: SessionConnection, frame: Record<string, unknown>): Promise<boolean> {
231
+ const reqId = crypto.randomUUID();
232
+ const { promise, resolve } = Promise.withResolvers<boolean>();
233
+ this.pendingAcks.set(reqId, resolve);
234
+ setTimeout(() => {
235
+ if (this.pendingAcks.has(reqId)) {
236
+ this.pendingAcks.delete(reqId);
237
+ resolve(false);
238
+ }
239
+ }, this.opts.ackTimeoutMs ?? 3_000);
240
+ try {
241
+ conn.ws.send(JSON.stringify({ ...frame, reqId }));
242
+ } catch {
243
+ this.pendingAcks.delete(reqId);
244
+ resolve(false);
245
+ }
246
+ return promise;
247
+ }
248
+
249
+ async handleInboundText(text: string): Promise<void> {
250
+ const trimmed = text.trim();
251
+ if (trimmed === "/subagents") {
252
+ const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
253
+ await this.notify(formatSubagentsList(sessions));
254
+ return;
255
+ }
256
+ if (trimmed === "/help" || trimmed === "/start") {
257
+ await this.notify(HELP_TEXT);
258
+ return;
259
+ }
260
+ const steer = parseSteerCommand(trimmed);
261
+ if (steer) {
262
+ const conn = this.findSession(steer.shortId);
263
+ if (!conn) {
264
+ await this.notify(`No connected session matches '${steer.shortId}'. Send /subagents to see live sessions.`);
265
+ return;
266
+ }
267
+ const ok = await this.sendRequest(conn, { type: "steer", id: steer.subagentId, message: steer.message });
268
+ await this.notify(ok ? `Steered '${steer.subagentId}'.` : `Steer failed — '${steer.subagentId}' is unknown or not running.`);
269
+ return;
270
+ }
271
+ const cancel = parseCancelCommand(trimmed);
272
+ if (cancel) {
273
+ const conn = this.findSession(cancel.shortId);
274
+ if (!conn) {
275
+ await this.notify(`No connected session matches '${cancel.shortId}'. Send /subagents to see live sessions.`);
276
+ return;
277
+ }
278
+ const ok = await this.sendRequest(conn, { type: "cancel", ids: [cancel.subagentId] });
279
+ await this.notify(ok ? `Cancelled '${cancel.subagentId}'.` : `Cancel failed — '${cancel.subagentId}' is unknown or already finished.`);
280
+ return;
281
+ }
282
+ if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
283
+ }
284
+
285
+ /** Trust boundary: a bot's username is publicly discoverable, so ANY Telegram
286
+ * user can message it. Only the paired chat (the one that ran
287
+ * `jeo notify setup`) may steer/cancel subagents; everything else is dropped
288
+ * without a reply (replying would leak that the bot is live). */
289
+ async handleUpdate(update: TelegramUpdate): Promise<void> {
290
+ const msg = update.message;
291
+ if (!msg?.text) return;
292
+ if (String(msg.chat.id) !== this.opts.chatId) return;
293
+ await this.handleInboundText(msg.text);
294
+ }
295
+
296
+ private async pollTelegramLoop(): Promise<void> {
297
+ while (!this.stopped) {
298
+ let res;
299
+ try {
300
+ res = await this.opts.telegram.getUpdates(this.updateOffset, 25);
301
+ } catch {
302
+ await sleep(2_000);
303
+ continue;
304
+ }
305
+ if (!res.ok) {
306
+ // e.g. 409 Conflict (another getUpdates owner) or 401 (revoked token) —
307
+ // without a pause this would hot-loop against the API.
308
+ await sleep(2_000);
309
+ continue;
310
+ }
311
+ for (const update of res.result) {
312
+ this.updateOffset = update.update_id + 1;
313
+ await this.handleUpdate(update);
314
+ }
315
+ }
316
+ }
317
+
318
+ /** Blocks (long-poll loop) until `stop()` is called. */
319
+ async start(): Promise<void> {
320
+ await this.scanSessions();
321
+ this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
322
+ await this.pollTelegramLoop();
323
+ }
324
+
325
+ stop(): void {
326
+ this.stopped = true;
327
+ if (this.scanTimer) clearInterval(this.scanTimer);
328
+ for (const conn of this.sessions.values()) {
329
+ try {
330
+ conn.ws.close();
331
+ } catch {}
332
+ }
333
+ this.sessions.clear();
334
+ }
335
+ }
336
+
337
+ // ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
338
+
339
+ export async function runNotifyDaemonForeground(): Promise<void> {
340
+ const lock = await acquireDaemonLock();
341
+ if (!lock) {
342
+ process.stderr.write("[jeo notify-daemon] another daemon instance already holds the lock; exiting.\n");
343
+ return;
344
+ }
345
+ try {
346
+ const config = await readGlobalConfig();
347
+ const botToken = config.notifications?.telegram?.botToken;
348
+ const chatId = config.notifications?.telegram?.chatId;
349
+ if (!config.notifications?.enabled || !botToken || !chatId) {
350
+ process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
351
+ return;
352
+ }
353
+ const daemon = new TelegramDaemon({ chatId, telegram: new TelegramApi(botToken) });
354
+ let shuttingDown = false;
355
+ const shutdown = () => {
356
+ if (shuttingDown) return;
357
+ shuttingDown = true;
358
+ daemon.stop();
359
+ process.exit(0);
360
+ };
361
+ process.on("SIGTERM", shutdown);
362
+ process.on("SIGINT", shutdown);
363
+ await daemon.start();
364
+ } finally {
365
+ await lock.release();
366
+ }
367
+ }
@@ -58,6 +58,15 @@ export interface Config {
58
58
  onComplete?: boolean;
59
59
  onAsk?: boolean;
60
60
  };
61
+ /** Remote subagent visibility/control over Telegram (see `src/agent/notify/`). */
62
+ notifications?: {
63
+ enabled?: boolean;
64
+ telegram?: {
65
+ botToken?: string;
66
+ chatId?: string;
67
+ };
68
+ };
69
+
61
70
  thinkingLevel?: "minimal" | "low" | "medium" | "high" | "xhigh";
62
71
  /** Friendly model aliases, e.g. { fast: "ollama/qwen2.5:0.5b" }. Override built-ins. */
63
72
  modelAliases?: { [alias: string]: string };
@@ -29,6 +29,8 @@ import {
29
29
  } from "./subagents";
30
30
  import { resolveMaxOutputTokens } from "../ai/model-manager";
31
31
  import type { SubagentRegistry } from "./subagent-registry";
32
+ import { ensureSessionNotifyEndpoint } from "./notify/session-endpoint";
33
+
32
34
 
33
35
  /** Lifecycle event emitted while a delegated subagent runs. */
34
36
  export interface TaskSubEvent {
@@ -473,6 +475,10 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
473
475
  const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
474
476
  runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id) }),
475
477
  );
478
+ // Remote subagent visibility/control over Telegram (gjc daemon parity, see
479
+ // `src/agent/notify/`): best-effort, no-op unless `notifications.enabled`.
480
+ ensureSessionNotifyEndpoint(opts.registry, cwd);
481
+
476
482
  return {
477
483
  success: true,
478
484
  output: