jeo-code 0.7.33 → 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.
- package/CHANGELOG.md +52 -1
- package/README.ja.md +4 -4
- package/README.ko.md +4 -4
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/package.json +2 -1
- package/src/agent/AGENTS.md +5 -2
- package/src/agent/approve-tool.ts +42 -0
- package/src/agent/config-schema.ts +17 -0
- package/src/agent/notify/AGENTS.md +47 -0
- package/src/agent/notify/daemon-control.ts +158 -0
- package/src/agent/notify/paths.ts +41 -0
- package/src/agent/notify/session-endpoint.ts +196 -0
- package/src/agent/notify/telegram-api.ts +83 -0
- package/src/agent/notify/telegram-daemon.ts +358 -0
- package/src/agent/plan.ts +27 -1
- package/src/agent/state.ts +9 -0
- package/src/agent/task-tool.ts +174 -93
- package/src/agent/tools.ts +19 -42
- package/src/ai/providers/anthropic.ts +2 -1
- package/src/ai/providers/antigravity.ts +3 -2
- package/src/ai/providers/gemini.ts +3 -2
- package/src/ai/providers/ollama.ts +2 -1
- package/src/ai/providers/openai-responses.ts +3 -2
- package/src/ai/providers/openai.ts +2 -1
- package/src/cli/runner.ts +29 -0
- package/src/commands/AGENTS.md +6 -2
- package/src/commands/approve.ts +63 -54
- package/src/commands/daemon.ts +45 -0
- package/src/commands/launch.ts +54 -4
- package/src/commands/notify.ts +109 -0
- package/src/commands/ralplan.ts +11 -1
- package/src/commands/team.ts +266 -79
- package/src/util/sanitize-json.ts +42 -0
|
@@ -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
|
+
}
|
package/src/agent/plan.ts
CHANGED
|
@@ -13,6 +13,11 @@ export const StepSchema = z.object({
|
|
|
13
13
|
name: z.string(),
|
|
14
14
|
/** Optional subagent role for this step (executor/planner/architect/critic). */
|
|
15
15
|
role: z.string().optional(),
|
|
16
|
+
/** Optional opt-in marker for a CONTIGUOUS run of steps `jeo team` may execute
|
|
17
|
+
* concurrently (git-worktree isolated) instead of strictly serially. Steps
|
|
18
|
+
* sharing the same non-empty value must be mutually consecutive — validated
|
|
19
|
+
* on `PlanSchema` below, since contiguity is a plan-wide property. */
|
|
20
|
+
parallel_group: z.string().optional(),
|
|
16
21
|
}).passthrough().superRefine((step, ctx) => {
|
|
17
22
|
for (const key of UNSUPPORTED_DEP_KEYS) {
|
|
18
23
|
if (key in step) {
|
|
@@ -27,7 +32,28 @@ export const StepSchema = z.object({
|
|
|
27
32
|
export const PlanSchema = z.object({
|
|
28
33
|
name: z.string().optional(),
|
|
29
34
|
steps: z.array(StepSchema).min(1),
|
|
30
|
-
}).passthrough()
|
|
35
|
+
}).passthrough().superRefine((plan, ctx) => {
|
|
36
|
+
// `parallel_group` steps must form a CONTIGUOUS run: once a group value is
|
|
37
|
+
// seen and then interrupted by a different (or absent) group, that value must
|
|
38
|
+
// never reappear later — `jeo team` dispatches a group as one concurrent batch
|
|
39
|
+
// located at a single position in the array, so a split group has no coherent
|
|
40
|
+
// execution position.
|
|
41
|
+
const closedGroups = new Set<string>();
|
|
42
|
+
let prevGroup: string | undefined;
|
|
43
|
+
for (const step of plan.steps) {
|
|
44
|
+
const group = step.parallel_group?.trim() || undefined;
|
|
45
|
+
if (group && group !== prevGroup && closedGroups.has(group)) {
|
|
46
|
+
ctx.addIssue({
|
|
47
|
+
code: z.ZodIssueCode.custom,
|
|
48
|
+
message: `plan step "${step.name}" reuses parallel_group "${group}" after a non-"${group}" step interrupted it — parallel_group steps must be contiguous.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (prevGroup !== undefined && prevGroup !== group) {
|
|
52
|
+
closedGroups.add(prevGroup);
|
|
53
|
+
}
|
|
54
|
+
prevGroup = group;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
31
57
|
|
|
32
58
|
/**
|
|
33
59
|
* Tolerate common planning-model deviations so a valid-enough plan still executes:
|
package/src/agent/state.ts
CHANGED
|
@@ -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 };
|