jeo-code 0.11.2 → 0.11.4

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.
@@ -8,10 +8,10 @@
8
8
  * - subagent visibility/control (unchanged since 0.7.34): `snapshot` on
9
9
  * connect + on change, `steer`/`cancel`/`list` requests — applied to
10
10
  * whichever `SubagentRegistry` is currently ATTACHED (see below);
11
- * - main-session mirroring (new): `identity_header` (once, at start),
11
+ * - main-session mirroring: `identity_header` (cached and replayed on connect),
12
12
  * `context_update` (turn start/end), `turn_stream` (finalized reply) sent
13
13
  * OUT, and `user_message`/`config_command` received IN via callbacks the
14
- * caller registers.
14
+ * caller registers. `user_message` ACKs report callback acceptance.
15
15
  *
16
16
  * `SubagentRegistry` instances are themselves per-turn (a fresh one every
17
17
  * `runTurn` call, see launch.ts), so this endpoint does NOT own one at
@@ -47,9 +47,10 @@ import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
47
47
  import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
48
48
 
49
49
  const SNAPSHOT_POLL_MS = 1_200;
50
+ const MAX_INBOUND_BYTES = 64 * 1024;
50
51
 
51
- /** Session identity, pushed once as an `identity_header` frame right after
52
- * `start()` — lets the daemon name/rename the session's forum topic. */
52
+ /** Session identity, cached and replayed as an `identity_header` on connection
53
+ * so a late or reconnecting daemon can name the session's forum topic. */
53
54
  export interface SessionIdentity {
54
55
  repo: string;
55
56
  branch?: string;
@@ -80,11 +81,10 @@ export class SessionNotifyEndpoint {
80
81
  private registry: SubagentRegistry | undefined;
81
82
  readonly sessionId: string;
82
83
  private readonly token: string;
84
+ private identityFrame: string | undefined;
83
85
 
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;
86
+ /** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
87
+ onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
88
88
  onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
89
89
 
90
90
  constructor(
@@ -94,6 +94,7 @@ export class SessionNotifyEndpoint {
94
94
  * falls back to a random id for session-less contexts. */
95
95
  sessionId: string = crypto.randomUUID(),
96
96
  ) {
97
+ if (!/^[A-Za-z0-9_-]+$/.test(sessionId)) throw new Error("Invalid notification session ID");
97
98
  this.sessionId = sessionId;
98
99
  this.token = crypto.randomUUID();
99
100
  }
@@ -136,15 +137,10 @@ export class SessionNotifyEndpoint {
136
137
  }
137
138
  }
138
139
 
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). */
140
+ /** Cache identity so a late or reconnecting daemon receives it as well. */
146
141
  sendIdentity(identity: SessionIdentity): void {
147
- this.broadcast(JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity }));
142
+ this.identityFrame = JSON.stringify({ type: "identity_header", sessionId: this.sessionId, ...identity });
143
+ this.broadcast(this.identityFrame);
148
144
  }
149
145
 
150
146
  /** Push a turn-boundary summary (`phase: "turn_start" | "turn_end"`). */
@@ -157,13 +153,16 @@ export class SessionNotifyEndpoint {
157
153
  this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
158
154
  }
159
155
 
160
- private handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): void {
156
+ private async handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): Promise<void> {
157
+ if (Buffer.byteLength(raw) > MAX_INBOUND_BYTES) return;
161
158
  let msg: Record<string, unknown>;
162
159
  try {
163
160
  msg = JSON.parse(String(raw));
164
161
  } catch {
165
162
  return;
166
163
  }
164
+ if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
165
+ if (msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
167
166
  if (msg.type === "list") {
168
167
  try {
169
168
  ws.send(JSON.stringify(this.snapshot()));
@@ -171,9 +170,9 @@ export class SessionNotifyEndpoint {
171
170
  return;
172
171
  }
173
172
  if (msg.type === "steer") {
174
- const id = String(msg.id ?? "");
175
- const message = String(msg.message ?? "");
176
- const ok = id && message ? (this.registry?.steer(id, message) ?? false) : false;
173
+ const id = typeof msg.id === "string" ? msg.id : "";
174
+ const message = typeof msg.message === "string" ? msg.message : "";
175
+ const ok = !!(id && message && this.registry?.steer(id, message));
177
176
  try {
178
177
  ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
179
178
  } catch {}
@@ -188,9 +187,18 @@ export class SessionNotifyEndpoint {
188
187
  } catch {}
189
188
  return;
190
189
  }
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 });
190
+ if (msg.type === "user_message") {
191
+ let ok = false;
192
+ const validPaths = msg.imagePaths === undefined || (Array.isArray(msg.imagePaths) && msg.imagePaths.every(x => typeof x === "string"));
193
+ if (typeof msg.text === "string" && !msg.text.trimStart().startsWith("/") && validPaths &&
194
+ (msg.text.trim() || (msg.imagePaths as string[] | undefined)?.length) && this.onUserMessage) {
195
+ try {
196
+ ok = (await this.onUserMessage({ text: msg.text, imagePaths: msg.imagePaths as string[] | undefined })) !== false;
197
+ } catch {}
198
+ }
199
+ try {
200
+ ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
201
+ } catch {}
194
202
  return;
195
203
  }
196
204
  if (msg.type === "config_command") {
@@ -217,13 +225,15 @@ export class SessionNotifyEndpoint {
217
225
  return new Response("upgrade failed", { status: 500 });
218
226
  },
219
227
  websocket: {
228
+ maxPayloadLength: MAX_INBOUND_BYTES,
220
229
  open: ws => {
221
230
  this.sockets.add(ws);
222
231
  try {
223
232
  ws.send(JSON.stringify(this.snapshot()));
233
+ if (this.identityFrame) ws.send(this.identityFrame);
224
234
  } catch {}
225
235
  },
226
- message: (ws, raw) => this.handleMessage(ws, raw),
236
+ message: (ws, raw) => { void this.handleMessage(ws, raw); },
227
237
  close: ws => {
228
238
  this.sockets.delete(ws);
229
239
  },
@@ -246,6 +256,7 @@ export class SessionNotifyEndpoint {
246
256
  // active handles refusing to exit.
247
257
  (this.pollTimer as { unref?: () => void }).unref?.();
248
258
  await fs.mkdir(notifySessionsDir(), { recursive: true, mode: 0o700 });
259
+ await fs.chmod(notifySessionsDir(), 0o700);
249
260
  const payload = {
250
261
  url: `ws://127.0.0.1:${this.server.port}`,
251
262
  token: this.token,
@@ -253,7 +264,14 @@ export class SessionNotifyEndpoint {
253
264
  cwd: this.cwd,
254
265
  startedAt: Date.now(),
255
266
  };
256
- await fs.writeFile(notifySessionEndpointPath(this.sessionId), JSON.stringify(payload), { mode: 0o600 });
267
+ const discoveryPath = notifySessionEndpointPath(this.sessionId);
268
+ const temporaryPath = `${discoveryPath}.${crypto.randomUUID()}.tmp`;
269
+ try {
270
+ await fs.writeFile(temporaryPath, JSON.stringify(payload), { mode: 0o600, flag: "wx" });
271
+ await fs.rename(temporaryPath, discoveryPath);
272
+ } finally {
273
+ await fs.unlink(temporaryPath).catch(() => {});
274
+ }
257
275
  }
258
276
 
259
277
  async stop(): Promise<void> {
@@ -0,0 +1,177 @@
1
+ export interface SlackApiOptions {
2
+ fetchImpl?: typeof fetch;
3
+ now?: () => number;
4
+ sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
5
+ requestTimeoutMs?: number;
6
+ maxRetries?: number;
7
+ setTimeout?: (callback: () => void, ms: number) => Timer;
8
+ clearTimeout?: (timer: Timer) => void;
9
+ }
10
+
11
+ export interface SlackIdentity { team_id: string; user_id: string; bot_id: string; app_id: string }
12
+ export interface SlackChannel {
13
+ id: string;
14
+ is_im?: boolean;
15
+ is_member?: boolean;
16
+ is_archived?: boolean;
17
+ context_team_id?: string;
18
+ }
19
+ export interface SlackMessage { ts: string }
20
+
21
+ export class SlackApiError extends Error {
22
+ constructor(message: string, readonly status?: number, readonly retryAfterMs?: number, readonly sentMessages: SlackMessage[] = []) {
23
+ super(message);
24
+ this.name = "SlackApiError";
25
+ }
26
+ }
27
+
28
+ export function isSlackChannelId(id: unknown): id is string { return typeof id === "string" && /^[CGD][A-Z0-9]+$/.test(id); }
29
+ export function isSlackUserId(id: unknown): id is string { return typeof id === "string" && /^[UW][A-Z0-9]+$/.test(id); }
30
+ export function isSlackTimestamp(value: unknown): value is string { return typeof value === "string" && /^\d{1,20}\.\d{6}$/.test(value); }
31
+ function object(value: unknown): value is Record<string, unknown> { return !!value && typeof value === "object" && !Array.isArray(value); }
32
+
33
+ /** Ticket-bearing URLs are restricted before any WebSocket constructor sees them. */
34
+ export function slackSocketUrl(raw: string): string {
35
+ let url: URL;
36
+ try { url = new URL(raw); } catch { throw new SlackApiError("Invalid Slack Socket Mode URL"); }
37
+ if (url.protocol !== "wss:" || !/^wss(?:-[a-z0-9-]+)?\.slack\.com$/.test(url.hostname) ||
38
+ url.username || url.password || url.port || url.hash || url.pathname !== "/link/" || !url.searchParams.get("ticket")) {
39
+ throw new SlackApiError("Invalid Slack Socket Mode URL");
40
+ }
41
+ return url.href;
42
+ }
43
+
44
+ function delay(ms: number, signal: AbortSignal): Promise<void> {
45
+ const { promise, resolve, reject } = Promise.withResolvers<void>();
46
+ const abort = () => { clearTimeout(timer); reject(new SlackApiError("Slack request stopped")); };
47
+ const timer = setTimeout(() => { signal.removeEventListener("abort", abort); resolve(); }, ms);
48
+ if (signal.aborted) abort();
49
+ else signal.addEventListener("abort", abort, { once: true });
50
+ return promise;
51
+ }
52
+
53
+ /** Tokens are headers only. Neither upstream response bodies nor thrown network errors are logged. */
54
+ export class SlackApi {
55
+ private readonly controller = new AbortController();
56
+ private queue = Promise.resolve();
57
+ private queued = 0;
58
+ private cooldownUntil = 0;
59
+ private nextMessageAt = 0;
60
+ private fatalStatus: number | undefined;
61
+
62
+ constructor(private readonly botToken: string, private readonly appToken: string, private readonly options: SlackApiOptions = {}) {
63
+ if (typeof botToken !== "string" || !/^(?:xoxe\.)?xoxb-\S+$/.test(botToken) ||
64
+ typeof appToken !== "string" || !/^(?:xoxe\.)?xapp-\S+$/.test(appToken)) throw new SlackApiError("Slack requires bot and app-level access tokens");
65
+ }
66
+
67
+ stop(): void { this.controller.abort(); }
68
+
69
+ private request(method: string, body: Record<string, unknown> = {}): Promise<Record<string, unknown>> {
70
+ if (this.queued >= 128) return Promise.reject(new SlackApiError("Slack request queue full"));
71
+ this.queued++;
72
+ const result = this.queue.then(() => this.perform(method, body));
73
+ this.queue = result.then(() => {}, () => {}).finally(() => { this.queued--; });
74
+ return result;
75
+ }
76
+
77
+ private async perform(method: string, body: Record<string, unknown>): Promise<Record<string, unknown>> {
78
+ const now = this.options.now ?? Date.now;
79
+ const retries = Number.isFinite(this.options.maxRetries) ? Math.max(0, Math.min(Math.floor(this.options.maxRetries!), 3)) : 2;
80
+ for (let attempt = 0; attempt <= retries; attempt++) {
81
+ if (this.controller.signal.aborted) throw new SlackApiError("Slack request stopped");
82
+ if (this.fatalStatus) throw new SlackApiError("Slack authentication or permission denied", this.fatalStatus);
83
+ const wait = Math.max(this.cooldownUntil, method === "chat.postMessage" ? this.nextMessageAt : 0) - now();
84
+ if (wait > 30_000) throw new SlackApiError("Slack rate limit cooldown active", 429, wait);
85
+ if (wait > 0) await (this.options.sleep ?? delay)(wait, this.controller.signal);
86
+ if (this.controller.signal.aborted) throw new SlackApiError("Slack request stopped");
87
+ const timeout = new AbortController();
88
+ const timer = (this.options.setTimeout ?? setTimeout)(() => timeout.abort(), Math.max(1, Math.min(this.options.requestTimeoutMs ?? 10_000, 30_000)));
89
+ let response: Response;
90
+ let data: unknown;
91
+ try {
92
+ const read = method === "conversations.info" || method === "bots.info";
93
+ const query = new URLSearchParams(Object.entries(body).map(([key, value]): [string, string] => [key, String(value)]));
94
+ response = await (this.options.fetchImpl ?? fetch)(`https://slack.com/api/${method}${read ? `?${query}` : ""}`, {
95
+ method: read ? "GET" : "POST",
96
+ headers: { Authorization: `Bearer ${method === "apps.connections.open" ? this.appToken : this.botToken}`, "Content-Type": "application/json; charset=utf-8" },
97
+ body: read ? undefined : JSON.stringify(body),
98
+ signal: AbortSignal.any([this.controller.signal, timeout.signal]),
99
+ redirect: "error",
100
+ });
101
+ if (response.status === 401 || response.status === 403) this.fatalStatus = response.status;
102
+ data = await response.json().catch(error => { if (response.ok) throw error; return null; });
103
+ } catch {
104
+ // An uncertain POST is never retried: Slack may have accepted the message already.
105
+ throw new SlackApiError(this.fatalStatus ? "Slack authentication or permission denied" : "Slack request failed or timed out", this.fatalStatus);
106
+ } finally { (this.options.clearTimeout ?? clearTimeout)(timer); }
107
+ if (response.status === 429) {
108
+ const seconds = Number(response.headers.get("Retry-After"));
109
+ const ms = Number.isFinite(seconds) && seconds > 0 ? seconds * 1000 : 30_000;
110
+ this.cooldownUntil = Math.max(this.cooldownUntil, now() + ms);
111
+ if (attempt < retries) continue;
112
+ throw new SlackApiError("Slack rate limited", 429, ms);
113
+ }
114
+ if (!response.ok) throw new SlackApiError(`Slack HTTP ${response.status}`, response.status);
115
+ if (!object(data)) throw new SlackApiError("Invalid Slack response");
116
+ if (data.ok !== true) {
117
+ if (["invalid_auth", "not_authed", "token_revoked", "token_expired", "account_inactive"].includes(String(data.error))) this.fatalStatus = 401;
118
+ if (["missing_scope", "no_permission", "access_denied", "not_allowed_token_type"].includes(String(data.error))) this.fatalStatus = 403;
119
+ throw new SlackApiError(this.fatalStatus ? "Slack authentication or permission denied" : "Slack API request rejected", this.fatalStatus);
120
+ }
121
+ if (method === "chat.postMessage") this.nextMessageAt = now() + 1000;
122
+ return data;
123
+ }
124
+ throw new SlackApiError("Slack retry limit reached");
125
+ }
126
+
127
+ async getMe(): Promise<SlackIdentity> {
128
+ const me = await this.request("auth.test");
129
+ if (typeof me.team_id !== "string" || !/^T[A-Z0-9]+$/.test(me.team_id) || !isSlackUserId(me.user_id) ||
130
+ typeof me.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(me.bot_id)) throw new SlackApiError("Slack bot identity required");
131
+ const result = await this.request("bots.info", { bot: me.bot_id });
132
+ const bot = result.bot;
133
+ if (!object(bot) || bot.id !== me.bot_id || bot.user_id !== me.user_id || bot.deleted === true ||
134
+ typeof bot.app_id !== "string" || !/^A[A-Z0-9]+$/.test(bot.app_id)) throw new SlackApiError("Slack bot app identity required");
135
+ return { team_id: me.team_id, user_id: me.user_id, bot_id: me.bot_id, app_id: bot.app_id };
136
+ }
137
+
138
+ async getChannel(channelId: string): Promise<SlackChannel> {
139
+ if (!isSlackChannelId(channelId)) throw new SlackApiError("Invalid Slack channel ID");
140
+ const result = await this.request("conversations.info", { channel: channelId });
141
+ const channel = result.channel;
142
+ if (!object(channel) || channel.id !== channelId || channel.is_archived === true ||
143
+ (channel.is_im !== true && channel.is_member !== true)) throw new SlackApiError("Slack channel is unavailable or bot is not a member");
144
+ if (channel.context_team_id !== undefined && (typeof channel.context_team_id !== "string" || !/^T[A-Z0-9]+$/.test(channel.context_team_id))) throw new SlackApiError("Invalid Slack channel workspace");
145
+ return channel as unknown as SlackChannel;
146
+ }
147
+
148
+ async openConnection(): Promise<{ url: string }> {
149
+ const result = await this.request("apps.connections.open");
150
+ if (typeof result.url !== "string") throw new SlackApiError("Invalid Slack Socket Mode response");
151
+ return { url: slackSocketUrl(result.url) };
152
+ }
153
+
154
+ async sendMessage(channelId: string, text: string, threadTs?: string): Promise<SlackMessage[]> {
155
+ if (!isSlackChannelId(channelId) || (threadTs !== undefined && !isSlackTimestamp(threadTs))) throw new SlackApiError("Invalid Slack message destination");
156
+ if (!text.trim()) return [];
157
+ if (text.length > 256_000) throw new SlackApiError("Slack message exceeds relay limit");
158
+ const sent: SlackMessage[] = [];
159
+ for (let offset = 0; offset < text.length;) {
160
+ let end = Math.min(offset + 4000, text.length);
161
+ if (end < text.length && /[\uD800-\uDBFF]/.test(text[end - 1]!)) end--;
162
+ // Escape explicit mention syntax as well as disabling automatic link/name parsing.
163
+ const chunk = text.slice(offset, end).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
164
+ try {
165
+ const result = await this.request("chat.postMessage", { channel: channelId, text: chunk, thread_ts: threadTs,
166
+ mrkdwn: false, parse: "none", link_names: false, unfurl_links: false, unfurl_media: false });
167
+ if (!isSlackTimestamp(result.ts) || result.channel !== channelId) throw new SlackApiError("Invalid Slack message response");
168
+ sent.push({ ts: result.ts });
169
+ } catch (error) {
170
+ throw new SlackApiError(error instanceof SlackApiError ? error.message : "Slack delivery failed",
171
+ error instanceof SlackApiError ? error.status : undefined, error instanceof SlackApiError ? error.retryAfterMs : undefined, sent);
172
+ }
173
+ offset = end;
174
+ }
175
+ return sent;
176
+ }
177
+ }