jeo-code 0.12.12 → 0.12.15
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 +47 -0
- package/README.ja.md +33 -9
- package/README.ko.md +33 -9
- package/README.md +33 -9
- package/README.zh.md +33 -9
- package/package.json +4 -2
- package/src/agent/browser-tool.ts +9 -1
- package/src/agent/config-schema.ts +8 -0
- package/src/agent/context-files.ts +19 -0
- package/src/agent/engine.ts +25 -6
- package/src/agent/eval-tool.ts +6 -0
- package/src/agent/goal-verifier.ts +30 -6
- package/src/agent/hooks.ts +52 -4
- package/src/agent/job-registry.ts +110 -18
- package/src/agent/job-tool.ts +43 -9
- package/src/agent/loop-guards.ts +17 -2
- package/src/agent/memory.ts +151 -112
- package/src/agent/monitor-registry.ts +142 -30
- package/src/agent/monitor-tool.ts +34 -6
- package/src/agent/notify/discord-daemon.ts +135 -42
- package/src/agent/notify/session-endpoint.ts +27 -5
- package/src/agent/sandbox/assets.d.ts +4 -0
- package/src/agent/sandbox/authority.ts +295 -0
- package/src/agent/sandbox/bundle.ts +37 -0
- package/src/agent/sandbox/control.ts +49 -0
- package/src/agent/sandbox/docker.ts +283 -0
- package/src/agent/sandbox/hands-source.text +14548 -0
- package/src/agent/sandbox/hands-worker.ts +168 -0
- package/src/agent/sandbox/policy.ts +155 -0
- package/src/agent/sandbox/rpc.ts +90 -0
- package/src/agent/sandbox/supervisor.ts +210 -0
- package/src/agent/sandbox/types.ts +77 -0
- package/src/agent/shell-lease.ts +190 -0
- package/src/agent/state.ts +167 -0
- package/src/agent/subagents.ts +4 -3
- package/src/agent/task-tool.ts +8 -0
- package/src/agent/todo-tool.ts +30 -1
- package/src/agent/tool-schemas.ts +13 -0
- package/src/ai/model-discovery.ts +12 -0
- package/src/ai/providers/anthropic.ts +27 -2
- package/src/ai/providers/antigravity.ts +10 -2
- package/src/ai/providers/gemini.ts +20 -16
- package/src/ai/providers/ollama.ts +14 -0
- package/src/ai/providers/openai-responses.ts +22 -5
- package/src/ai/providers/openai.ts +23 -40
- package/src/cli/runner.ts +3 -0
- package/src/commands/approve.ts +37 -13
- package/src/commands/launch/flags.ts +2 -1
- package/src/commands/launch/jobs-slash.ts +1 -1
- package/src/commands/launch.ts +261 -35
- package/src/commands/messenger.ts +105 -39
- package/src/commands/notify.ts +2 -2
- package/src/commands/team.ts +44 -12
- package/src/commands/ultragoal.ts +6 -16
- package/src/messenger/activity.ts +2 -1
- package/src/messenger/config.ts +51 -9
- package/src/messenger/discord-setup.ts +122 -20
- package/src/messenger/discord.ts +39 -52
- package/src/messenger/gateway.ts +33 -10
- package/src/messenger/model-selection.ts +101 -0
- package/src/messenger/session-pool.ts +189 -35
- package/src/messenger/types.ts +3 -2
- package/src/skills/catalog.ts +207 -0
|
@@ -3,16 +3,63 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import { createInterface } from "node:readline/promises";
|
|
5
5
|
import { Writable } from "node:stream";
|
|
6
|
-
import { loadMessengerConfig, validateMessengerEnvironment } from "../messenger/config";
|
|
6
|
+
import { loadMessengerConfig, parseMessengerSandbox, validateMessengerEnvironment, validateMessengerStorage } from "../messenger/config";
|
|
7
7
|
import { createDiscordAdapter } from "../messenger/discord";
|
|
8
8
|
import { setupDiscordMessenger } from "../messenger/discord-setup";
|
|
9
9
|
import { messengerArtifactPrompt } from "../messenger/artifacts";
|
|
10
|
-
import { createMessengerGateway, messengerSessionRoute
|
|
10
|
+
import { createMessengerGateway, messengerSessionRoute } from "../messenger/gateway";
|
|
11
11
|
import { createMessengerSessionPool } from "../messenger/session-pool";
|
|
12
12
|
import { createSlackAdapter } from "../messenger/slack";
|
|
13
13
|
import { createTelegramAdapter } from "../messenger/telegram";
|
|
14
14
|
import type { MessengerAdapter, MessengerConfig, MessengerReply } from "../messenger/types";
|
|
15
15
|
import { createSession } from "../agent/session";
|
|
16
|
+
import { selectDiscordModel } from "../messenger/model-selection";
|
|
17
|
+
import type { MessengerGatewayOptions } from "../messenger/gateway";
|
|
18
|
+
import { notifySessionEndpointPath } from "../agent/notify/paths";
|
|
19
|
+
import { parseSessionEndpoint } from "../agent/notify/session-discovery";
|
|
20
|
+
|
|
21
|
+
async function sendSandboxControl(config: MessengerConfig, id: string, userId: string, text: string, signal: AbortSignal): Promise<string> {
|
|
22
|
+
const file = await fs.open(notifySessionEndpointPath(id), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
23
|
+
let raw: string;
|
|
24
|
+
try {
|
|
25
|
+
const stat = await file.stat();
|
|
26
|
+
if (!stat.isFile() || (process.platform !== "win32" && (stat.mode & 0o077) !== 0)) throw new Error("Sandbox endpoint discovery is not private");
|
|
27
|
+
raw = await file.readFile("utf8");
|
|
28
|
+
} finally { await file.close(); }
|
|
29
|
+
const endpoint = parseSessionEndpoint(raw);
|
|
30
|
+
if (!endpoint || endpoint.cwd !== config.cwd) throw new Error("Sandbox endpoint session mismatch");
|
|
31
|
+
if (signal.aborted) throw new Error("Sandbox control aborted");
|
|
32
|
+
const { promise, resolve, reject } = Promise.withResolvers<string>();
|
|
33
|
+
const reqId = crypto.randomUUID();
|
|
34
|
+
const ws = new WebSocket(`${endpoint.url}/?token=${encodeURIComponent(endpoint.token)}`);
|
|
35
|
+
let sent = false;
|
|
36
|
+
let settled = false;
|
|
37
|
+
const finish = (error?: Error, output?: string) => {
|
|
38
|
+
if (settled) return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
signal.removeEventListener("abort", abort);
|
|
42
|
+
ws.close();
|
|
43
|
+
if (error) reject(error); else resolve(output ?? "Sandbox control returned no output");
|
|
44
|
+
};
|
|
45
|
+
const abort = () => finish(new Error("Sandbox control aborted"));
|
|
46
|
+
const timer = setTimeout(() => finish(new Error("Sandbox control timed out; outcome is unconfirmed")), 30_000);
|
|
47
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
48
|
+
ws.addEventListener("error", () => finish(new Error("Sandbox control endpoint unavailable")));
|
|
49
|
+
ws.addEventListener("close", () => finish(new Error("Sandbox control endpoint closed")));
|
|
50
|
+
ws.addEventListener("message", event => {
|
|
51
|
+
try {
|
|
52
|
+
const frame = JSON.parse(String(event.data));
|
|
53
|
+
if (frame.type === "discord_control" && frame.sessionId === id && !sent) {
|
|
54
|
+
if (frame.enabled !== true || !Number.isSafeInteger(frame.generation)) return finish(new Error("Discord sandbox control is disabled"));
|
|
55
|
+
sent = true;
|
|
56
|
+
ws.send(JSON.stringify({ type: "sandbox_control", sessionId: id, reqId, text, actor: { origin: "discord", sessionKey: id, userId }, controlGeneration: frame.generation }));
|
|
57
|
+
} else if (frame.type === "ack" && frame.reqId === reqId && typeof frame.output === "string") finish(undefined, frame.output);
|
|
58
|
+
} catch { finish(new Error("Invalid sandbox control response")); }
|
|
59
|
+
});
|
|
60
|
+
if (signal.aborted) abort();
|
|
61
|
+
return promise;
|
|
62
|
+
}
|
|
16
63
|
|
|
17
64
|
type Flags = { config?: string; channel: string[]; user: string[]; tools?: string; artifacts: boolean; service: boolean; noDm: boolean };
|
|
18
65
|
|
|
@@ -40,15 +87,20 @@ function parse(args: string[]): { action: "setup" | "check" | "run"; flags: Flag
|
|
|
40
87
|
function terminalIO() {
|
|
41
88
|
let muted = false;
|
|
42
89
|
const output = new Writable({ write(chunk, _encoding, callback) { if (!muted) process.stdout.write(chunk); callback(); } });
|
|
43
|
-
const rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY) });
|
|
90
|
+
const rl = createInterface({ input: process.stdin, output, terminal: Boolean(process.stdin.isTTY && process.stdout.isTTY), historySize: 0 });
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
const interrupted = new AbortController();
|
|
93
|
+
rl.on("SIGINT", () => { interrupted.abort(); rl.close(); });
|
|
94
|
+
rl.on("close", () => controller.abort());
|
|
44
95
|
const ask = async (question: string, secret = false) => {
|
|
45
96
|
process.stdout.write(question);
|
|
46
97
|
muted = secret;
|
|
47
|
-
try { return (await rl.question("")).trim(); }
|
|
98
|
+
try { return (await rl.question("", { signal: controller.signal })).trim(); }
|
|
48
99
|
finally { muted = false; if (secret) process.stdout.write("\n"); }
|
|
49
100
|
};
|
|
50
101
|
return {
|
|
51
102
|
interactive: Boolean(process.stdin.isTTY && process.stdout.isTTY),
|
|
103
|
+
signal: interrupted.signal,
|
|
52
104
|
say: (line: string) => process.stdout.write(`${line}\n`),
|
|
53
105
|
ask: (question: string) => ask(question),
|
|
54
106
|
askSecret: (question: string) => ask(question, true),
|
|
@@ -57,53 +109,39 @@ function terminalIO() {
|
|
|
57
109
|
}
|
|
58
110
|
|
|
59
111
|
function routeUuid(config: MessengerConfig, message: Parameters<MessengerReply>[0]): string {
|
|
60
|
-
if (config.independentSessions) {
|
|
61
|
-
const hex = new Bun.CryptoHasher("sha256").update(JSON.stringify([config.cwd, messengerConversationKey(message), message.messageId])).digest("hex");
|
|
62
|
-
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
63
|
-
}
|
|
64
112
|
const hex = messengerSessionRoute(config.cwd, message);
|
|
65
113
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
66
114
|
}
|
|
67
115
|
|
|
68
116
|
/** Run each conversation in its own reusable agent process. */
|
|
69
|
-
export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn): MessengerReply & { close(): Promise<void> } {
|
|
70
|
-
const
|
|
117
|
+
export function createMessengerCliReply(config: MessengerConfig, spawn: typeof Bun.spawn = Bun.spawn, selectModel: typeof selectDiscordModel = selectDiscordModel): MessengerReply & { close(): Promise<void>; sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> } {
|
|
118
|
+
const sandbox = config.sandbox !== undefined || config.platforms.discord ? parseMessengerSandbox(config.sandbox) : undefined;
|
|
119
|
+
// Launch may inherit a global sandbox even when this platform has no local override.
|
|
120
|
+
const pool = createMessengerSessionPool(spawn, undefined, undefined, undefined, { requireSandboxCleanup: true });
|
|
71
121
|
const reply: MessengerReply = async (message, signal, onActivity) => {
|
|
72
122
|
if (signal.aborted) throw new Error("Messenger request aborted");
|
|
123
|
+
await validateMessengerStorage(config);
|
|
73
124
|
const id = routeUuid(config, message);
|
|
74
125
|
await fs.mkdir(config.sessionDir, { recursive: true, mode: 0o700 });
|
|
126
|
+
if (signal.aborted) throw new Error("Messenger request aborted");
|
|
75
127
|
const target = path.join(config.sessionDir, `${id}.jsonl`);
|
|
76
|
-
const legacy = path.join(config.cwd, ".jeo", "sessions", `${id}.jsonl`);
|
|
77
128
|
try {
|
|
78
129
|
await fs.access(target);
|
|
130
|
+
if (signal.aborted) throw new Error("Messenger request aborted");
|
|
79
131
|
} catch (error) {
|
|
80
132
|
if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
catch (openError) { if ((openError as NodeJS.ErrnoException).code !== "ENOENT") throw openError; }
|
|
84
|
-
try {
|
|
85
|
-
if (old) {
|
|
86
|
-
if (!(await old.stat()).isFile()) throw new Error("Invalid legacy messenger session");
|
|
87
|
-
const contents = await old.readFile();
|
|
88
|
-
const temporary = `${target}.${crypto.randomUUID()}.tmp`;
|
|
89
|
-
try {
|
|
90
|
-
await fs.writeFile(temporary, contents, { flag: "wx", mode: 0o600 });
|
|
91
|
-
await fs.link(temporary, target);
|
|
92
|
-
} finally { await fs.unlink(temporary).catch(() => {}); }
|
|
93
|
-
} else {
|
|
94
|
-
await createSession(config.cwd, id, undefined, config.sessionDir, true);
|
|
95
|
-
}
|
|
96
|
-
} catch (createError) {
|
|
97
|
-
if ((createError as NodeJS.ErrnoException).code !== "EEXIST") throw createError;
|
|
98
|
-
} finally { await old?.close(); }
|
|
133
|
+
try { await createSession(config.cwd, id, undefined, config.sessionDir, true); }
|
|
134
|
+
catch (createError) { if ((createError as NodeJS.ErrnoException).code !== "EEXIST") throw createError; }
|
|
99
135
|
}
|
|
100
136
|
const artifactPrompt = await messengerArtifactPrompt(config.platforms[message.platform]?.artifactDir, message, signal);
|
|
137
|
+
if (signal.aborted) throw new Error("Messenger request aborted");
|
|
101
138
|
const executable = process.execPath;
|
|
102
139
|
const sourceEntry = process.argv[1]?.endsWith(".ts") ? process.argv[1] : undefined;
|
|
103
140
|
const tools = [...new Set(config.toolNames.map(name => name === "grep" ? "search" : name === "glob" ? "find" : name))];
|
|
104
141
|
const toolFlags = tools.length ? ["--tools", tools.join(",")] : ["--no-tools"];
|
|
105
142
|
const platformConfig = config.platforms[message.platform];
|
|
106
|
-
const
|
|
143
|
+
const model = message.platform === "discord" ? await selectModel(platformConfig?.model, { signal }) : platformConfig?.model;
|
|
144
|
+
const modelFlags = model ? ["--model", model] : [];
|
|
107
145
|
const command = [executable, ...(sourceEntry ? ["--no-env-file", sourceEntry] : []), "launch", "--print", "--no-tui", "--no-skills", "--literal-worker", ...toolFlags, ...modelFlags, "--resume", id, "--", "worker"];
|
|
108
146
|
const secretNames = new Set<string>();
|
|
109
147
|
for (const platform of Object.values(config.platforms)) {
|
|
@@ -118,34 +156,62 @@ export function createMessengerCliReply(config: MessengerConfig, spawn: typeof B
|
|
|
118
156
|
const env = Object.fromEntries(Object.entries(process.env).filter(([name, value]) => value !== undefined && !secretNames.has(name))) as Record<string, string>;
|
|
119
157
|
for (const name of secretNames) env[name] = "";
|
|
120
158
|
env.JEO_SESSION_DIR = config.sessionDir;
|
|
159
|
+
delete env.JEO_MESSENGER_SANDBOX;
|
|
160
|
+
delete env.JEO_MESSENGER_ALLOWED_USER_IDS;
|
|
161
|
+
delete env.JEO_MESSENGER_SESSION_KEY;
|
|
162
|
+
if (sandbox) {
|
|
163
|
+
env.JEO_MESSENGER_SANDBOX = JSON.stringify(sandbox);
|
|
164
|
+
env.JEO_MESSENGER_ALLOWED_USER_IDS = JSON.stringify(platformConfig?.allowedUserIds ?? []);
|
|
165
|
+
env.JEO_MESSENGER_SESSION_KEY = id;
|
|
166
|
+
}
|
|
121
167
|
// The explicit tool allowlist is the opt-in for this worker only.
|
|
122
168
|
env.JEO_MESSENGER_COMPUTER_ENABLED = tools.includes("computer") ? "1" : "0";
|
|
123
169
|
// Never inherit another platform's fallback policy from the gateway environment.
|
|
124
170
|
delete env.JEO_MESSENGER_USAGE_FALLBACK_MODEL;
|
|
125
|
-
if (platformConfig?.usageFallbackModel)
|
|
171
|
+
if (platformConfig?.usageFallbackModel) {
|
|
172
|
+
if (message.platform !== "discord") env.JEO_MESSENGER_USAGE_FALLBACK_MODEL = platformConfig.usageFallbackModel;
|
|
173
|
+
else {
|
|
174
|
+
try { env.JEO_MESSENGER_USAGE_FALLBACK_MODEL = await selectModel(platformConfig.usageFallbackModel, { signal }); }
|
|
175
|
+
catch {
|
|
176
|
+
if (signal.aborted) throw new Error("Messenger request aborted");
|
|
177
|
+
console.warn("Discord usage fallback model could not be verified; fallback disabled for this request.");
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
}
|
|
126
181
|
delete env.JEO_MESSENGER_FIXED_MODEL;
|
|
127
|
-
if (
|
|
128
|
-
|
|
182
|
+
if (model) env.JEO_MESSENGER_FIXED_MODEL = "1";
|
|
183
|
+
const policyKey = message.platform === "discord" ? JSON.stringify([model, env.JEO_MESSENGER_USAGE_FALLBACK_MODEL]) : undefined;
|
|
184
|
+
return pool.turn(id, command, config.cwd, env, [message.text, artifactPrompt].filter(Boolean).join("\n\n"), signal, onActivity, policyKey);
|
|
185
|
+
};
|
|
186
|
+
const sandboxControl: NonNullable<MessengerGatewayOptions["sandboxControl"]> = async (message, signal) => {
|
|
187
|
+
if (!/^\/sandbox(?:\s|$)/.test(message.text)) return { handled: false, output: "" };
|
|
188
|
+
if (message.platform !== "discord" || !config.platforms.discord?.allowedUserIds.includes(message.senderId)) return { handled: true, output: "UNAUTHORIZED: Discord sandbox control required" };
|
|
189
|
+
try { return { handled: true, output: await sendSandboxControl(config, routeUuid(config, message), message.senderId, message.text, signal) }; }
|
|
190
|
+
catch (error) { return { handled: true, output: error instanceof Error ? error.message : "Sandbox control failed" }; }
|
|
129
191
|
};
|
|
130
|
-
return Object.assign(reply, { close: pool.close });
|
|
192
|
+
return Object.assign(reply, { close: pool.close, sandboxControl });
|
|
131
193
|
}
|
|
132
194
|
|
|
133
195
|
export async function runMessengerCommand(args: string[]): Promise<void> {
|
|
134
196
|
const { action, flags } = parse([...args]);
|
|
135
197
|
if (action === "setup") {
|
|
136
198
|
const io = terminalIO();
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const stop = () => { controller.abort(); io.close(); };
|
|
201
|
+
process.once("SIGINT", stop);
|
|
202
|
+
process.once("SIGTERM", stop);
|
|
137
203
|
try {
|
|
138
|
-
const result = await setupDiscordMessenger({ configPath: path.resolve(flags.config!), channelIds: flags.channel.length ? flags.channel : undefined, userIds: flags.user.length ? flags.user : undefined, toolNames: flags.tools?.split(",").filter(Boolean), artifacts: flags.artifacts, service: flags.service, dmTest: !flags.noDm }, io);
|
|
204
|
+
const result = await setupDiscordMessenger({ configPath: path.resolve(flags.config!), channelIds: flags.channel.length ? flags.channel : undefined, userIds: flags.user.length ? flags.user : undefined, toolNames: flags.tools?.split(",").filter(Boolean), artifacts: flags.artifacts, service: flags.service, dmTest: !flags.noDm }, io, { signal: AbortSignal.any([controller.signal, io.signal]) });
|
|
139
205
|
process.stdout.write(`Discord messenger ready: ${result.configPath}\nStart: ${result.runScript}\n`);
|
|
140
|
-
} finally { io.close(); }
|
|
206
|
+
} finally { process.off("SIGINT", stop); process.off("SIGTERM", stop); io.close(); }
|
|
141
207
|
return;
|
|
142
208
|
}
|
|
143
209
|
let config: MessengerConfig;
|
|
144
210
|
try { config = await loadMessengerConfig(flags.config!); validateMessengerEnvironment(config); }
|
|
145
|
-
catch { throw new Error("Messenger configuration or required environment is invalid"); }
|
|
211
|
+
catch (error) { throw new Error(error instanceof Error ? error.message : "Messenger configuration or required environment is invalid"); }
|
|
146
212
|
const platforms = Object.keys(config.platforms);
|
|
147
213
|
if (action === "check") { process.stdout.write(`Messenger configuration valid: ${platforms.join(", ")}; ${config.toolNames.length} tools enabled. No network checks performed.\n`); return; }
|
|
148
|
-
if (config.
|
|
214
|
+
if (config.sandbox) process.stderr.write("Messenger tools execute in session-specific containers with approved workspace mounts and no network. Sandbox failures do not fall back to host execution.\n");
|
|
149
215
|
const adapters: MessengerAdapter[] = [];
|
|
150
216
|
if (config.platforms.discord) adapters.push(createDiscordAdapter(config.platforms.discord));
|
|
151
217
|
if (config.platforms.telegram) adapters.push(createTelegramAdapter(config.platforms.telegram));
|
|
@@ -154,6 +220,6 @@ export async function runMessengerCommand(args: string[]): Promise<void> {
|
|
|
154
220
|
const stop = () => controller.abort();
|
|
155
221
|
process.once("SIGINT", stop); process.once("SIGTERM", stop);
|
|
156
222
|
const reply = createMessengerCliReply(config);
|
|
157
|
-
try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4 }).run(controller.signal); }
|
|
223
|
+
try { process.stdout.write(`Starting messenger: ${platforms.join(", ")}. Press Ctrl+C to stop.\n`); await createMessengerGateway(config, adapters, reply, { concurrency: 4, sandboxControl: reply.sandboxControl }).run(controller.signal); }
|
|
158
224
|
finally { controller.abort(); await reply.close(); process.off("SIGINT", stop); process.off("SIGTERM", stop); }
|
|
159
225
|
}
|
package/src/commands/notify.ts
CHANGED
|
@@ -175,7 +175,7 @@ async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
|
|
|
175
175
|
});
|
|
176
176
|
console.log(`Telegram bot verified; chatId=${chatId}. Notifications enabled. Setup did not send a message.`);
|
|
177
177
|
}
|
|
178
|
-
console.log("Run 'jeo daemon start' (or reload if already running) to apply. Use 'jeo notify health --provider " + flags.provider + "' for read-only API checks; 'jeo notify test --provider " + flags.provider + "' explicitly sends a test message.");
|
|
178
|
+
console.log("Setup only saves and verifies configuration; it does not start the notification daemon. Run 'jeo daemon start' (or reload if already running) to apply. Use 'jeo notify health --provider " + flags.provider + "' for read-only API checks; 'jeo notify test --provider " + flags.provider + "' explicitly sends a test message.");
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
async function runStatus(provider: Provider): Promise<void> {
|
|
@@ -188,7 +188,7 @@ async function runStatus(provider: Provider): Promise<void> {
|
|
|
188
188
|
if (provider === "slack") console.log(`appToken=${n?.slack?.appToken ? "(set)" : "(not set)"}`);
|
|
189
189
|
console.log(provider === "telegram" ? `chatId=${n?.telegram?.chatId ?? "(not set)"}` : `channelId=${n?.[provider]?.channelId ?? "(not set)"}`);
|
|
190
190
|
console.log(`allowedUserIds=${channel?.allowedUserIds?.join(",") ?? (provider === "telegram" ? "private chat owner only" : "(not set; inbound disabled)")}`);
|
|
191
|
-
console.log(`daemon=${status.pairing ? "pairing owns polling (daemon not running)" : status.running ? `process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}; transport connectivity not checked)` : status.stale ? "stale" : "stopped"}`);
|
|
191
|
+
console.log(`daemon=${status.pairing ? "pairing owns polling (daemon not running)" : status.running ? `process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}; transport connectivity not checked)` : status.stale ? "stale" : status.configured ? "stopped (configuration is saved; run 'jeo daemon start')" : "stopped (not configured)"}`);
|
|
192
192
|
console.log("Channel health not checked; use 'jeo notify health --provider " + provider + "'.");
|
|
193
193
|
}
|
|
194
194
|
|
package/src/commands/team.ts
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
1
|
import * as path from "node:path";
|
|
3
2
|
import { createHash } from "node:crypto";
|
|
4
3
|
import chalk from "chalk";
|
|
5
4
|
import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
|
|
6
5
|
import {
|
|
7
6
|
readWorkflowState,
|
|
7
|
+
readWorkflowFile,
|
|
8
8
|
readWorkflowStateStrict,
|
|
9
9
|
writeWorkflowState,
|
|
10
10
|
acquireWorkflowRunLock,
|
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
} from "../agent/subagents";
|
|
26
26
|
import { loadProjectContext } from "../agent/context-files";
|
|
27
27
|
import { categoryBadge } from "../tui/components/category-index";
|
|
28
|
+
import { currentSandboxExecution } from "../agent/hooks";
|
|
28
29
|
|
|
29
30
|
export type RalphStreamKind = "step" | "complete" | "error" | "warn";
|
|
30
31
|
|
|
@@ -220,7 +221,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<TeamE
|
|
|
220
221
|
|
|
221
222
|
let planContent = "";
|
|
222
223
|
try {
|
|
223
|
-
planContent = await
|
|
224
|
+
planContent = await readWorkflowFile(planPath, cwd);
|
|
224
225
|
} catch (err: any) {
|
|
225
226
|
log(`[ERROR] Failed to read plan file: ${err.message}`);
|
|
226
227
|
return { ok: false, reason: err.message };
|
|
@@ -332,7 +333,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<TeamE
|
|
|
332
333
|
// with `git status --porcelain` and report the CONCRETE uncommitted count so
|
|
333
334
|
// the user knows whether real partial work is present before re-running on it.
|
|
334
335
|
if (teamState.current_phase === "failed" && teamState.failed_task) {
|
|
335
|
-
const dirty =
|
|
336
|
+
const dirty = await workflowDirtyCount(cwd);
|
|
336
337
|
const treeNote = dirty === undefined
|
|
337
338
|
? `The working tree could not be inspected (not a git repo or git unavailable) — review it manually before trusting this re-run.`
|
|
338
339
|
: dirty > 0
|
|
@@ -597,11 +598,40 @@ interface ParallelWorktree {
|
|
|
597
598
|
branch: string;
|
|
598
599
|
}
|
|
599
600
|
|
|
600
|
-
function
|
|
601
|
+
async function runGit(cwd: string, args: string[]): Promise<{ ok: boolean; stdout: string; stderr: string }> {
|
|
602
|
+
const sandbox = currentSandboxExecution();
|
|
603
|
+
if (sandbox) {
|
|
604
|
+
const result = await sandbox.supervisor.exec(["git", ...args], { cwd, signal: sandbox.signal });
|
|
605
|
+
return { ok: result.success && result.exitCode === 0, stdout: result.stdout ?? result.output, stderr: result.stderr ?? result.error ?? "" };
|
|
606
|
+
}
|
|
601
607
|
const res = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
602
608
|
return { ok: res.exitCode === 0, stdout: res.stdout.toString(), stderr: res.stderr.toString() };
|
|
603
609
|
}
|
|
604
610
|
|
|
611
|
+
async function workflowDirtyCount(cwd: string): Promise<number | undefined> {
|
|
612
|
+
if (!currentSandboxExecution()) return gitDirtyCount(cwd);
|
|
613
|
+
const result = await runGit(cwd, ["status", "--porcelain"]);
|
|
614
|
+
if (!result.ok) throw new Error(result.stderr || "Sandbox git status failed");
|
|
615
|
+
return result.stdout.split("\n").filter(line => line.trim().length > 0).length;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
async function workflowWorktree(cwd: string, worktreePath: string): Promise<string> {
|
|
619
|
+
const sandbox = currentSandboxExecution();
|
|
620
|
+
if (!sandbox) return resolveWorktree(cwd, worktreePath);
|
|
621
|
+
// Only cwd is translated by exec; keep git paths relative to that guest cwd.
|
|
622
|
+
const relativePath = path.relative(cwd, worktreePath).split(path.sep).join("/");
|
|
623
|
+
const exists = await sandbox.supervisor.exec(["test", "-d", relativePath], { cwd, signal: sandbox.signal });
|
|
624
|
+
if (exists.success && exists.exitCode === 0) return worktreePath;
|
|
625
|
+
if (exists.exitCode !== 1) throw new Error(exists.error || exists.stderr || "Sandbox worktree inspection failed");
|
|
626
|
+
const branch = path.basename(worktreePath).replace(/[^a-zA-Z0-9_-]/g, "-") || "jeo-wt";
|
|
627
|
+
const created = await runGit(cwd, ["worktree", "add", "-b", branch, "--", relativePath]);
|
|
628
|
+
if (!created.ok) {
|
|
629
|
+
const attached = await runGit(cwd, ["worktree", "add", "--", relativePath]);
|
|
630
|
+
if (!attached.ok) throw new Error(attached.stderr || created.stderr || "Sandbox worktree creation failed");
|
|
631
|
+
}
|
|
632
|
+
return worktreePath;
|
|
633
|
+
}
|
|
634
|
+
|
|
605
635
|
/**
|
|
606
636
|
* Execute a CONTIGUOUS run of `parallel_group`-marked plan steps concurrently,
|
|
607
637
|
* each in its own git worktree via `resolveWorktree` + the SAME
|
|
@@ -637,8 +667,9 @@ async function runParallelGroup(
|
|
|
637
667
|
|
|
638
668
|
for (const step of group) {
|
|
639
669
|
const worktreePath = path.join(cwd, ".jeo", "team-worktrees", `${slug}-${step.index}`);
|
|
640
|
-
const resolvedPath =
|
|
641
|
-
const branchRes =
|
|
670
|
+
const resolvedPath = await workflowWorktree(cwd, worktreePath);
|
|
671
|
+
const branchRes = await runGit(resolvedPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
672
|
+
if (!branchRes.ok) throw new Error(branchRes.stderr || "Failed to resolve worktree branch");
|
|
642
673
|
const branch = branchRes.stdout.trim();
|
|
643
674
|
worktreeByIndex.set(step.index, { path: resolvedPath, branch });
|
|
644
675
|
|
|
@@ -702,13 +733,13 @@ async function runParallelGroup(
|
|
|
702
733
|
}
|
|
703
734
|
// An empty diff (read-only role, or a no-op) needs no commit — skip it for
|
|
704
735
|
// the merge step below without treating it as an error.
|
|
705
|
-
if (
|
|
706
|
-
const add =
|
|
736
|
+
if (await workflowDirtyCount(wt.path)) {
|
|
737
|
+
const add = await runGit(wt.path, ["add", "-A"]);
|
|
707
738
|
if (!add.ok) {
|
|
708
739
|
log(`${categoryBadge("error")} Failed to stage changes in worktree ${wt.path} for step "${step.name}": ${add.stderr.trim()}`);
|
|
709
740
|
return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
710
741
|
}
|
|
711
|
-
const commit =
|
|
742
|
+
const commit = await runGit(wt.path, ["commit", "-m", step.name]);
|
|
712
743
|
if (!commit.ok) {
|
|
713
744
|
log(`${categoryBadge("error")} Failed to commit changes in worktree ${wt.path} for step "${step.name}": ${commit.stderr.trim()}`);
|
|
714
745
|
return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
@@ -721,9 +752,9 @@ async function runParallelGroup(
|
|
|
721
752
|
// rest of the group's merges.
|
|
722
753
|
for (const step of group) {
|
|
723
754
|
const wt = worktreeByIndex.get(step.index)!;
|
|
724
|
-
const merge =
|
|
755
|
+
const merge = await runGit(cwd, ["merge", "--no-ff", wt.branch, "-m", `merge: ${step.name}`]);
|
|
725
756
|
if (!merge.ok) {
|
|
726
|
-
|
|
757
|
+
await runGit(cwd, ["merge", "--abort"]);
|
|
727
758
|
log(
|
|
728
759
|
`${categoryBadge("error")} Merge conflict merging step "${step.name}"'s branch "${wt.branch}" into the current branch — ` +
|
|
729
760
|
`the merge was aborted automatically (no changes were auto-resolved). Resolve manually: ` +
|
|
@@ -738,7 +769,8 @@ async function runParallelGroup(
|
|
|
738
769
|
// e.g. stray untracked files, is a warning, never a hard failure of the run).
|
|
739
770
|
for (const step of group) {
|
|
740
771
|
const wt = worktreeByIndex.get(step.index)!;
|
|
741
|
-
const
|
|
772
|
+
const worktreePath = currentSandboxExecution() ? path.relative(cwd, wt.path).split(path.sep).join("/") : wt.path;
|
|
773
|
+
const removal = await runGit(cwd, ["worktree", "remove", worktreePath]);
|
|
742
774
|
if (!removal.ok) {
|
|
743
775
|
log(`[WARN] Could not remove worktree ${wt.path} for step "${step.name}": ${removal.stderr.trim()}`);
|
|
744
776
|
}
|
|
@@ -1,7 +1,6 @@
|
|
|
1
|
-
import * as fs from "node:fs/promises";
|
|
2
1
|
import * as path from "node:path";
|
|
3
|
-
import { readWorkflowState, writeWorkflowState, getLocalJeoDir, type WorkflowState } from "../agent/state";
|
|
4
|
-
import {
|
|
2
|
+
import { readWorkflowState, writeWorkflowState, readWorkflowFile, writeWorkflowFile, getLocalJeoDir, type WorkflowState } from "../agent/state";
|
|
3
|
+
import { DEFAULT_TOOLS } from "../agent/engine";
|
|
5
4
|
import { parseSeedAcceptanceCriteria, parseCriterion } from "../agent/seed";
|
|
6
5
|
|
|
7
6
|
export interface UltragoalEngineOptions {
|
|
@@ -62,7 +61,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
|
|
|
62
61
|
|
|
63
62
|
let seedContent = "";
|
|
64
63
|
try {
|
|
65
|
-
seedContent = await
|
|
64
|
+
seedContent = await readWorkflowFile(seedPath, cwd);
|
|
66
65
|
} catch (err: any) {
|
|
67
66
|
log(`[ERROR] Failed to read seed file: ${err.message}`);
|
|
68
67
|
return { ok: false, reason: err.message };
|
|
@@ -94,7 +93,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
|
|
|
94
93
|
if (opts.onProgress) {
|
|
95
94
|
opts.onProgress({ skill: "ultragoal", phase: "verifying", detail: "Running verification suite" });
|
|
96
95
|
}
|
|
97
|
-
const suite = await
|
|
96
|
+
const suite = await DEFAULT_TOOLS.bash!({ command: "bun test" }, cwd);
|
|
98
97
|
log(` └─ Suite: ${suite.success ? "GREEN" : "FAILED"}`);
|
|
99
98
|
|
|
100
99
|
if (opts.signal?.aborted) {
|
|
@@ -111,7 +110,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
|
|
|
111
110
|
opts.onProgress({ skill: "ultragoal", phase: "verifying", detail: `Verifying: ${c.text}` });
|
|
112
111
|
}
|
|
113
112
|
log(`[CHECK] ${c.text}\n └─ $ ${c.verify}`);
|
|
114
|
-
const check = await
|
|
113
|
+
const check = await DEFAULT_TOOLS.bash!({ command: c.verify }, cwd);
|
|
115
114
|
log(` ${check.success ? "PASS" : "FAIL"}`);
|
|
116
115
|
results.push(
|
|
117
116
|
check.success
|
|
@@ -131,7 +130,6 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
|
|
|
131
130
|
|
|
132
131
|
// Write verification report
|
|
133
132
|
const reportDir = path.join(getLocalJeoDir(cwd), "state");
|
|
134
|
-
await fs.mkdir(reportDir, { recursive: true });
|
|
135
133
|
const reportPath = path.join(reportDir, "ultragoal-report.md");
|
|
136
134
|
|
|
137
135
|
const totalCount = results.length;
|
|
@@ -168,15 +166,7 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
|
|
|
168
166
|
results.map(r => `| ${r.criterion} | ${r.status === "passed" ? "✅ PASSED" : r.status === "failed" ? "❌ FAILED" : "⚠️ UNVERIFIED"} | ${r.note} |`).join("\n") +
|
|
169
167
|
`\n`;
|
|
170
168
|
|
|
171
|
-
|
|
172
|
-
const tmpReport = `${reportPath}.${Math.random().toString(36).slice(2)}.tmp`;
|
|
173
|
-
try {
|
|
174
|
-
await fs.writeFile(tmpReport, reportContent, "utf-8");
|
|
175
|
-
await fs.rename(tmpReport, reportPath);
|
|
176
|
-
} catch (err) {
|
|
177
|
-
await fs.unlink(tmpReport).catch(() => {});
|
|
178
|
-
throw err;
|
|
179
|
-
}
|
|
169
|
+
await writeWorkflowFile(reportPath, reportContent, cwd);
|
|
180
170
|
|
|
181
171
|
// Persist a machine-readable terminal phase so the chain is queryable end-to-end.
|
|
182
172
|
const ultragoalState: WorkflowState = {
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { AgentLoopEvents, ToolInvocation } from "../agent/engine";
|
|
2
2
|
import type { MessengerActivityReporter } from "./types";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
|
|
5
|
+
/** Report only fixed status text and validated tool names, never model-authored content. */
|
|
5
6
|
export function createMessengerActivityEvents(report: MessengerActivityReporter): AgentLoopEvents {
|
|
6
7
|
let step = 0;
|
|
7
8
|
return {
|
package/src/messenger/config.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import type { MessengerConfig, MessengerPlatform, MessengerPlatformConfig } from "./types";
|
|
4
|
+
import { canonicalSandboxPath, sandboxPathContains, validateSandboxConfig } from "../agent/sandbox/policy";
|
|
5
|
+
import type { SandboxConfig } from "../agent/sandbox/types";
|
|
6
|
+
import { notifyDir } from "../agent/notify/paths";
|
|
4
7
|
|
|
5
8
|
const PLATFORMS: MessengerPlatform[] = ["discord", "telegram", "slack"];
|
|
6
9
|
const TOOLS: Record<string, true> = {
|
|
@@ -13,6 +16,7 @@ const TOOLS: Record<string, true> = {
|
|
|
13
16
|
edit: true,
|
|
14
17
|
write: true,
|
|
15
18
|
computer: true,
|
|
19
|
+
sandbox_request: true,
|
|
16
20
|
};
|
|
17
21
|
|
|
18
22
|
function object(value: unknown, label: string): Record<string, unknown> {
|
|
@@ -61,11 +65,25 @@ export function resolveMessengerToken(
|
|
|
61
65
|
return undefined;
|
|
62
66
|
}
|
|
63
67
|
|
|
64
|
-
export const DEFAULT_MESSENGER_TOOLS = ["read", "find", "search", "write", "edit", "bash", "computer"];
|
|
68
|
+
export const DEFAULT_MESSENGER_TOOLS = ["read", "find", "search", "write", "edit", "bash", "computer", "sandbox_request"];
|
|
69
|
+
|
|
70
|
+
export function parseMessengerSandbox(value: unknown): SandboxConfig {
|
|
71
|
+
const input = object(value, "sandbox");
|
|
72
|
+
keys(input, ["image", "limits"], "sandbox");
|
|
73
|
+
const limits = object(input.limits, "sandbox.limits");
|
|
74
|
+
keys(limits, ["memoryBytes", "pids", "cpus"], "sandbox.limits");
|
|
75
|
+
const config: SandboxConfig = {
|
|
76
|
+
image: string(input.image, "sandbox.image"),
|
|
77
|
+
limits: { memoryBytes: limits.memoryBytes as number, pids: limits.pids as number, cpus: limits.cpus as number },
|
|
78
|
+
};
|
|
79
|
+
validateSandboxConfig(config);
|
|
80
|
+
return config;
|
|
81
|
+
}
|
|
65
82
|
|
|
66
83
|
export function parseMessengerConfig(value: unknown, baseDir: string): MessengerConfig {
|
|
67
84
|
const input = object(value, "Messenger config");
|
|
68
|
-
|
|
85
|
+
if (Object.hasOwn(input, "independentSessions")) throw new Error("independentSessions is obsolete; run messenger setup to migrate to persistent conversation sessions");
|
|
86
|
+
keys(input, ["cwd", "sessionDir", "sandbox", "toolNames", "platforms"], "Messenger config");
|
|
69
87
|
const platformsInput = object(input.platforms, "platforms");
|
|
70
88
|
keys(platformsInput, PLATFORMS, "platforms");
|
|
71
89
|
if (Object.keys(platformsInput).length === 0) throw new Error("At least one messenger platform is required");
|
|
@@ -108,19 +126,42 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
|
|
|
108
126
|
) {
|
|
109
127
|
throw new Error("toolNames must be a unique list drawn from read, grep, glob, find, search, bash, edit, write, computer");
|
|
110
128
|
}
|
|
111
|
-
const
|
|
129
|
+
const sandbox = input.sandbox !== undefined || platforms.discord ? parseMessengerSandbox(input.sandbox) : undefined;
|
|
130
|
+
const cwd = path.resolve(baseDir, input.cwd === undefined ? "." : string(input.cwd, "cwd"));
|
|
131
|
+
const sessionDir = input.sessionDir === undefined ? path.join(notifyDir(), "messenger-sessions") : path.resolve(baseDir, string(input.sessionDir, "sessionDir"));
|
|
132
|
+
if (sandbox && (sandboxPathContains(cwd, sessionDir) || sandboxPathContains(sessionDir, cwd))) {
|
|
133
|
+
throw new Error("sessionDir must be outside and disjoint from the sandbox workspace");
|
|
134
|
+
}
|
|
112
135
|
return {
|
|
113
|
-
cwd
|
|
114
|
-
sessionDir
|
|
115
|
-
baseDir,
|
|
116
|
-
input.sessionDir === undefined ? ".jeo/messenger-sessions" : string(input.sessionDir, "sessionDir"),
|
|
117
|
-
),
|
|
136
|
+
cwd,
|
|
137
|
+
sessionDir,
|
|
118
138
|
toolNames: [...toolNames] as string[],
|
|
139
|
+
...(sandbox ? { sandbox } : {}),
|
|
119
140
|
platforms,
|
|
120
|
-
independentSessions,
|
|
121
141
|
};
|
|
122
142
|
}
|
|
123
143
|
|
|
144
|
+
export async function validateMessengerStorage(config: MessengerConfig): Promise<void> {
|
|
145
|
+
if (!config.sandbox && !config.platforms.discord) return;
|
|
146
|
+
const [cwd, sessionDir] = await Promise.all([canonicalSandboxPath(config.cwd), canonicalSandboxPath(config.sessionDir, false)]);
|
|
147
|
+
if (sandboxPathContains(cwd, sessionDir) || sandboxPathContains(sessionDir, cwd)) {
|
|
148
|
+
throw new Error("sessionDir must be outside and disjoint from the sandbox workspace");
|
|
149
|
+
}
|
|
150
|
+
const pending = [path.join(cwd, ".jeo", "sessions"), path.join(cwd, ".jeo", "messenger-sessions"), path.join(cwd, ".jeo", "memory", "raw")];
|
|
151
|
+
while (pending.length) {
|
|
152
|
+
const directory = pending.pop()!;
|
|
153
|
+
let entries;
|
|
154
|
+
try { entries = await fs.readdir(directory, { withFileTypes: true }); }
|
|
155
|
+
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") continue; throw error; }
|
|
156
|
+
for (const entry of entries) {
|
|
157
|
+
if (/\.(?:jsonl|json)$/i.test(entry.name) || entry.isSymbolicLink()) {
|
|
158
|
+
throw new Error("Legacy session transcripts or raw history may be exposed in the sandbox workspace; move existing private history outside the workspace before starting the sandbox");
|
|
159
|
+
}
|
|
160
|
+
if (entry.isDirectory()) pending.push(path.join(directory, entry.name));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
124
165
|
export async function loadMessengerConfig(file: string): Promise<MessengerConfig> {
|
|
125
166
|
let value: unknown;
|
|
126
167
|
try {
|
|
@@ -129,6 +170,7 @@ export async function loadMessengerConfig(file: string): Promise<MessengerConfig
|
|
|
129
170
|
throw new Error("Cannot read messenger configuration as JSON");
|
|
130
171
|
}
|
|
131
172
|
const config = parseMessengerConfig(value, path.dirname(path.resolve(file)));
|
|
173
|
+
await validateMessengerStorage(config);
|
|
132
174
|
const artifactDir = config.platforms.discord?.artifactDir;
|
|
133
175
|
if (artifactDir) {
|
|
134
176
|
const stat = await fs.lstat(artifactDir).catch(() => undefined);
|