jeo-code 0.12.18 → 0.12.21
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 +28 -1
- package/README.ja.md +3 -3
- package/README.ko.md +3 -3
- package/README.md +3 -3
- package/README.zh.md +3 -3
- package/package.json +1 -1
- package/src/agent/AGENTS.md +2 -1
- package/src/agent/engine.ts +55 -11
- package/src/agent/loop-guards.ts +101 -21
- package/src/agent/sandbox/hands-source.text +590 -590
- package/src/agent/task-tool.ts +4 -1
- package/src/agent/workspace-snapshot.ts +209 -0
- package/src/cli/runner.ts +1 -1
- package/src/commands/launch.ts +32 -13
- package/src/commands/messenger.ts +27 -7
- package/src/commands/update.ts +30 -1
- package/src/messenger/discord-setup.ts +42 -72
- package/src/messenger/gateway.ts +20 -8
- package/src/messenger/launcher.ts +68 -0
- package/src/messenger/runtime.ts +255 -0
- package/src/messenger/service.ts +40 -2
- package/src/messenger/session-pool.ts +50 -4
- package/src/messenger/types.ts +8 -0
- package/src/util/slack.ts +40 -0
package/src/messenger/service.ts
CHANGED
|
@@ -72,6 +72,44 @@ WantedBy=default.target
|
|
|
72
72
|
`;
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
/** Where the user service definition lives on this platform, or undefined where services are unsupported. */
|
|
76
|
+
export function messengerServiceFile(plan: MessengerServicePlan, platform: NodeJS.Platform = process.platform, home: string = os.homedir()): string | undefined {
|
|
77
|
+
if (platform === "darwin") return path.join(home, "Library", "LaunchAgents", `${plan.label}.plist`);
|
|
78
|
+
if (platform === "linux") return path.join(home, ".config", "systemd", "user", `${plan.label}.service`);
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Restart an installed service so it picks up a rewritten launcher. Returns `"not installed"` when no
|
|
84
|
+
* definition exists; a definition that cannot be restarted is an error, never a silent stale service.
|
|
85
|
+
*/
|
|
86
|
+
export async function restartMessengerService(
|
|
87
|
+
plan: MessengerServicePlan,
|
|
88
|
+
platform: NodeJS.Platform = process.platform,
|
|
89
|
+
home: string = os.homedir(),
|
|
90
|
+
): Promise<"restarted" | "not installed"> {
|
|
91
|
+
const file = messengerServiceFile(plan, platform, home);
|
|
92
|
+
if (!file || !(await fs.stat(file).then(stat => stat.isFile(), () => false))) return "not installed";
|
|
93
|
+
if (platform === "darwin") {
|
|
94
|
+
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
95
|
+
// kickstart -k restarts a loaded job; a definition that is on disk but unloaded is bootstrapped instead.
|
|
96
|
+
if ((await $`launchctl kickstart -k ${domain}/${plan.label}`.quiet().nothrow()).exitCode !== 0) {
|
|
97
|
+
if ((await $`launchctl bootstrap ${domain} ${file}`.quiet().nothrow()).exitCode !== 0) throw new Error(`launchctl could not restart ${plan.label}`);
|
|
98
|
+
}
|
|
99
|
+
for (let attempt = 0; attempt < 20; attempt++) {
|
|
100
|
+
const status = await $`launchctl print ${domain}/${plan.label}`.quiet().nothrow();
|
|
101
|
+
if (status.exitCode === 0 && /\bstate = running\b/.test(status.text())) return "restarted";
|
|
102
|
+
await Bun.sleep(500);
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`launchd job ${plan.label} did not reach the running state; see ${plan.logFile}`);
|
|
105
|
+
}
|
|
106
|
+
const unit = `${plan.label}.service`;
|
|
107
|
+
if ((await $`systemctl --user restart ${unit}`.quiet().nothrow()).exitCode !== 0) throw new Error(`systemctl --user could not restart ${unit}`);
|
|
108
|
+
const active = await $`systemctl --user is-active ${unit}`.quiet().nothrow();
|
|
109
|
+
if (active.text().trim() !== "active") throw new Error(`${unit} is not active; see ${plan.logFile}`);
|
|
110
|
+
return "restarted";
|
|
111
|
+
}
|
|
112
|
+
|
|
75
113
|
/**
|
|
76
114
|
* Install (or replace) and start the service, then confirm the platform reports it running.
|
|
77
115
|
* Returns a one-line description of where it was installed.
|
|
@@ -82,7 +120,7 @@ export async function installMessengerService(
|
|
|
82
120
|
home: string = os.homedir(),
|
|
83
121
|
): Promise<string> {
|
|
84
122
|
if (platform === "darwin") {
|
|
85
|
-
const file =
|
|
123
|
+
const file = messengerServiceFile(plan, platform, home)!;
|
|
86
124
|
await Bun.write(file, launchdPlist(plan));
|
|
87
125
|
const domain = `gui/${process.getuid?.() ?? 0}`;
|
|
88
126
|
await $`launchctl bootout ${domain} ${file}`.quiet().nothrow();
|
|
@@ -103,7 +141,7 @@ export async function installMessengerService(
|
|
|
103
141
|
}
|
|
104
142
|
if (platform === "linux") {
|
|
105
143
|
const unit = `${plan.label}.service`;
|
|
106
|
-
const file =
|
|
144
|
+
const file = messengerServiceFile(plan, platform, home)!;
|
|
107
145
|
await fs.mkdir(path.dirname(file), { recursive: true });
|
|
108
146
|
await Bun.write(file, systemdUnit(plan));
|
|
109
147
|
const reload = await $`systemctl --user daemon-reload`.quiet().nothrow();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { MessengerActivityReporter } from "./types";
|
|
2
|
+
import { MessengerUnverifiedReplyError } from "./types";
|
|
2
3
|
|
|
3
4
|
interface WorkerProcess {
|
|
4
5
|
stdin: { write(data: string): unknown; flush(): unknown; end(): unknown };
|
|
@@ -15,6 +16,8 @@ interface Pending {
|
|
|
15
16
|
reject(error: Error): void;
|
|
16
17
|
report?: MessengerActivityReporter;
|
|
17
18
|
result?: string;
|
|
19
|
+
/** The result is an UNVERIFIED-marked answer: the turn rejects with it while the worker keeps serving. */
|
|
20
|
+
unverified?: boolean;
|
|
18
21
|
written?: boolean;
|
|
19
22
|
}
|
|
20
23
|
|
|
@@ -32,18 +35,39 @@ interface Worker {
|
|
|
32
35
|
cleanupError?: Error;
|
|
33
36
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
34
37
|
exitTimer?: ReturnType<typeof setTimeout>;
|
|
38
|
+
/** Diagnostic lines already forwarded for this worker. */
|
|
39
|
+
diagnostics: number;
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
/** Resident sessions the pool keeps, and the number of turns it runs at once. */
|
|
38
43
|
export const MESSENGER_SESSION_LIMIT = 16;
|
|
44
|
+
/** Operator-visible worker stderr is bounded per worker so a chatty worker cannot flood the gateway log. */
|
|
45
|
+
export const MESSENGER_WORKER_DIAGNOSTIC_LINES = 20;
|
|
46
|
+
const MAX_DIAGNOSTIC_LINE_LENGTH = 300;
|
|
39
47
|
|
|
40
|
-
export
|
|
48
|
+
export interface MessengerSessionPoolOptions {
|
|
49
|
+
requireSandboxCleanup?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Receives bounded, non-protocol worker stderr lines and abnormal exits for the operator log. Worker
|
|
52
|
+
* environments carry no platform tokens, and the conversation key is a route digest, never message text.
|
|
53
|
+
*/
|
|
54
|
+
onDiagnostic?: (key: string, line: string) => void;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** One printable, bounded line: control characters collapse to spaces. */
|
|
58
|
+
function diagnosticLine(text: string): string {
|
|
59
|
+
const line = text.replace(/[\u0000-\u001f\u007f-\u009f]+/g, " ").trim();
|
|
60
|
+
return line.length > MAX_DIAGNOSTIC_LINE_LENGTH ? `${line.slice(0, MAX_DIAGNOSTIC_LINE_LENGTH - 1)}…` : line;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn, limit = MESSENGER_SESSION_LIMIT, idleTimeoutMs = 5 * 60_000, shutdownTimeoutMs = 5_000, options: MessengerSessionPoolOptions = {}) {
|
|
41
64
|
const workers = new Map<string, Worker>();
|
|
42
65
|
const allWorkers = new Map<Worker, string>();
|
|
43
66
|
const exits = new Map<Promise<number>, Worker>();
|
|
44
67
|
const retiring = new Map<string, Promise<number>>();
|
|
45
68
|
const keyTails = new Map<string, Promise<void>>();
|
|
46
69
|
const requireSandboxCleanup = options.requireSandboxCleanup === true;
|
|
70
|
+
const onDiagnostic = options.onDiagnostic;
|
|
47
71
|
const incomplete = (detail: string) => new Error(`REVOKE_INCOMPLETE: ${detail}`);
|
|
48
72
|
|
|
49
73
|
let closed = false;
|
|
@@ -138,7 +162,8 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
138
162
|
worker.idleTimer = setTimeout(() => retire(key, worker), idleTimeoutMs);
|
|
139
163
|
worker.idleTimer.unref();
|
|
140
164
|
}
|
|
141
|
-
pending.
|
|
165
|
+
if (pending.unverified) pending.reject(new MessengerUnverifiedReplyError(pending.result));
|
|
166
|
+
else pending.resolve(pending.result);
|
|
142
167
|
}
|
|
143
168
|
|
|
144
169
|
async function readLines(stream: ReadableStream<Uint8Array>, handle: (line: string) => void) {
|
|
@@ -191,7 +216,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
191
216
|
const spawnOptions = { cwd, env: workerEnv, stdin: "pipe", stdout: "pipe", stderr: "pipe", ...(cleanupNonce ? { ipc() {}, serialization: "json" } : {}) };
|
|
192
217
|
const child = spawn(command, spawnOptions as never) as unknown as WorkerProcess;
|
|
193
218
|
const cleanup = Promise.withResolvers<number>();
|
|
194
|
-
const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false };
|
|
219
|
+
const worker: Worker = { child, exit: cleanupNonce ? cleanup.promise : child.exited, cleanupNonce, policyKey, lastUsed: ++tick, dead: false, diagnostics: 0 };
|
|
220
|
+
const diagnose = (text: string): void => {
|
|
221
|
+
if (!onDiagnostic || worker.diagnostics > MESSENGER_WORKER_DIAGNOSTIC_LINES) return;
|
|
222
|
+
const line = diagnosticLine(text);
|
|
223
|
+
if (!line) return;
|
|
224
|
+
worker.diagnostics++;
|
|
225
|
+
try { onDiagnostic(key, worker.diagnostics > MESSENGER_WORKER_DIAGNOSTIC_LINES ? "further worker output suppressed" : line); }
|
|
226
|
+
catch { /* The operator log never affects the turn. */ }
|
|
227
|
+
};
|
|
228
|
+
// An exit during a turn, or a non-zero exit, explains the user-visible failure; a clean idle exit is routine.
|
|
229
|
+
void child.exited.then(code => { if (code !== 0 || worker.pending) diagnose(`worker exited with code ${code}${worker.pending ? " during a turn" : ""}`); }, () => {});
|
|
195
230
|
exits.set(worker.exit, worker);
|
|
196
231
|
void worker.exit.then(() => {
|
|
197
232
|
exits.delete(worker.exit);
|
|
@@ -203,6 +238,17 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
203
238
|
const pending = worker.pending;
|
|
204
239
|
if (!pending) return;
|
|
205
240
|
if (line === "JEO_MESSENGER_ERROR") { retire(key, worker); return; }
|
|
241
|
+
// An exhausted completion gate is a controlled turn end: deliver the marked answer, keep the worker.
|
|
242
|
+
if (line.startsWith("JEO_MESSENGER_UNVERIFIED ")) {
|
|
243
|
+
try {
|
|
244
|
+
const text: unknown = JSON.parse(line.slice("JEO_MESSENGER_UNVERIFIED ".length));
|
|
245
|
+
if (typeof text !== "string" || !text.trim()) throw new Error("Invalid response");
|
|
246
|
+
pending.result = text;
|
|
247
|
+
pending.unverified = true;
|
|
248
|
+
finishTurn(key, worker, pending);
|
|
249
|
+
} catch { retire(key, worker); }
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
206
252
|
if (!line.startsWith("JEO_MESSENGER_RESULT ")) return;
|
|
207
253
|
try {
|
|
208
254
|
const text: unknown = JSON.parse(line.slice("JEO_MESSENGER_RESULT ".length));
|
|
@@ -227,7 +273,7 @@ export function createMessengerSessionPool(spawn: typeof Bun.spawn = Bun.spawn,
|
|
|
227
273
|
if (activityText) worker.pending?.report?.(activityText);
|
|
228
274
|
} else if (line === "JEO_MESSENGER_STEP") {
|
|
229
275
|
worker.pending?.report?.("Working on your request");
|
|
230
|
-
}
|
|
276
|
+
} else diagnose(line);
|
|
231
277
|
}).catch(() => retire(key, worker));
|
|
232
278
|
if (cleanupNonce) {
|
|
233
279
|
void Promise.all([child.exited, stderrDone, stdoutDone]).then(([code]) => {
|
package/src/messenger/types.ts
CHANGED
|
@@ -78,6 +78,14 @@ export class MessengerFatalError extends Error {
|
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
81
|
+
/** The agent turn ended without verified completion; `reply` is the UNVERIFIED-marked answer for the user. */
|
|
82
|
+
export class MessengerUnverifiedReplyError extends Error {
|
|
83
|
+
constructor(readonly reply: string) {
|
|
84
|
+
super("Messenger agent turn ended without verified completion");
|
|
85
|
+
this.name = "MessengerUnverifiedReplyError";
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
81
89
|
export function isRecord(value: unknown): value is Record<string, unknown> {
|
|
82
90
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
91
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slack-style direct-request detection (ops/specs/slack-autopilot.md).
|
|
3
|
+
*
|
|
4
|
+
* Goal: free-form natural-language input ("fix the build error") routes to
|
|
5
|
+
* the interactive agent, while command invocations (`joc status`), flags,
|
|
6
|
+
* and single-token typos still reach the normal dispatcher so command
|
|
7
|
+
* resolution and "did you mean" suggestions keep working.
|
|
8
|
+
*
|
|
9
|
+
* Pure, domain-agnostic helpers only (src/util convention).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/** Command-shaped token: `status`, `evolve-core`, `skills:list`, … */
|
|
13
|
+
const COMMAND_SHAPED = /^[a-z][a-z0-9:_-]*$/i;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Slack command-prefix check: `/deploy` and `!ops` style messages are
|
|
17
|
+
* explicit commands, never direct requests.
|
|
18
|
+
*/
|
|
19
|
+
export function isSlackDM(text: string): boolean {
|
|
20
|
+
return !text.startsWith("/") && !text.startsWith("!");
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* True when the argv tokens look like a free-form request rather than a
|
|
25
|
+
* CLI command invocation.
|
|
26
|
+
*
|
|
27
|
+
* - empty / flags-only input → false (dispatcher owns defaults)
|
|
28
|
+
* - `/cmd` or `!cmd` prefix → false (explicit command style)
|
|
29
|
+
* - single command-shaped token → false (could be a typo of a real
|
|
30
|
+
* command; the dispatcher must keep its suggestion path)
|
|
31
|
+
* - anything else (multi-word sentences, CJK text, punctuation) → true
|
|
32
|
+
*/
|
|
33
|
+
export function isDirectRequest(tokens: readonly string[]): boolean {
|
|
34
|
+
const words = tokens.filter(t => !t.startsWith("-"));
|
|
35
|
+
if (words.length === 0) return false;
|
|
36
|
+
const first = words[0];
|
|
37
|
+
if (!isSlackDM(first)) return false;
|
|
38
|
+
if (words.length === 1 && COMMAND_SHAPED.test(first)) return false;
|
|
39
|
+
return true;
|
|
40
|
+
}
|