jeo-code 0.12.6 → 0.12.8
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 +15 -0
- package/README.ja.md +3 -1
- package/README.ko.md +3 -1
- package/README.md +5 -3
- package/README.zh.md +3 -1
- package/package.json +1 -1
- package/src/agent/compaction.ts +6 -7
- package/src/agent/engine.ts +6 -2
- package/src/agent/notify/discord-api.ts +125 -28
- package/src/agent/notify/discord-artifacts.ts +126 -0
- package/src/agent/notify/discord-attachments.ts +116 -0
- package/src/agent/notify/discord-daemon.ts +213 -39
- package/src/agent/notify/session-endpoint.ts +127 -5
- package/src/agent/session.ts +7 -5
- package/src/agent/tools.ts +1 -0
- package/src/ai/providers/anthropic.ts +1 -0
- package/src/ai/providers/openai-responses.ts +1 -0
- package/src/cli/runner.ts +9 -0
- package/src/cli.ts +4 -0
- package/src/commands/computer.ts +46 -15
- package/src/commands/launch/agy-turn.ts +20 -7
- package/src/commands/launch/confirm.ts +78 -0
- package/src/commands/launch/flags.ts +7 -0
- package/src/commands/launch/input.ts +18 -1
- package/src/commands/launch.ts +320 -40
- package/src/commands/messenger.ts +146 -0
- package/src/commands/notify.ts +3 -0
- package/src/messenger/activity.ts +30 -0
- package/src/messenger/artifacts.ts +167 -0
- package/src/messenger/config.ts +145 -0
- package/src/messenger/discord-setup.ts +568 -0
- package/src/messenger/discord.ts +707 -0
- package/src/messenger/gateway.ts +444 -0
- package/src/messenger/http.ts +88 -0
- package/src/messenger/service.ts +122 -0
- package/src/messenger/session-pool.ts +109 -0
- package/src/messenger/slack.ts +333 -0
- package/src/messenger/telegram.ts +241 -0
- package/src/messenger/types.ts +82 -0
- package/src/tui/components/slash.ts +1 -1
|
@@ -44,6 +44,7 @@ import * as fs from "node:fs/promises";
|
|
|
44
44
|
import type { Server, ServerWebSocket } from "bun";
|
|
45
45
|
import { readGlobalConfig } from "../state";
|
|
46
46
|
import { notifySessionEndpointPath, notifySessionsDir } from "./paths";
|
|
47
|
+
import { resolveDiscordArtifactStage, type DiscordArtifactPaths } from "./discord-artifacts";
|
|
47
48
|
import type { SubagentRecord, SubagentRegistry } from "../subagent-registry";
|
|
48
49
|
|
|
49
50
|
const SNAPSHOT_POLL_MS = 1_200;
|
|
@@ -75,6 +76,13 @@ export class SessionNotifyEndpoint {
|
|
|
75
76
|
private server: Server<undefined> | undefined;
|
|
76
77
|
|
|
77
78
|
private readonly sockets = new Set<ServerWebSocket<undefined>>();
|
|
79
|
+
private readonly relaySockets = new Set<ServerWebSocket<undefined>>();
|
|
80
|
+
private readonly pendingRelay = new Map<ServerWebSocket<undefined>, number>();
|
|
81
|
+
private relayRequestGeneration = 0;
|
|
82
|
+
private readonly remoteControllers = new Map<ServerWebSocket<undefined>, { reqId: string; controller: AbortController }>();
|
|
83
|
+
private relayGeneration = 0;
|
|
84
|
+
private discordControlEnabled = false;
|
|
85
|
+
private discordControlGeneration = 0;
|
|
78
86
|
private pollTimer: ReturnType<typeof setInterval> | undefined;
|
|
79
87
|
private lastSnapshotJson = "";
|
|
80
88
|
/** `undefined` between turns — subagent requests report an empty registry. */
|
|
@@ -85,6 +93,8 @@ export class SessionNotifyEndpoint {
|
|
|
85
93
|
|
|
86
94
|
/** Registered by launch.ts; absent or rejected handlers return a failed ACK. */
|
|
87
95
|
onUserMessage: ((msg: RemoteUserMessage) => void | boolean | Promise<void | boolean>) | undefined;
|
|
96
|
+
onRemoteRequest: ((text: string, finish: (text?: string, artifactsAllowed?: boolean) => void, signal: AbortSignal, stage?: DiscordArtifactPaths) => boolean) | undefined;
|
|
97
|
+
onRegistryMutation: (() => void) | undefined;
|
|
88
98
|
onConfigCommand: ((cmd: RemoteConfigCommand) => void) | undefined;
|
|
89
99
|
|
|
90
100
|
constructor(
|
|
@@ -153,6 +163,61 @@ export class SessionNotifyEndpoint {
|
|
|
153
163
|
this.broadcast(JSON.stringify({ type: "turn_stream", sessionId: this.sessionId, phase: "finalized", text }));
|
|
154
164
|
}
|
|
155
165
|
|
|
166
|
+
/** Revoke current subscriptions before asking a daemon to change destination. */
|
|
167
|
+
requestLocalRelay(destination: string): number {
|
|
168
|
+
this.relaySockets.clear();
|
|
169
|
+
this.relayGeneration++;
|
|
170
|
+
this.pendingRelay.clear();
|
|
171
|
+
const generation = ++this.relayRequestGeneration;
|
|
172
|
+
const json = JSON.stringify({ type: "local_relay_control", sessionId: this.sessionId, destination, generation });
|
|
173
|
+
let sent = 0;
|
|
174
|
+
for (const ws of this.sockets) {
|
|
175
|
+
try { if (ws.send(json) > 0) { if (destination !== "off") this.pendingRelay.set(ws, generation); sent++; continue; } } catch {}
|
|
176
|
+
this.sockets.delete(ws);
|
|
177
|
+
this.remoteControllers.get(ws)?.controller.abort();
|
|
178
|
+
this.remoteControllers.delete(ws);
|
|
179
|
+
try { ws.terminate(); } catch { try { ws.close(); } catch {} }
|
|
180
|
+
}
|
|
181
|
+
return sent;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Snapshot eligibility before a turn; changes invalidate its relay. */
|
|
185
|
+
localRelayGeneration(): number | undefined {
|
|
186
|
+
return this.relaySockets.size ? this.relayGeneration : undefined;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
sendLocalFinal(text: string, generation: number): void {
|
|
190
|
+
if (generation !== this.relayGeneration) return;
|
|
191
|
+
const json = JSON.stringify({ type: "local_final", sessionId: this.sessionId, text });
|
|
192
|
+
for (const ws of this.relaySockets) {
|
|
193
|
+
try { ws.send(json); } catch {}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private sendDiscordControl(ws: ServerWebSocket<undefined>): void {
|
|
198
|
+
try {
|
|
199
|
+
const frame = JSON.stringify({ type: "discord_control", sessionId: this.sessionId, enabled: this.discordControlEnabled, generation: this.discordControlGeneration });
|
|
200
|
+
if (ws.send(frame) > 0) return;
|
|
201
|
+
} catch {}
|
|
202
|
+
this.sockets.delete(ws);
|
|
203
|
+
this.pendingRelay.delete(ws);
|
|
204
|
+
if (this.relaySockets.delete(ws)) this.relayGeneration++;
|
|
205
|
+
this.remoteControllers.get(ws)?.controller.abort();
|
|
206
|
+
this.remoteControllers.delete(ws);
|
|
207
|
+
try { ws.terminate(); } catch { try { ws.close(); } catch {} }
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
setDiscordControlEnabled(enabled: boolean): void {
|
|
211
|
+
if (enabled === this.discordControlEnabled) return;
|
|
212
|
+
this.discordControlEnabled = enabled;
|
|
213
|
+
this.discordControlGeneration++;
|
|
214
|
+
for (const ws of this.sockets) this.sendDiscordControl(ws);
|
|
215
|
+
if (!enabled) {
|
|
216
|
+
for (const remote of this.remoteControllers.values()) remote.controller.abort();
|
|
217
|
+
this.remoteControllers.clear();
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
156
221
|
private async handleMessage(ws: ServerWebSocket<undefined>, raw: string | Buffer): Promise<void> {
|
|
157
222
|
if (Buffer.byteLength(raw) > MAX_INBOUND_BYTES) return;
|
|
158
223
|
let msg: Record<string, unknown>;
|
|
@@ -162,25 +227,71 @@ export class SessionNotifyEndpoint {
|
|
|
162
227
|
return;
|
|
163
228
|
}
|
|
164
229
|
if (!msg || typeof msg !== "object" || Array.isArray(msg)) return;
|
|
165
|
-
if (msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
|
|
230
|
+
if (msg.type !== "remote_request" && msg.sessionId !== undefined && msg.sessionId !== this.sessionId) return;
|
|
231
|
+
if (msg.type === "remote_request") {
|
|
232
|
+
const reqId = msg.reqId;
|
|
233
|
+
let ok = false;
|
|
234
|
+
const onlyExpectedFields = Object.keys(msg).every(key => ["type", "sessionId", "reqId", "text", "staged", "controlGeneration"].includes(key));
|
|
235
|
+
if (onlyExpectedFields && this.discordControlEnabled && msg.controlGeneration === this.discordControlGeneration && msg.sessionId === this.sessionId && typeof reqId === "string" && reqId.length > 0 && reqId.length <= 256 &&
|
|
236
|
+
typeof msg.text === "string" && !!msg.text.trim() && !msg.text.includes("\u0003") && !/^[\/!$]/.test(msg.text.trimStart()) && (msg.staged === undefined || msg.staged === true) && this.onRemoteRequest && !this.remoteControllers.has(ws)) {
|
|
237
|
+
let finished = false;
|
|
238
|
+
const controlGeneration = this.discordControlGeneration;
|
|
239
|
+
const controller = new AbortController();
|
|
240
|
+
this.remoteControllers.set(ws, { reqId, controller });
|
|
241
|
+
let stage: DiscordArtifactPaths | undefined;
|
|
242
|
+
const finish = (text?: string, artifactsAllowed = false): void => {
|
|
243
|
+
if (finished) return;
|
|
244
|
+
finished = true;
|
|
245
|
+
if (this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
|
|
246
|
+
if (!this.sockets.has(ws)) return;
|
|
247
|
+
if (!this.discordControlEnabled || controller.signal.aborted || controlGeneration !== this.discordControlGeneration) text = undefined;
|
|
248
|
+
if (!stage) artifactsAllowed = false;
|
|
249
|
+
try { ws.send(JSON.stringify({ type: "remote_final", sessionId: this.sessionId, reqId, controlGeneration, ok: text !== undefined, artifactsAllowed: text !== undefined && artifactsAllowed, ...(text !== undefined ? { text } : {}) })); } catch {}
|
|
250
|
+
};
|
|
251
|
+
try {
|
|
252
|
+
if (msg.staged === true) stage = await resolveDiscordArtifactStage(this.sessionId, reqId);
|
|
253
|
+
if (this.discordControlEnabled && controlGeneration === this.discordControlGeneration && !controller.signal.aborted && this.sockets.has(ws)) {
|
|
254
|
+
ok = this.onRemoteRequest(msg.text, finish, controller.signal, stage);
|
|
255
|
+
}
|
|
256
|
+
} catch {}
|
|
257
|
+
if (!ok && this.remoteControllers.get(ws)?.controller === controller) this.remoteControllers.delete(ws);
|
|
258
|
+
}
|
|
259
|
+
try { ws.send(JSON.stringify({ type: "ack", reqId, ok })); } catch {}
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
if (msg.type === "remote_cancel" && msg.sessionId === this.sessionId && typeof msg.reqId === "string") {
|
|
263
|
+
const remote = this.remoteControllers.get(ws);
|
|
264
|
+
if (remote?.reqId === msg.reqId) remote.controller.abort();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (msg.type === "local_relay" && msg.sessionId === this.sessionId && typeof msg.enabled === "boolean") {
|
|
268
|
+
const allowed = !msg.enabled || (typeof msg.generation === "number" && this.pendingRelay.get(ws) === msg.generation && msg.generation === this.relayRequestGeneration);
|
|
269
|
+
if (allowed && msg.enabled) this.relaySockets.add(ws);
|
|
270
|
+
else if (!msg.enabled) this.relaySockets.delete(ws);
|
|
271
|
+
if (allowed) { this.pendingRelay.delete(ws); this.relayGeneration++; }
|
|
272
|
+
try { ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: allowed })); } catch {}
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
166
275
|
if (msg.type === "list") {
|
|
167
276
|
try {
|
|
168
277
|
ws.send(JSON.stringify(this.snapshot()));
|
|
169
278
|
} catch {}
|
|
170
279
|
return;
|
|
171
280
|
}
|
|
172
|
-
if (msg.type === "steer") {
|
|
281
|
+
if (msg.type === "steer" || msg.type === "discord_steer") {
|
|
173
282
|
const id = typeof msg.id === "string" ? msg.id : "";
|
|
174
283
|
const message = typeof msg.message === "string" ? msg.message : "";
|
|
175
|
-
const ok = !!(id && message && this.registry?.steer(id, message));
|
|
284
|
+
const ok = !!((msg.type === "steer" || (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration)) && id && message && this.registry?.steer(id, message));
|
|
285
|
+
if (ok) this.onRegistryMutation?.();
|
|
176
286
|
try {
|
|
177
287
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok }));
|
|
178
288
|
} catch {}
|
|
179
289
|
return;
|
|
180
290
|
}
|
|
181
|
-
if (msg.type === "cancel") {
|
|
291
|
+
if (msg.type === "cancel" || msg.type === "discord_cancel") {
|
|
182
292
|
const ids = Array.isArray(msg.ids) ? msg.ids.filter((x): x is string => typeof x === "string") : [];
|
|
183
|
-
const cancelled = ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
|
|
293
|
+
const cancelled = (msg.type === "cancel" || (this.discordControlEnabled && msg.sessionId === this.sessionId && msg.controlGeneration === this.discordControlGeneration)) && ids.length > 0 ? (this.registry?.cancel(ids) ?? []) : [];
|
|
294
|
+
if (cancelled.length) this.onRegistryMutation?.();
|
|
184
295
|
try {
|
|
185
296
|
ws.send(JSON.stringify({ type: "ack", reqId: msg.reqId, ok: cancelled.length > 0 }));
|
|
186
297
|
ws.send(JSON.stringify(this.snapshot()));
|
|
@@ -232,10 +343,15 @@ export class SessionNotifyEndpoint {
|
|
|
232
343
|
ws.send(JSON.stringify(this.snapshot()));
|
|
233
344
|
if (this.identityFrame) ws.send(this.identityFrame);
|
|
234
345
|
} catch {}
|
|
346
|
+
this.sendDiscordControl(ws);
|
|
235
347
|
},
|
|
236
348
|
message: (ws, raw) => { void this.handleMessage(ws, raw); },
|
|
237
349
|
close: ws => {
|
|
238
350
|
this.sockets.delete(ws);
|
|
351
|
+
this.pendingRelay.delete(ws);
|
|
352
|
+
this.remoteControllers.get(ws)?.controller.abort();
|
|
353
|
+
this.remoteControllers.delete(ws);
|
|
354
|
+
if (this.relaySockets.delete(ws)) this.relayGeneration++;
|
|
239
355
|
},
|
|
240
356
|
},
|
|
241
357
|
});
|
|
@@ -279,6 +395,12 @@ export class SessionNotifyEndpoint {
|
|
|
279
395
|
clearInterval(this.pollTimer);
|
|
280
396
|
this.pollTimer = undefined;
|
|
281
397
|
}
|
|
398
|
+
this.setDiscordControlEnabled(false);
|
|
399
|
+
this.relaySockets.clear();
|
|
400
|
+
this.pendingRelay.clear();
|
|
401
|
+
this.relayGeneration++;
|
|
402
|
+
for (const remote of this.remoteControllers.values()) remote.controller.abort();
|
|
403
|
+
this.remoteControllers.clear();
|
|
282
404
|
for (const ws of this.sockets) {
|
|
283
405
|
try {
|
|
284
406
|
ws.close();
|
package/src/agent/session.ts
CHANGED
|
@@ -76,7 +76,7 @@ export function newSessionId(): string {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
export function sessionsDir(cwd = process.cwd()): string {
|
|
79
|
-
return path.join(getLocalJeoDir(cwd), "sessions");
|
|
79
|
+
return process.env.JEO_SESSION_DIR || path.join(getLocalJeoDir(cwd), "sessions");
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
export function sessionPath(id: string, cwd = process.cwd()): string {
|
|
@@ -86,9 +86,11 @@ export function sessionPath(id: string, cwd = process.cwd()): string {
|
|
|
86
86
|
export async function createSession(
|
|
87
87
|
cwd = process.cwd(),
|
|
88
88
|
id = newSessionId(),
|
|
89
|
-
model?: string
|
|
89
|
+
model?: string,
|
|
90
|
+
directory = sessionsDir(cwd),
|
|
91
|
+
exclusive = false,
|
|
90
92
|
): Promise<{ id: string; path: string }> {
|
|
91
|
-
const dir =
|
|
93
|
+
const dir = directory;
|
|
92
94
|
await fs.mkdir(dir, { recursive: true });
|
|
93
95
|
|
|
94
96
|
const header: SessionHeader = {
|
|
@@ -100,8 +102,8 @@ export async function createSession(
|
|
|
100
102
|
...(model ? { model } : {}),
|
|
101
103
|
};
|
|
102
104
|
|
|
103
|
-
const file =
|
|
104
|
-
await fs.writeFile(file, JSON.stringify(header) + "\n", "utf8");
|
|
105
|
+
const file = path.join(dir, `${id}.jsonl`);
|
|
106
|
+
await fs.writeFile(file, JSON.stringify(header) + "\n", { encoding: "utf8", ...(exclusive ? { flag: "wx", mode: 0o600 } : {}) });
|
|
105
107
|
|
|
106
108
|
return { id, path: file };
|
|
107
109
|
}
|
package/src/agent/tools.ts
CHANGED
|
@@ -240,6 +240,7 @@ export function buildAnthropicMessages(messages: Message[], model: string, think
|
|
|
240
240
|
const blocks: AnthropicContentBlock[] = m.toolResults.map(tr => ({
|
|
241
241
|
type: "tool_result", tool_use_id: tr.id, content: tr.output, is_error: tr.isError,
|
|
242
242
|
}));
|
|
243
|
+
for (const img of m.images ?? []) blocks.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data: img.data } });
|
|
243
244
|
if (m.toolResultExtra) blocks.push({ type: "text", text: m.toolResultExtra });
|
|
244
245
|
return { role: "user", content: blocks };
|
|
245
246
|
}
|
|
@@ -87,6 +87,7 @@ export function buildResponsesInput(messages: Message[], modelKey: string, strip
|
|
|
87
87
|
&& nonSystem[i - 1].role === "assistant" && responsesNativizable(nonSystem[i - 1], modelKey)) {
|
|
88
88
|
for (const tr of m.toolResults) items.push({ type: "function_call_output", call_id: tr.id, output: tr.output });
|
|
89
89
|
if (m.toolResultExtra) items.push({ role: "user", content: [{ type: "input_text", text: m.toolResultExtra }] });
|
|
90
|
+
if (m.images?.length) items.push({ role: "user", content: m.images.map(img => ({ type: "input_image", image_url: `data:${img.mediaType};base64,${img.data}` })) });
|
|
90
91
|
return;
|
|
91
92
|
}
|
|
92
93
|
items.push(plain(m));
|
package/src/cli/runner.ts
CHANGED
|
@@ -119,6 +119,15 @@ export const COMMANDS: readonly CommandSpec[] = [
|
|
|
119
119
|
return args => m.runNotifyCommand(args);
|
|
120
120
|
},
|
|
121
121
|
},
|
|
122
|
+
{
|
|
123
|
+
name: "messenger",
|
|
124
|
+
summary: "Run or configure the bidirectional Discord, Telegram, and Slack agent gateway.",
|
|
125
|
+
usage: "messenger [setup|check|run] --config <file> [--channel ID] [--user ID] [--tools LIST] [--artifacts] [--service]",
|
|
126
|
+
loader: async () => {
|
|
127
|
+
const m = await import("../commands/messenger");
|
|
128
|
+
return args => m.runMessengerCommand(args);
|
|
129
|
+
},
|
|
130
|
+
},
|
|
122
131
|
{
|
|
123
132
|
name: "daemon",
|
|
124
133
|
summary: "Manage the shared Telegram/Discord/Slack notification daemon.",
|
package/src/cli.ts
CHANGED
|
@@ -44,6 +44,10 @@ const fatal = (err: unknown): never => {
|
|
|
44
44
|
};
|
|
45
45
|
process.on("uncaughtException", fatal);
|
|
46
46
|
process.on("unhandledRejection", fatal);
|
|
47
|
+
// Bun 1.4 may report a closed downstream pipe as a stream error event instead of
|
|
48
|
+
// promoting it to uncaughtException. Keep both runtime shapes on the same quiet path.
|
|
49
|
+
process.stdout.on("error", fatal);
|
|
50
|
+
process.stderr.on("error", fatal);
|
|
47
51
|
|
|
48
52
|
try {
|
|
49
53
|
const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
|
package/src/commands/computer.ts
CHANGED
|
@@ -2,6 +2,7 @@ import * as fs from "node:fs/promises";
|
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import * as os from "node:os";
|
|
4
4
|
import { computerSupervisor } from "../agent/computer-supervisor";
|
|
5
|
+
import type { ImageAttachment } from "../ai/types";
|
|
5
6
|
import { readGlobalConfig } from "../agent/state";
|
|
6
7
|
|
|
7
8
|
export interface ComputerAction {
|
|
@@ -36,9 +37,11 @@ async function logAudit(action: ComputerAction, success: boolean, error?: string
|
|
|
36
37
|
async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: string; stderr: string }> {
|
|
37
38
|
try {
|
|
38
39
|
const proc = Bun.spawn(cmd, { stdout: "pipe", stderr: "pipe" });
|
|
39
|
-
await
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
const [stdout, stderr] = await Promise.all([
|
|
41
|
+
new Response(proc.stdout).text(),
|
|
42
|
+
new Response(proc.stderr).text(),
|
|
43
|
+
proc.exited,
|
|
44
|
+
]);
|
|
42
45
|
return {
|
|
43
46
|
success: proc.exitCode === 0,
|
|
44
47
|
stdout,
|
|
@@ -53,7 +56,7 @@ async function runCommand(cmd: string[]): Promise<{ success: boolean; stdout: st
|
|
|
53
56
|
}
|
|
54
57
|
}
|
|
55
58
|
|
|
56
|
-
export async function executeComputerAction(action: ComputerAction, opts?: { enabledOverride?: boolean }): Promise<{ success: boolean; output: string; error?: string }> {
|
|
59
|
+
export async function executeComputerAction(action: ComputerAction, opts?: { enabledOverride?: boolean }): Promise<{ success: boolean; output: string; error?: string; images?: ImageAttachment[] }> {
|
|
57
60
|
let enabled = opts?.enabledOverride;
|
|
58
61
|
if (enabled === undefined) {
|
|
59
62
|
const config = await readGlobalConfig().catch(() => null);
|
|
@@ -68,6 +71,18 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
68
71
|
}
|
|
69
72
|
|
|
70
73
|
|
|
74
|
+
if (!action || typeof action !== "object") return { success: false, output: "", error: "Expected a computer action object." };
|
|
75
|
+
for (const field of ["x", "y", "deltaX", "deltaY", "duration"] as const) {
|
|
76
|
+
if (action[field] !== undefined && (typeof action[field] !== "number" || !Number.isFinite(action[field]))) {
|
|
77
|
+
return { success: false, output: "", error: `Invalid finite number for ${field}.` };
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (action.duration !== undefined && (action.duration < 0 || action.duration > 60)) {
|
|
81
|
+
return { success: false, output: "", error: "duration must be between 0 and 60 seconds." };
|
|
82
|
+
}
|
|
83
|
+
if (os.platform() !== "darwin" && os.platform() !== "linux") {
|
|
84
|
+
return { success: false, output: "", error: "Computer use supports macOS and Linux/X11 only." };
|
|
85
|
+
}
|
|
71
86
|
// Read-only actions don't need supervisor inputAllowed check
|
|
72
87
|
const isReadOnly = action.action === "screenshot" || action.action === "wait";
|
|
73
88
|
if (!isReadOnly) {
|
|
@@ -83,7 +98,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
83
98
|
switch (action.action) {
|
|
84
99
|
case "screenshot": {
|
|
85
100
|
const tmpDir = os.tmpdir();
|
|
86
|
-
const screenPath = path.join(tmpDir, `jeo-screen-${
|
|
101
|
+
const screenPath = path.join(tmpDir, `jeo-screen-${crypto.randomUUID()}.png`);
|
|
87
102
|
let cmd: string[];
|
|
88
103
|
if (isMac) {
|
|
89
104
|
cmd = ["screencapture", "-x", screenPath];
|
|
@@ -100,7 +115,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
100
115
|
const base64 = bytes.toString("base64");
|
|
101
116
|
await fs.unlink(screenPath).catch(() => {});
|
|
102
117
|
await logAudit(action, true);
|
|
103
|
-
return { success: true, output: base64 };
|
|
118
|
+
return { success: true, output: "Screenshot captured.", images: [{ mediaType: "image/png", data: base64 }] };
|
|
104
119
|
} catch (err: any) {
|
|
105
120
|
await logAudit(action, false, err.message);
|
|
106
121
|
return { success: false, output: "", error: `Failed to read screenshot file: ${err.message}` };
|
|
@@ -164,9 +179,9 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
164
179
|
}
|
|
165
180
|
let cmd: string[];
|
|
166
181
|
if (isMac) {
|
|
167
|
-
cmd = ["cliclick", `
|
|
182
|
+
cmd = ["cliclick", "dd:.", `dm:${action.x},${action.y}`, `du:${action.x},${action.y}`];
|
|
168
183
|
} else {
|
|
169
|
-
cmd = ["xdotool", "mousemove", String(action.x), String(action.y), "
|
|
184
|
+
cmd = ["xdotool", "mousedown", "1", "mousemove", String(action.x), String(action.y), "mouseup", "1"];
|
|
170
185
|
}
|
|
171
186
|
const res = await runCommand(cmd);
|
|
172
187
|
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
@@ -179,9 +194,14 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
179
194
|
let cmd: string[];
|
|
180
195
|
const dx = action.deltaX ?? 0;
|
|
181
196
|
const dy = action.deltaY ?? 0;
|
|
197
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy) || Math.abs(dx) > 1000 || Math.abs(dy) > 1000) {
|
|
198
|
+
return { success: false, output: "", error: "Scroll deltas must be integers between -1000 and 1000." };
|
|
199
|
+
}
|
|
200
|
+
if (dx === 0 && dy === 0) return { success: true, output: "Scroll skipped (zero deltas)." };
|
|
182
201
|
if (isMac) {
|
|
183
|
-
// cliclick scroll
|
|
184
|
-
cmd = ["
|
|
202
|
+
// cliclick's w: command WAITS; it cannot scroll. Use Quartz line events.
|
|
203
|
+
cmd = ["/usr/bin/osascript", "-l", "JavaScript", "-e",
|
|
204
|
+
`ObjC.import('Cocoa'); var event = $.CGEventCreateScrollWheelEvent(null, 1, 2, ${dy}, ${dx}); $.CGEventPost(0, event);`];
|
|
185
205
|
} else {
|
|
186
206
|
// xdotool click 4 (scroll up), 5 (scroll down), 6 (scroll left), 7 (scroll right)
|
|
187
207
|
const actions: string[] = [];
|
|
@@ -212,7 +232,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
212
232
|
if (isMac) {
|
|
213
233
|
cmd = ["cliclick", `t:${action.text}`];
|
|
214
234
|
} else {
|
|
215
|
-
cmd = ["xdotool", "type", action.text];
|
|
235
|
+
cmd = ["xdotool", "type", "--clearmodifiers", "--", action.text];
|
|
216
236
|
}
|
|
217
237
|
const res = await runCommand(cmd);
|
|
218
238
|
await logAudit(action, res.success, res.success ? undefined : res.stderr);
|
|
@@ -227,7 +247,16 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
227
247
|
}
|
|
228
248
|
let cmd: string[];
|
|
229
249
|
if (isMac) {
|
|
230
|
-
|
|
250
|
+
const aliases: Record<string, string> = { return: "return", enter: "return", escape: "esc", backspace: "delete", arrowup: "arrow-up", arrowdown: "arrow-down", arrowleft: "arrow-left", arrowright: "arrow-right", pageup: "page-up", pagedown: "page-down" };
|
|
251
|
+
const parts = action.key.toLowerCase().split("+");
|
|
252
|
+
const key = parts.pop()!;
|
|
253
|
+
const modifiers = parts.map(p => ({ command: "cmd", meta: "cmd", control: "ctrl", option: "alt" }[p] ?? p));
|
|
254
|
+
if (modifiers.some(p => !["cmd", "ctrl", "alt", "shift", "fn"].includes(p))) {
|
|
255
|
+
return { success: false, output: "", error: "Unknown key modifier." };
|
|
256
|
+
}
|
|
257
|
+
const press = key.length === 1 ? `t:${key}` : `kp:${aliases[key] ?? key}`;
|
|
258
|
+
cmd = ["cliclick", ...(modifiers.length ? [`kd:${modifiers.join(",")}`] : []), press,
|
|
259
|
+
...(modifiers.length ? [`ku:${modifiers.join(",")}`] : [])];
|
|
231
260
|
} else {
|
|
232
261
|
cmd = ["xdotool", "key", action.key];
|
|
233
262
|
}
|
|
@@ -250,6 +279,7 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
250
279
|
return { success: false, output: "", error: "Missing actions array for batch action." };
|
|
251
280
|
}
|
|
252
281
|
const results: string[] = [];
|
|
282
|
+
const images: ImageAttachment[] = [];
|
|
253
283
|
for (let i = 0; i < action.actions.length; i++) {
|
|
254
284
|
const subAction = action.actions[i];
|
|
255
285
|
const res = await executeComputerAction(subAction, opts);
|
|
@@ -257,12 +287,13 @@ export async function executeComputerAction(action: ComputerAction, opts?: { ena
|
|
|
257
287
|
if (!res.success) {
|
|
258
288
|
const err = `Batch failed at step ${i}: ${res.error}`;
|
|
259
289
|
await logAudit(action, false, err);
|
|
260
|
-
return { success: false, output: results.join("\n"), error: err };
|
|
290
|
+
return { success: false, output: results.join("\n"), error: err, images };
|
|
261
291
|
}
|
|
262
292
|
results.push(`Step ${i}: ${res.output}`);
|
|
293
|
+
images.push(...(res.images ?? []));
|
|
263
294
|
}
|
|
264
295
|
await logAudit(action, true);
|
|
265
|
-
return { success: true, output: results.join("\n") };
|
|
296
|
+
return { success: true, output: results.join("\n"), images };
|
|
266
297
|
}
|
|
267
298
|
|
|
268
299
|
default:
|
|
@@ -329,7 +360,7 @@ export async function runComputerCommand(args: string[]): Promise<void> {
|
|
|
329
360
|
const res = await executeComputerAction(action);
|
|
330
361
|
if (res.success) {
|
|
331
362
|
if (actionName === "screenshot") {
|
|
332
|
-
console.log(res.output); // base64 output
|
|
363
|
+
console.log(res.images?.[0]?.data ?? res.output); // preserve CLI base64 output
|
|
333
364
|
} else {
|
|
334
365
|
console.log(res.output);
|
|
335
366
|
}
|
|
@@ -9,13 +9,20 @@ export interface AgySessionTurnOptions extends Omit<AgyTurnOptions, "conversatio
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
/** An explicit full-agent turn: no raw-model loop, credential fallback, or tool replay. */
|
|
12
|
-
export async function runAgySessionTurn(options: AgySessionTurnOptions
|
|
12
|
+
export async function runAgySessionTurn(options: AgySessionTurnOptions, deps: {
|
|
13
|
+
loadSession?: typeof loadSession;
|
|
14
|
+
appendMessage?: typeof appendMessage;
|
|
15
|
+
appendAgySession?: typeof appendAgySession;
|
|
16
|
+
runAgyTurn?: typeof runAgyTurn;
|
|
17
|
+
} = {}): Promise<AgyTurnResult> {
|
|
13
18
|
const { history, sessionId, images, ...turn } = options;
|
|
19
|
+
const { loadSession: load = loadSession, appendMessage: append = appendMessage, appendAgySession: appendAgy = appendAgySession, runAgyTurn: run = runAgyTurn } = deps;
|
|
14
20
|
if (images?.length) throw new Error("Image attachments are not supported by the official agy bridge");
|
|
15
21
|
if (turn.signal?.aborted) throw new AgyCliError("Official CLI turn cancelled", "cancelled");
|
|
16
22
|
|
|
17
23
|
const previousCount = history.reduce((count, message) => count + (message.role === "system" ? 0 : 1), 0);
|
|
18
|
-
const state = sessionId ? (await
|
|
24
|
+
const state = sessionId ? (await load(sessionId, turn.cwd)).header.agy : undefined;
|
|
25
|
+
turn.signal?.throwIfAborted();
|
|
19
26
|
// A non-CLI turn or compaction changes the cursor. Start fresh with text context
|
|
20
27
|
// rather than resume a stale agent conversation or agy's unrelated latest one.
|
|
21
28
|
let conversationId = state?.messageCount === previousCount ? state.conversationId : undefined;
|
|
@@ -33,26 +40,32 @@ export async function runAgySessionTurn(options: AgySessionTurnOptions): Promise
|
|
|
33
40
|
}
|
|
34
41
|
|
|
35
42
|
const user: Message = { role: "user", content: turn.prompt };
|
|
36
|
-
if (sessionId) await
|
|
43
|
+
if (sessionId) await append(sessionId, user, turn.cwd);
|
|
44
|
+
turn.signal?.throwIfAborted();
|
|
37
45
|
history.push(user);
|
|
38
|
-
const result = await
|
|
46
|
+
const result = await run({
|
|
39
47
|
...turn,
|
|
40
48
|
prompt,
|
|
41
49
|
conversationId,
|
|
42
50
|
onConversation: async id => {
|
|
51
|
+
turn.signal?.throwIfAborted();
|
|
43
52
|
conversationId = id;
|
|
44
|
-
if (sessionId) await
|
|
53
|
+
if (sessionId) await appendAgy(sessionId, { conversationId: id, messageCount: previousCount + 1 }, turn.cwd);
|
|
54
|
+
turn.signal?.throwIfAborted();
|
|
45
55
|
},
|
|
46
56
|
});
|
|
57
|
+
turn.signal?.throwIfAborted();
|
|
47
58
|
|
|
48
59
|
const assistant: Message = { role: "assistant", content: result.response };
|
|
49
60
|
try {
|
|
50
|
-
if (sessionId) await
|
|
61
|
+
if (sessionId) await append(sessionId, assistant, turn.cwd);
|
|
62
|
+
turn.signal?.throwIfAborted();
|
|
51
63
|
history.push(assistant);
|
|
52
|
-
if (sessionId) await
|
|
64
|
+
if (sessionId) await appendAgy(sessionId, {
|
|
53
65
|
conversationId: result.conversationId,
|
|
54
66
|
messageCount: previousCount + 2,
|
|
55
67
|
}, turn.cwd);
|
|
68
|
+
turn.signal?.throwIfAborted();
|
|
56
69
|
} catch (error) {
|
|
57
70
|
throw new Error(`Official CLI completed, but saving its jeo session failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
|
|
58
71
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/** Confirm a sensitive action on an isolated local TTY; all other input denies. */
|
|
2
|
+
export async function confirmLocalControl({
|
|
3
|
+
input,
|
|
4
|
+
output,
|
|
5
|
+
timeoutMs = 15_000,
|
|
6
|
+
warning = "Discord control grants authorized users the host session's tools and approval policy, including reading or changing files and running commands. This is not a sandbox.",
|
|
7
|
+
prompt = "Confirm Discord control: press y to allow, any other key to cancel.",
|
|
8
|
+
}: {
|
|
9
|
+
input: NodeJS.ReadStream;
|
|
10
|
+
output: NodeJS.WriteStream;
|
|
11
|
+
timeoutMs?: number;
|
|
12
|
+
warning?: string;
|
|
13
|
+
prompt?: string;
|
|
14
|
+
}): Promise<boolean> {
|
|
15
|
+
if (!input.isTTY || !output.isTTY || typeof input.setRawMode !== "function" || !Number.isFinite(timeoutMs) || timeoutMs <= 0) return false;
|
|
16
|
+
const wasRaw = !!input.isRaw;
|
|
17
|
+
const wasPaused = input.isPaused();
|
|
18
|
+
const dataListeners = input.rawListeners("data");
|
|
19
|
+
const keyListeners = input.rawListeners("keypress");
|
|
20
|
+
let deadline: ReturnType<typeof setTimeout> | undefined;
|
|
21
|
+
let quiet: ReturnType<typeof setTimeout> | undefined;
|
|
22
|
+
let phase: "drain" | "ready" | "candidate" = "drain";
|
|
23
|
+
let finished = false;
|
|
24
|
+
let resolveResult: (value: boolean) => void = () => {};
|
|
25
|
+
const result = new Promise<boolean>(resolve => { resolveResult = resolve; });
|
|
26
|
+
const finish = (accepted: boolean) => {
|
|
27
|
+
if (finished) return;
|
|
28
|
+
finished = true;
|
|
29
|
+
resolveResult(accepted);
|
|
30
|
+
};
|
|
31
|
+
const startQuietDrain = () => {
|
|
32
|
+
clearTimeout(quiet);
|
|
33
|
+
quiet = setTimeout(() => {
|
|
34
|
+
if (finished) return;
|
|
35
|
+
phase = "ready";
|
|
36
|
+
try { output.write(`${prompt}\n`); }
|
|
37
|
+
catch { finish(false); }
|
|
38
|
+
}, 200);
|
|
39
|
+
};
|
|
40
|
+
const onData = (chunk: string | Uint8Array) => {
|
|
41
|
+
if (phase === "drain") { startQuietDrain(); return; }
|
|
42
|
+
if (phase !== "ready" || (typeof chunk === "string" ? chunk !== "y" : chunk.length !== 1 || chunk[0] !== 0x79)) { finish(false); return; }
|
|
43
|
+
phase = "candidate";
|
|
44
|
+
quiet = setTimeout(() => finish(true), 200);
|
|
45
|
+
};
|
|
46
|
+
const onEnd = () => finish(false);
|
|
47
|
+
try {
|
|
48
|
+
input.pause();
|
|
49
|
+
for (const listener of dataListeners) input.removeListener("data", listener);
|
|
50
|
+
for (const listener of keyListeners) input.removeListener("keypress", listener);
|
|
51
|
+
input.setRawMode(true);
|
|
52
|
+
output.write(`${warning}\n`);
|
|
53
|
+
while (input.read() !== null) { /* discard input queued before the warning */ }
|
|
54
|
+
input.on("data", onData);
|
|
55
|
+
input.once("end", onEnd);
|
|
56
|
+
input.once("close", onEnd);
|
|
57
|
+
input.once("error", onEnd);
|
|
58
|
+
deadline = setTimeout(() => finish(false), timeoutMs);
|
|
59
|
+
startQuietDrain();
|
|
60
|
+
input.resume();
|
|
61
|
+
return await result;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
} finally {
|
|
65
|
+
clearTimeout(deadline);
|
|
66
|
+
clearTimeout(quiet);
|
|
67
|
+
input.pause();
|
|
68
|
+
input.removeListener("data", onData);
|
|
69
|
+
input.removeListener("end", onEnd);
|
|
70
|
+
input.removeListener("close", onEnd);
|
|
71
|
+
input.removeListener("error", onEnd);
|
|
72
|
+
for (const listener of dataListeners) input.on("data", listener);
|
|
73
|
+
for (const listener of keyListeners) input.on("keypress", listener);
|
|
74
|
+
try { input.setRawMode(wasRaw); } catch {}
|
|
75
|
+
if (wasPaused) input.pause();
|
|
76
|
+
else input.resume();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -23,6 +23,8 @@ export interface LaunchFlags {
|
|
|
23
23
|
thinking?: ThinkLevel;
|
|
24
24
|
errors: string[];
|
|
25
25
|
print?: boolean;
|
|
26
|
+
literalPrompt?: boolean;
|
|
27
|
+
literalWorker?: boolean;
|
|
26
28
|
appendSystemPromptRaw?: string;
|
|
27
29
|
appendSystemPrompt?: string;
|
|
28
30
|
noSkills: boolean;
|
|
@@ -160,6 +162,11 @@ export function parseFlags(args: string[], cwd: string = process.cwd()): LaunchF
|
|
|
160
162
|
if (value) flags.skills = value;
|
|
161
163
|
else flags.errors.push("--skills requires a value");
|
|
162
164
|
i = nextIndex;
|
|
165
|
+
} else if (a === "--literal-prompt") {
|
|
166
|
+
flags.literalPrompt = true;
|
|
167
|
+
} else if (a === "--literal-worker") {
|
|
168
|
+
flags.literalWorker = true;
|
|
169
|
+
flags.literalPrompt = true;
|
|
163
170
|
} else if (a === "--no-tools") {
|
|
164
171
|
flags.noTools = true;
|
|
165
172
|
} else if (a === "--tools" || a.startsWith("--tools=")) {
|
|
@@ -34,6 +34,9 @@ interface AbortHarnessOptions {
|
|
|
34
34
|
onScrollKey?: (dir: -1 | 1, page: boolean) => void;
|
|
35
35
|
/** Invoked with printable keyboard input received while the live turn owns stdin. */
|
|
36
36
|
onBufferedInput?: (chunk: string) => void;
|
|
37
|
+
/** Emergency-only input while an aborted turn is still unwinding. */
|
|
38
|
+
onBufferedInputAfterAbort?: (chunk: string) => void;
|
|
39
|
+
pasteActiveAfterAbort?: () => boolean;
|
|
37
40
|
/** True while the input queue is inside a bracketed paste (mid-paste chunks
|
|
38
41
|
* carry no marker and must keep routing to the queue, not the noise path). */
|
|
39
42
|
pasteActive?: () => boolean;
|
|
@@ -46,6 +49,15 @@ interface AbortHarnessOptions {
|
|
|
46
49
|
export const PASTE_START = "\u001b[200~";
|
|
47
50
|
export const PASTE_END = "\u001b[201~";
|
|
48
51
|
|
|
52
|
+
/** Conservative paste signal; one-character chunks remain indistinguishable from typing. */
|
|
53
|
+
export function isPasteLikeInputChunk(chunk: string): boolean {
|
|
54
|
+
let printable = 0;
|
|
55
|
+
for (const ch of stripPasteEscapes(chunk)) {
|
|
56
|
+
if ((ch >= " " || ch === "\t") && ++printable > 1) return true;
|
|
57
|
+
}
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
|
|
49
61
|
/** True when a stdin chunk is ONLY backspace bytes (DEL 0x7f or BS 0x08) — i.e. a
|
|
50
62
|
* standalone Backspace keystroke with nothing else. A backspace on an EMPTY input
|
|
51
63
|
* line is a no-op edit, but some Bun readline builds turn it into a spurious `close`
|
|
@@ -483,8 +495,13 @@ export function createInFlightAbortHarness(opts: AbortHarnessOptions = {}): InFl
|
|
|
483
495
|
};
|
|
484
496
|
|
|
485
497
|
const handleData = (chunk: string | Uint8Array) => {
|
|
486
|
-
if (!captureEsc
|
|
498
|
+
if (!captureEsc) return;
|
|
487
499
|
const raw = typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8");
|
|
500
|
+
if (controller.signal.aborted) {
|
|
501
|
+
if (!raw.includes(PASTE_START) && !opts.pasteActiveAfterAbort?.() && chunkHasCtrlC(raw)) handleSigint();
|
|
502
|
+
else opts.onBufferedInputAfterAbort?.(raw);
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
488
505
|
if (raw.includes(PASTE_START) || opts.pasteActive?.()) {
|
|
489
506
|
opts.onBufferedInput?.(raw);
|
|
490
507
|
return;
|