jeo-code 0.7.35 → 0.7.37

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,196 @@
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
+ interface WsData {
32
+ authed: true;
33
+ }
34
+
35
+ export class SessionNotifyEndpoint {
36
+ private server: Server<WsData> | undefined;
37
+
38
+ private readonly sockets = new Set<ServerWebSocket<WsData>>();
39
+ private pollTimer: ReturnType<typeof setInterval> | undefined;
40
+ private lastSnapshotJson = "";
41
+ readonly sessionId: string;
42
+ private readonly token: string;
43
+
44
+ constructor(
45
+ private readonly registry: SubagentRegistry,
46
+ private readonly cwd: string,
47
+ ) {
48
+ this.sessionId = crypto.randomUUID();
49
+ this.token = crypto.randomUUID();
50
+ }
51
+
52
+ private snapshot(): { type: "snapshot"; sessionId: string; cwd: string; pid: number; subagents: SubagentRecord[] } {
53
+ return { type: "snapshot", sessionId: this.sessionId, cwd: this.cwd, pid: process.pid, subagents: this.registry.list() };
54
+ }
55
+
56
+ private broadcastIfChanged(): void {
57
+ if (this.sockets.size === 0) return;
58
+ const snap = this.snapshot();
59
+ const json = JSON.stringify(snap);
60
+ if (json === this.lastSnapshotJson) return;
61
+ this.lastSnapshotJson = json;
62
+ for (const ws of this.sockets) {
63
+ try {
64
+ ws.send(json);
65
+ } catch {}
66
+ }
67
+ }
68
+
69
+ private handleMessage(ws: ServerWebSocket<WsData>, raw: string | Buffer): void {
70
+ let msg: Record<string, unknown>;
71
+ try {
72
+ msg = JSON.parse(String(raw));
73
+ } catch {
74
+ return;
75
+ }
76
+ if (msg.type === "list") {
77
+ try {
78
+ ws.send(JSON.stringify(this.snapshot()));
79
+ } catch {}
80
+ return;
81
+ }
82
+ if (msg.type === "steer") {
83
+ const id = String(msg.id ?? "");
84
+ const message = String(msg.message ?? "");
85
+ const ok = id && message ? this.registry.steer(id, message) : false;
86
+ try {
87
+ ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
88
+ } catch {}
89
+ return;
90
+ }
91
+ if (msg.type === "cancel") {
92
+ const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === "string") : [];
93
+ const cancelled = ids.length > 0 ? this.registry.cancel(ids) : [];
94
+ try {
95
+ ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
96
+ ws.send(JSON.stringify(this.snapshot()));
97
+ } catch {}
98
+ return;
99
+ }
100
+ }
101
+
102
+ /** Bind the server, start the change-poll, and publish the discovery file.
103
+ * Idempotent no-op if already started. */
104
+ async start(): Promise<void> {
105
+ if (this.server) return;
106
+ const self = this;
107
+ this.server = Bun.serve<WsData>({
108
+
109
+ hostname: "127.0.0.1",
110
+ port: 0,
111
+ fetch(req, server) {
112
+ const url = new URL(req.url);
113
+ if (url.searchParams.get("token") !== self.token) {
114
+ return new Response("unauthorized", { status: 401 });
115
+ }
116
+ if (server.upgrade(req, { data: { authed: true } })) return undefined;
117
+ return new Response("upgrade failed", { status: 500 });
118
+ },
119
+ websocket: {
120
+ open: ws => {
121
+ self.sockets.add(ws);
122
+ try {
123
+ ws.send(JSON.stringify(self.snapshot()));
124
+ } catch {}
125
+ },
126
+ message: (ws, raw) => self.handleMessage(ws, raw),
127
+ close: ws => {
128
+ self.sockets.delete(ws);
129
+ },
130
+ },
131
+ });
132
+ this.pollTimer = setInterval(() => this.broadcastIfChanged(), SNAPSHOT_POLL_MS);
133
+ await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
134
+ const payload = {
135
+ url: `ws://127.0.0.1:${this.server.port}`,
136
+ token: this.token,
137
+ pid: process.pid,
138
+ cwd: this.cwd,
139
+ startedAt: Date.now(),
140
+ };
141
+ await fs.writeFile(notifySessionEndpointPath(this.sessionId), JSON.stringify(payload), { mode: 0o600 });
142
+ }
143
+
144
+ async stop(): Promise<void> {
145
+ if (this.pollTimer) {
146
+ clearInterval(this.pollTimer);
147
+ this.pollTimer = undefined;
148
+ }
149
+ for (const ws of this.sockets) {
150
+ try {
151
+ ws.close();
152
+ } catch {}
153
+ }
154
+ this.sockets.clear();
155
+ if (this.server) {
156
+ this.server.stop(true);
157
+ this.server = undefined;
158
+ }
159
+ await fs.unlink(notifySessionEndpointPath(this.sessionId)).catch(() => {});
160
+ }
161
+ }
162
+
163
+ // ── Lazy per-registry singleton wiring (task-tool.ts detached path) ────────────
164
+ const endpointsByRegistry = new WeakMap<SubagentRegistry, SessionNotifyEndpoint>();
165
+ const startingRegistries = new WeakSet<SubagentRegistry>();
166
+
167
+ /** Best-effort, fire-and-forget: starts (once) a `SessionNotifyEndpoint` bound to
168
+ * `registry` IF `notifications.enabled` is set, otherwise a cheap no-op. Never
169
+ * throws and never blocks the caller — matches the `spawnDetachedDistill`
170
+ * "background best-effort, never slows the turn" convention. */
171
+ export function ensureSessionNotifyEndpoint(registry: SubagentRegistry, cwd: string): void {
172
+ if (endpointsByRegistry.has(registry) || startingRegistries.has(registry)) return;
173
+ startingRegistries.add(registry);
174
+ void (async () => {
175
+ try {
176
+ const config = await readGlobalConfig();
177
+ if (!config.notifications?.enabled) return;
178
+ const endpoint = new SessionNotifyEndpoint(registry, cwd);
179
+ await endpoint.start();
180
+ endpointsByRegistry.set(registry, endpoint);
181
+ } catch {
182
+ // best-effort: a bind/config failure must never break the parent turn.
183
+ } finally {
184
+ startingRegistries.delete(registry);
185
+ }
186
+ })();
187
+ }
188
+
189
+ /** Turn-boundary teardown (mirrors `registry.cancelAll()`); a no-op if no endpoint
190
+ * was ever started for this registry (the common case — notifications off). */
191
+ export async function stopSessionNotifyEndpoint(registry: SubagentRegistry): Promise<void> {
192
+ const endpoint = endpointsByRegistry.get(registry);
193
+ if (!endpoint) return;
194
+ endpointsByRegistry.delete(registry);
195
+ await endpoint.stop();
196
+ }
@@ -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,358 @@
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 } from "./telegram-api";
26
+ import type { SubagentRecord } from "../subagent-registry";
27
+
28
+ // ── Pure helpers (unit-testable without network/ws) ────────────────────────────
29
+
30
+ export type NotifyEventKind = "started" | "completed" | "failed" | "cancelled";
31
+
32
+ export interface NotifyEvent {
33
+ kind: NotifyEventKind;
34
+ record: SubagentRecord;
35
+ }
36
+
37
+ /** Edge-triggered diff: only reports a transition INTO running (started) or OUT
38
+ * of running into a terminal state — never re-reports a steady "still running"
39
+ * or "still completed" on every poll tick. */
40
+ export function diffSubagentTransitions(prev: SubagentRecord[], next: SubagentRecord[]): NotifyEvent[] {
41
+ const prevById = new Map(prev.map(r => [r.id, r]));
42
+ const events: NotifyEvent[] = [];
43
+ for (const rec of next) {
44
+ const before = prevById.get(rec.id);
45
+ if (!before) {
46
+ if (rec.status === "running") events.push({ kind: "started", record: rec });
47
+ continue;
48
+ }
49
+ if (before.status === "running" && rec.status !== "running") {
50
+ events.push({ kind: rec.status as NotifyEventKind, record: rec });
51
+ }
52
+ }
53
+ return events;
54
+ }
55
+
56
+ const NOTIFY_ICON: Record<NotifyEventKind, string> = { started: "▶", completed: "✅", failed: "❌", cancelled: "⏹" };
57
+
58
+ function projectName(cwd: string): string {
59
+ return cwd.split(/[\\/]/).filter(Boolean).pop() ?? cwd;
60
+ }
61
+
62
+ /** Short, human-typeable session id for Telegram commands: first 8 hex chars
63
+ * (dashes stripped) of the session's uuid. Matched by prefix at dispatch time. */
64
+ export function shortSessionId(sessionId: string): string {
65
+ return sessionId.replace(/-/g, "").slice(0, 8);
66
+ }
67
+
68
+ export function formatNotifyEvent(shortId: string, cwd: string, ev: NotifyEvent): string {
69
+ const header = `${NOTIFY_ICON[ev.kind]} [${shortId}] ${projectName(cwd)} · ${ev.record.role} '${ev.record.id}' ${ev.kind}`;
70
+ if (ev.kind === "started") return `${header}\n${ev.record.task}`;
71
+ const resultPreview = (ev.record.result ?? "").slice(0, 500);
72
+ return resultPreview ? `${header}\n${resultPreview}` : header;
73
+ }
74
+
75
+ export function formatSubagentsList(sessions: Array<{ sessionId: string; cwd: string; records: SubagentRecord[] }>): string {
76
+ const withRecords = sessions.filter(s => s.records.length > 0);
77
+ if (withRecords.length === 0) return "No subagents running.";
78
+ const lines: string[] = [];
79
+ for (const s of withRecords) {
80
+ lines.push(`— ${shortSessionId(s.sessionId)} (${projectName(s.cwd)})`);
81
+ for (const r of s.records) {
82
+ const icon = r.status === "running" ? "▶" : r.status === "completed" ? "✅" : r.status === "failed" ? "❌" : "⏹";
83
+ lines.push(` ${icon} ${r.role} '${r.id}': ${r.task}`);
84
+ }
85
+ }
86
+ return lines.join("\n");
87
+ }
88
+
89
+ export interface ParsedSteerCommand {
90
+ shortId: string;
91
+ subagentId: string;
92
+ message: string;
93
+ }
94
+ export function parseSteerCommand(text: string): ParsedSteerCommand | undefined {
95
+ const m = /^\/steer\s+(\S+)\s+(\S+)\s+([\s\S]+)$/.exec(text.trim());
96
+ if (!m) return undefined;
97
+ return { shortId: m[1]!, subagentId: m[2]!, message: m[3]!.trim() };
98
+ }
99
+
100
+ export interface ParsedCancelCommand {
101
+ shortId: string;
102
+ subagentId: string;
103
+ }
104
+ export function parseCancelCommand(text: string): ParsedCancelCommand | undefined {
105
+ const m = /^\/cancel\s+(\S+)\s+(\S+)\s*$/.exec(text.trim());
106
+ if (!m) return undefined;
107
+ return { shortId: m[1]!, subagentId: m[2]! };
108
+ }
109
+
110
+ export const HELP_TEXT =
111
+ "jeo notify daemon commands:\n" +
112
+ "/subagents — list running/recent subagents across every connected session\n" +
113
+ "/steer <sessionId> <subagentId> <message> — send a live message into a running subagent\n" +
114
+ "/cancel <sessionId> <subagentId> — cancel a running subagent\n" +
115
+ "/help — show this message";
116
+
117
+ // ── Stateful daemon (network/ws — DI'd for tests) ───────────────────────────────
118
+
119
+ interface SessionConnection {
120
+ sessionId: string;
121
+ cwd: string;
122
+ pid: number;
123
+ ws: WebSocket;
124
+ lastRecords: SubagentRecord[];
125
+ }
126
+
127
+ export interface TelegramDaemonOptions {
128
+ chatId: string;
129
+ telegram: TelegramApi;
130
+ WebSocketImpl?: typeof WebSocket;
131
+ readdir?: (dir: string) => Promise<string[]>;
132
+ readFile?: (p: string) => Promise<string>;
133
+ unlink?: (p: string) => Promise<void>;
134
+ isPidAlive?: (pid: number) => boolean;
135
+ scanIntervalMs?: number;
136
+ ackTimeoutMs?: number;
137
+ }
138
+
139
+ export class TelegramDaemon {
140
+ private readonly WS: typeof WebSocket;
141
+ readonly sessions = new Map<string, SessionConnection>();
142
+ private readonly pendingAcks = new Map<string, (ok: boolean) => void>();
143
+ private updateOffset: number | undefined;
144
+ private stopped = false;
145
+ private scanTimer: ReturnType<typeof setInterval> | undefined;
146
+
147
+ constructor(private readonly opts: TelegramDaemonOptions) {
148
+ this.WS = opts.WebSocketImpl ?? WebSocket;
149
+ }
150
+
151
+ private async notify(text: string): Promise<void> {
152
+ try {
153
+ await this.opts.telegram.sendMessage(this.opts.chatId, text);
154
+ } catch {
155
+ // best-effort — a failed push must never crash the daemon loop.
156
+ }
157
+ }
158
+
159
+ async scanSessions(): Promise<void> {
160
+ const readdir = this.opts.readdir ?? (d => fs.readdir(d));
161
+ let files: string[];
162
+ try {
163
+ files = await readdir(notifySessionsDir());
164
+ } catch {
165
+ return;
166
+ }
167
+ const readFile = this.opts.readFile ?? (p => fs.readFile(p, "utf-8"));
168
+ const unlink = this.opts.unlink ?? (p => fs.unlink(p));
169
+ const pidAlive = this.opts.isPidAlive ?? isPidAlive;
170
+ for (const file of files.filter(f => f.endsWith(".json"))) {
171
+ const sessionId = path.basename(file, ".json");
172
+ if (this.sessions.has(sessionId)) continue;
173
+ const filePath = path.join(notifySessionsDir(), file);
174
+ try {
175
+ const raw = await readFile(filePath);
176
+ const endpoint = JSON.parse(raw) as { url: string; token: string; pid: number; cwd: string };
177
+ if (!pidAlive(endpoint.pid)) {
178
+ await unlink(filePath).catch(() => {});
179
+ continue;
180
+ }
181
+ this.connectSession(sessionId, endpoint);
182
+ } catch {
183
+ continue;
184
+ }
185
+ }
186
+ }
187
+
188
+ private connectSession(sessionId: string, endpoint: { url: string; token: string; pid: number; cwd: string }): void {
189
+ const ws = new this.WS(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`);
190
+ const conn: SessionConnection = { sessionId, cwd: endpoint.cwd, pid: endpoint.pid, ws, lastRecords: [] };
191
+ this.sessions.set(sessionId, conn);
192
+ ws.addEventListener("message", (ev: MessageEvent) => {
193
+ void this.handleSessionMessage(conn, String(ev.data));
194
+ });
195
+ ws.addEventListener("close", () => {
196
+ if (this.sessions.get(sessionId) === conn) this.sessions.delete(sessionId);
197
+ });
198
+ }
199
+
200
+ async handleSessionMessage(conn: SessionConnection, raw: string): Promise<void> {
201
+ let msg: Record<string, unknown>;
202
+ try {
203
+ msg = JSON.parse(raw);
204
+ } catch {
205
+ return;
206
+ }
207
+ if (msg.type === "snapshot" && Array.isArray(msg.subagents)) {
208
+ const next = msg.subagents as SubagentRecord[];
209
+ const events = diffSubagentTransitions(conn.lastRecords, next);
210
+ conn.lastRecords = next;
211
+ for (const ev of events) {
212
+ await this.notify(formatNotifyEvent(shortSessionId(conn.sessionId), conn.cwd, ev));
213
+ }
214
+ return;
215
+ }
216
+ if (msg.type === "ack" && typeof msg.reqId === "string") {
217
+ const resolve = this.pendingAcks.get(msg.reqId);
218
+ if (resolve) {
219
+ resolve(Boolean(msg.ok));
220
+ this.pendingAcks.delete(msg.reqId);
221
+ }
222
+ }
223
+ }
224
+
225
+ private findSession(shortId: string): SessionConnection | undefined {
226
+ const needle = shortId.toLowerCase();
227
+ for (const conn of this.sessions.values()) {
228
+ if (shortSessionId(conn.sessionId) === needle || conn.sessionId.toLowerCase().startsWith(needle)) return conn;
229
+ }
230
+ return undefined;
231
+ }
232
+
233
+ private sendRequest(conn: SessionConnection, frame: Record<string, unknown>): Promise<boolean> {
234
+ const reqId = crypto.randomUUID();
235
+ const timeoutMs = this.opts.ackTimeoutMs ?? 3_000;
236
+ const p = new Promise<boolean>(resolve => {
237
+ this.pendingAcks.set(reqId, resolve);
238
+ setTimeout(() => {
239
+ if (this.pendingAcks.has(reqId)) {
240
+ this.pendingAcks.delete(reqId);
241
+ resolve(false);
242
+ }
243
+ }, timeoutMs);
244
+ });
245
+ try {
246
+ conn.ws.send(JSON.stringify({ ...frame, reqId }));
247
+ } catch {
248
+ this.pendingAcks.delete(reqId);
249
+ return Promise.resolve(false);
250
+ }
251
+ return p;
252
+ }
253
+
254
+ async handleInboundText(text: string): Promise<void> {
255
+ const trimmed = text.trim();
256
+ if (trimmed === "/subagents") {
257
+ const sessions = [...this.sessions.values()].map(c => ({ sessionId: c.sessionId, cwd: c.cwd, records: c.lastRecords }));
258
+ await this.notify(formatSubagentsList(sessions));
259
+ return;
260
+ }
261
+ if (trimmed === "/help" || trimmed === "/start") {
262
+ await this.notify(HELP_TEXT);
263
+ return;
264
+ }
265
+ const steer = parseSteerCommand(trimmed);
266
+ if (steer) {
267
+ const conn = this.findSession(steer.shortId);
268
+ if (!conn) {
269
+ await this.notify(`No connected session matches '${steer.shortId}'. Send /subagents to see live sessions.`);
270
+ return;
271
+ }
272
+ const ok = await this.sendRequest(conn, { type: "steer", id: steer.subagentId, message: steer.message });
273
+ await this.notify(ok ? `Steered '${steer.subagentId}'.` : `Steer failed — '${steer.subagentId}' is unknown or not running.`);
274
+ return;
275
+ }
276
+ const cancel = parseCancelCommand(trimmed);
277
+ if (cancel) {
278
+ const conn = this.findSession(cancel.shortId);
279
+ if (!conn) {
280
+ await this.notify(`No connected session matches '${cancel.shortId}'. Send /subagents to see live sessions.`);
281
+ return;
282
+ }
283
+ const ok = await this.sendRequest(conn, { type: "cancel", ids: [cancel.subagentId] });
284
+ await this.notify(ok ? `Cancelled '${cancel.subagentId}'.` : `Cancel failed — '${cancel.subagentId}' is unknown or already finished.`);
285
+ return;
286
+ }
287
+ if (trimmed.startsWith("/")) await this.notify(`Unrecognized command. ${HELP_TEXT}`);
288
+ }
289
+
290
+ private async pollTelegramLoop(): Promise<void> {
291
+ while (!this.stopped) {
292
+ let res;
293
+ try {
294
+ res = await this.opts.telegram.getUpdates(this.updateOffset, 25);
295
+ } catch {
296
+ await new Promise(r => setTimeout(r, 2_000));
297
+ continue;
298
+ }
299
+ if (res.ok) {
300
+ for (const update of res.result) {
301
+ this.updateOffset = update.update_id + 1;
302
+ const text = update.message?.text;
303
+ if (text) await this.handleInboundText(text);
304
+ }
305
+ }
306
+ }
307
+ }
308
+
309
+ /** Blocks (long-poll loop) until `stop()` is called. */
310
+ async start(): Promise<void> {
311
+ await this.scanSessions();
312
+ this.scanTimer = setInterval(() => void this.scanSessions(), this.opts.scanIntervalMs ?? 3_000);
313
+ await this.pollTelegramLoop();
314
+ }
315
+
316
+ stop(): void {
317
+ this.stopped = true;
318
+ if (this.scanTimer) clearInterval(this.scanTimer);
319
+ for (const conn of this.sessions.values()) {
320
+ try {
321
+ conn.ws.close();
322
+ } catch {}
323
+ }
324
+ this.sessions.clear();
325
+ }
326
+ }
327
+
328
+ // ── CLI entrypoint (`jeo notify-daemon-run`, internal) ──────────────────────────
329
+
330
+ export async function runNotifyDaemonForeground(): Promise<void> {
331
+ const lock = await acquireDaemonLock();
332
+ if (!lock) {
333
+ process.stderr.write("[jeo notify-daemon] another daemon instance already holds the lock; exiting.\n");
334
+ return;
335
+ }
336
+ try {
337
+ const config = await readGlobalConfig();
338
+ const botToken = config.notifications?.telegram?.botToken;
339
+ const chatId = config.notifications?.telegram?.chatId;
340
+ if (!config.notifications?.enabled || !botToken || !chatId) {
341
+ process.stderr.write("[jeo notify-daemon] notifications not configured — run `jeo notify setup` first.\n");
342
+ return;
343
+ }
344
+ const daemon = new TelegramDaemon({ chatId, telegram: new TelegramApi(botToken) });
345
+ let shuttingDown = false;
346
+ const shutdown = () => {
347
+ if (shuttingDown) return;
348
+ shuttingDown = true;
349
+ daemon.stop();
350
+ process.exit(0);
351
+ };
352
+ process.on("SIGTERM", shutdown);
353
+ process.on("SIGINT", shutdown);
354
+ await daemon.start();
355
+ } finally {
356
+ await lock.release();
357
+ }
358
+ }
@@ -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: