jeo-code 0.12.10 → 0.12.12
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 +12 -0
- package/README.ja.md +17 -12
- package/README.ko.md +86 -27
- package/README.md +76 -20
- package/README.zh.md +17 -12
- package/package.json +2 -1
- package/src/agent/ast-edit-tool.ts +3 -1
- package/src/agent/engine.ts +114 -16
- package/src/agent/goal-tool.ts +7 -1
- package/src/agent/goal-verifier.ts +95 -16
- package/src/agent/loop-guards.ts +10 -9
- package/src/agent/lsp-rename-tool.ts +3 -1
- package/src/agent/notify/discord-daemon.ts +3 -1
- package/src/agent/notify/paths.ts +8 -0
- package/src/agent/notify/session-endpoint.ts +23 -4
- package/src/agent/notify/slack-daemon.ts +2 -2
- package/src/agent/notify/telegram-daemon.ts +223 -89
- package/src/agent/notify/topic-registry.ts +118 -15
- package/src/agent/skill-router.ts +31 -5
- package/src/agent/subagent-registry.ts +87 -5
- package/src/agent/subagent-tool.ts +133 -18
- package/src/agent/subagents.ts +7 -5
- package/src/agent/task-tool.ts +85 -14
- package/src/agent/tools.ts +14 -4
- package/src/cli.ts +1 -0
- package/src/commands/launch.ts +145 -17
- package/src/commands/messenger.ts +5 -1
- package/src/commands/notify.ts +5 -6
- package/src/commands/skills.ts +9 -0
- package/src/commands/team.ts +72 -53
- package/src/messenger/activity.ts +13 -17
- package/src/messenger/config.ts +6 -2
- package/src/messenger/discord-setup.ts +3 -1
- package/src/messenger/discord.ts +16 -19
- package/src/messenger/gateway.ts +7 -8
- package/src/messenger/session-pool.ts +99 -13
- package/src/messenger/types.ts +4 -5
- package/src/skills/catalog.ts +19 -2
|
@@ -7,7 +7,7 @@ import { loadMessengerConfig, validateMessengerEnvironment } from "../messenger/
|
|
|
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 } from "../messenger/gateway";
|
|
10
|
+
import { createMessengerGateway, messengerSessionRoute, messengerConversationKey } 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";
|
|
@@ -57,6 +57,10 @@ function terminalIO() {
|
|
|
57
57
|
}
|
|
58
58
|
|
|
59
59
|
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
|
+
}
|
|
60
64
|
const hex = messengerSessionRoute(config.cwd, message);
|
|
61
65
|
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-4${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
62
66
|
}
|
package/src/commands/notify.ts
CHANGED
|
@@ -15,7 +15,6 @@ interface NotifyOptions {
|
|
|
15
15
|
}
|
|
16
16
|
interface Flags {
|
|
17
17
|
provider: Provider;
|
|
18
|
-
token?: string;
|
|
19
18
|
tokenEnv?: string;
|
|
20
19
|
appTokenEnv?: string;
|
|
21
20
|
chatId?: string;
|
|
@@ -27,13 +26,13 @@ function parseFlags(args: string[]): Flags {
|
|
|
27
26
|
const out: Flags = { provider: "telegram" };
|
|
28
27
|
for (let i = 0; i < args.length; i++) {
|
|
29
28
|
const flag = args[i];
|
|
29
|
+
if (flag === "--token") throw new Error("Raw --token credentials are not supported; use --token-env NAME");
|
|
30
30
|
const value = args[++i];
|
|
31
31
|
if (!value || value.startsWith("--")) throw new Error("Missing value for notify option (use --token-env NAME for credentials)");
|
|
32
32
|
if (flag === "--provider" || flag === "--platform") {
|
|
33
33
|
if (value !== "telegram" && value !== "discord" && value !== "slack") throw new Error("Provider must be telegram, discord or slack");
|
|
34
34
|
out.provider = value;
|
|
35
|
-
} else if (flag === "--token") out.
|
|
36
|
-
else if (flag === "--token-env") out.tokenEnv = value;
|
|
35
|
+
} else if (flag === "--token-env") out.tokenEnv = value;
|
|
37
36
|
else if (flag === "--app-token-env") out.appTokenEnv = value;
|
|
38
37
|
else if (flag === "--chat-id") out.chatId = value;
|
|
39
38
|
else if (flag === "--channel-id") out.channelId = value;
|
|
@@ -73,12 +72,12 @@ async function verifySlack(api: SlackApi, channelId: string): Promise<void> {
|
|
|
73
72
|
|
|
74
73
|
async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
|
|
75
74
|
const envName = flags.tokenEnv ?? `JEO_${flags.provider.toUpperCase()}_BOT_TOKEN`;
|
|
76
|
-
let token =
|
|
75
|
+
let token = process.env[envName]?.trim();
|
|
77
76
|
if (!token) {
|
|
78
77
|
const name = await question(`Set the bot token in an environment variable, then enter its NAME (default ${envName}): `, opts);
|
|
79
78
|
token = process.env[name || envName]?.trim();
|
|
80
79
|
}
|
|
81
|
-
if (!token) throw new Error(
|
|
80
|
+
if (!token) throw new Error("Bot token environment variable is empty; use --token-env NAME");
|
|
82
81
|
const existing = (await readGlobalConfig()).notifications;
|
|
83
82
|
let allowedUserIds = flags.allowedUserIds;
|
|
84
83
|
if (flags.provider === "slack") {
|
|
@@ -237,7 +236,7 @@ export async function runNotifyCommand(args: string[], opts: NotifyOptions = {})
|
|
|
237
236
|
} catch (error) {
|
|
238
237
|
// Only emit our known configuration/API errors, never fetch URLs or token-bearing bodies.
|
|
239
238
|
const message = error instanceof Error ? error.message : "request failed";
|
|
240
|
-
const safe = /^(Missing |Unknown notify|Provider |Allowed user|Telegram |Discord |Slack |Bot token|Missing setup|A daemon|Notification lock recovery|Usage:)/.test(message) && !message.includes("https:") && !message.includes("http:");
|
|
239
|
+
const safe = /^(Missing |Unknown notify|Raw --token|Provider |Allowed user|Telegram |Discord |Slack |Bot token|Missing setup|A daemon|Notification lock recovery|Usage:)/.test(message) && !message.includes("https:") && !message.includes("http:");
|
|
241
240
|
console.error(`Notification ${sub} failed: ${safe ? message : "API or storage request failed; check credentials, connectivity and permissions"}${(error instanceof DiscordApiError || error instanceof SlackApiError) && error.status ? ` (HTTP ${error.status})` : ""}`);
|
|
242
241
|
process.exitCode = 1;
|
|
243
242
|
}
|
package/src/commands/skills.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
2
|
import * as path from "node:path";
|
|
3
3
|
import { SKILLS, getSkillFrom, formatSkill, loadSkills, skillDirs, bundledSkillFileContent, userSkillsDir } from "../skills/catalog";
|
|
4
|
+
import { inventorySkillConflicts } from "../agent/skill-router";
|
|
4
5
|
import { getLocalJeoDir } from "../agent/state";
|
|
5
6
|
import { appendSkillLesson, evalSkillLessons, type SkillLesson } from "../agent/skill-lessons";
|
|
6
7
|
|
|
@@ -221,6 +222,14 @@ export async function runSkillsCommand(args: string[] = []): Promise<void> {
|
|
|
221
222
|
console.log("Install/inspect bundled defaults: 'jeo skills sync' (preserve local) · '--check' (drift report) · '--force' (overwrite local)");
|
|
222
223
|
console.log("Discovery dirs (later wins on name clash; JEO_SKILLS_DIR adds more):");
|
|
223
224
|
for (const d of skillDirs(cwd)) console.log(` ${d}`);
|
|
225
|
+
const conflicts = inventorySkillConflicts(skills);
|
|
226
|
+
if (conflicts.length > 0) {
|
|
227
|
+
console.log("\nECC Single-Harness duplicate identity warnings:");
|
|
228
|
+
for (const c of conflicts) {
|
|
229
|
+
console.log(` ! Duplicate skill '${c.normalized}' installed across multiple distinct paths:`);
|
|
230
|
+
for (const p of c.paths) console.log(` ${p}`);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
224
233
|
console.log("");
|
|
225
234
|
}
|
|
226
235
|
return;
|
package/src/commands/team.ts
CHANGED
|
@@ -168,8 +168,14 @@ export interface TeamEngineOptions {
|
|
|
168
168
|
* write/edit/bash fails the task instead of merely warning (round-11). */
|
|
169
169
|
strictMutations?: boolean;
|
|
170
170
|
}
|
|
171
|
+
export interface TeamEngineResult {
|
|
172
|
+
ok: boolean;
|
|
173
|
+
reason?: string;
|
|
174
|
+
/** True when a delegated child may have changed workspace state before settling. */
|
|
175
|
+
mutationMayHaveOccurred?: boolean;
|
|
176
|
+
}
|
|
171
177
|
|
|
172
|
-
export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<
|
|
178
|
+
export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<TeamEngineResult> {
|
|
173
179
|
const cwd = opts.cwd ?? process.cwd();
|
|
174
180
|
|
|
175
181
|
const log = (msg?: any) => {
|
|
@@ -339,6 +345,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
339
345
|
|
|
340
346
|
await writeWorkflowState("team", teamState, cwd);
|
|
341
347
|
const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
|
|
348
|
+
let mutationMayHaveOccurred = false;
|
|
342
349
|
for (const line of formatRalphTodoGuide(tasks, activeStepIndex(tasks.length, teamState.pending_tasks), teamState.completed_tasks ?? [], renderOpts)) log(line);
|
|
343
350
|
|
|
344
351
|
while (teamState.pending_tasks && teamState.pending_tasks.length > 0) {
|
|
@@ -384,20 +391,22 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
384
391
|
});
|
|
385
392
|
|
|
386
393
|
if (opts.signal?.aborted) {
|
|
387
|
-
return { ok: false, reason: "aborted" };
|
|
394
|
+
return { ok: false, reason: "aborted", ...(mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}) };
|
|
388
395
|
}
|
|
389
396
|
|
|
390
397
|
if (groupResult.ok) {
|
|
398
|
+
mutationMayHaveOccurred ||= groupResult.mutationMayHaveOccurred === true;
|
|
391
399
|
teamState.completed_tasks = [...(teamState.completed_tasks ?? []), ...groupSteps.map(s => s.name)];
|
|
392
400
|
teamState.pending_tasks = teamState.pending_tasks.slice(groupSteps.length);
|
|
393
401
|
await writeWorkflowState("team", teamState, cwd);
|
|
394
402
|
log(`${categoryBadge("done")} Completed parallel group: ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
|
|
395
403
|
} else {
|
|
404
|
+
mutationMayHaveOccurred ||= groupResult.mutationMayHaveOccurred === true;
|
|
396
405
|
teamState.current_phase = "failed";
|
|
397
406
|
teamState.failed_task = groupResult.failedTaskName;
|
|
398
407
|
await writeWorkflowState("team", teamState, cwd);
|
|
399
408
|
log(`${categoryBadge("error")} Failed on parallel group step: "${groupResult.failedTaskName}". Halting execution.`);
|
|
400
|
-
return { ok: false, reason: `Failed on task: "${groupResult.failedTaskName}"
|
|
409
|
+
return { ok: false, reason: `Failed on task: "${groupResult.failedTaskName}"`, ...(mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}) };
|
|
401
410
|
}
|
|
402
411
|
continue;
|
|
403
412
|
}
|
|
@@ -409,7 +418,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
409
418
|
opts.onProgress({ skill: "team", phase: "executing", detail: `Current task: ${currentTask}` });
|
|
410
419
|
}
|
|
411
420
|
|
|
412
|
-
const
|
|
421
|
+
const taskResult = await executeTaskWithAgent({
|
|
413
422
|
task: currentTask,
|
|
414
423
|
tasks,
|
|
415
424
|
activeIndex,
|
|
@@ -419,12 +428,13 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
419
428
|
strictMutations: opts.strictMutations ?? false,
|
|
420
429
|
log,
|
|
421
430
|
});
|
|
431
|
+
mutationMayHaveOccurred ||= taskResult.mutationMayHaveOccurred === true;
|
|
422
432
|
|
|
423
433
|
if (opts.signal?.aborted) {
|
|
424
|
-
return { ok: false, reason: "aborted" };
|
|
434
|
+
return { ok: false, reason: "aborted", ...(mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}) };
|
|
425
435
|
}
|
|
426
436
|
|
|
427
|
-
if (
|
|
437
|
+
if (taskResult.ok) {
|
|
428
438
|
teamState.completed_tasks = [...(teamState.completed_tasks ?? []), currentTask];
|
|
429
439
|
teamState.pending_tasks = teamState.pending_tasks.slice(1);
|
|
430
440
|
await writeWorkflowState("team", teamState, cwd);
|
|
@@ -436,7 +446,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
436
446
|
teamState.failed_task = currentTask;
|
|
437
447
|
await writeWorkflowState("team", teamState, cwd);
|
|
438
448
|
log(`${categoryBadge("error")} Failed on task: "${currentTask}". Halting execution.`);
|
|
439
|
-
return { ok: false, reason: `Failed on task: "${currentTask}"
|
|
449
|
+
return { ok: false, reason: `Failed on task: "${currentTask}"`, ...(mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}) };
|
|
440
450
|
}
|
|
441
451
|
}
|
|
442
452
|
|
|
@@ -450,7 +460,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
450
460
|
opts.onProgress({ skill: "team", phase: "complete" });
|
|
451
461
|
}
|
|
452
462
|
}
|
|
453
|
-
return { ok: true };
|
|
463
|
+
return { ok: true, ...(mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}) };
|
|
454
464
|
} finally {
|
|
455
465
|
await releaseLock();
|
|
456
466
|
}
|
|
@@ -464,7 +474,11 @@ export async function runTeamCommand(args: string[] = []): Promise<void> {
|
|
|
464
474
|
}
|
|
465
475
|
}
|
|
466
476
|
|
|
467
|
-
|
|
477
|
+
interface TeamTaskResult {
|
|
478
|
+
ok: boolean;
|
|
479
|
+
mutationMayHaveOccurred?: boolean;
|
|
480
|
+
}
|
|
481
|
+
async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: string; roleId?: string; strictMutations?: boolean; log: (line: string) => void }): Promise<TeamTaskResult> {
|
|
468
482
|
const log = ctx.log;
|
|
469
483
|
const config = await readGlobalConfig();
|
|
470
484
|
const role = getSubagentRole(ctx.roleId, config) ?? defaultSubagentRole();
|
|
@@ -476,25 +490,33 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
476
490
|
const projectContext = await loadProjectContext(ctx.cwd);
|
|
477
491
|
|
|
478
492
|
// `jeo team` now runs every step through the SAME execution core as the `task`/
|
|
479
|
-
// `subagent` tools
|
|
480
|
-
//
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
})
|
|
493
|
+
// `subagent` tools — one way a jeo-code subagent runs, whether launched
|
|
494
|
+
// interactively, in a fan-out batch, detached, or as a team plan step.
|
|
495
|
+
let result: SubagentRunResult;
|
|
496
|
+
try {
|
|
497
|
+
result = await runSubagentOnce(role, buildRalphSubagentPrompt(ctx), "", ctx.cwd, {
|
|
498
|
+
config,
|
|
499
|
+
projectContext,
|
|
500
|
+
onEvent: ev => {
|
|
501
|
+
if (ev.kind === "step") {
|
|
502
|
+
log(formatRalphStreamEvent("step", ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`, renderOpts));
|
|
503
|
+
} else if (ev.kind === "tool") {
|
|
504
|
+
const suffix = ev.summary ? ` — ${ev.summary}` : "";
|
|
505
|
+
log(formatRalphStreamEvent(ev.success ? "complete" : "error", `tool ${ev.detail}${suffix}`, renderOpts));
|
|
506
|
+
}
|
|
507
|
+
},
|
|
508
|
+
});
|
|
509
|
+
} catch (error: unknown) {
|
|
510
|
+
const explicitSignal = typeof error === "object" && error !== null && "mutationMayHaveOccurred" in error && error.mutationMayHaveOccurred === true;
|
|
511
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
512
|
+
log(formatRalphStreamEvent("error", message, renderOpts));
|
|
513
|
+
return { ok: false, ...(explicitSignal || !role.readOnly ? { mutationMayHaveOccurred: true } : {}) };
|
|
514
|
+
}
|
|
496
515
|
|
|
497
|
-
return
|
|
516
|
+
return {
|
|
517
|
+
ok: evaluateSubagentResult(result, role, ctx.strictMutations ?? false, log, renderOpts),
|
|
518
|
+
...(result.mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}),
|
|
519
|
+
};
|
|
498
520
|
}
|
|
499
521
|
|
|
500
522
|
/**
|
|
@@ -543,23 +565,14 @@ function evaluateSubagentResult(
|
|
|
543
565
|
return false;
|
|
544
566
|
}
|
|
545
567
|
|
|
546
|
-
//
|
|
547
|
-
//
|
|
548
|
-
// unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
|
|
568
|
+
// A mutating role's changed-files claim needs an observed workspace mutation.
|
|
569
|
+
// Bash is separate because it may or may not have changed files.
|
|
549
570
|
if (!role.readOnly && result.fileMutations === 0) {
|
|
550
571
|
const msg = result.bashRuns === 0
|
|
551
|
-
? `${role.title} completed WITHOUT any successful
|
|
552
|
-
: `${role.title} completed
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
// a completed task. bash-only stays advisory to avoid penalizing shell edits.
|
|
556
|
-
const hardFail = strictMutations && result.bashRuns === 0;
|
|
557
|
-
// Round-12: separate the tones so a passing advisory run doesn't masquerade
|
|
558
|
-
// as a stream:error — only a real hard-fail is red; an advisory note is warn.
|
|
559
|
-
log(formatRalphStreamEvent(hardFail ? "error" : "warn", msg, renderOpts));
|
|
560
|
-
if (hardFail) {
|
|
561
|
-
return false;
|
|
562
|
-
}
|
|
572
|
+
? `${role.title} completed WITHOUT any successful workspace mutation or bash call — treat its changed-files claim as unverified.`
|
|
573
|
+
: `${role.title} completed without a successful workspace mutation (only bash ran) — verify its changed-files claim independently.`;
|
|
574
|
+
log(formatRalphStreamEvent(strictMutations ? "error" : "warn", msg, renderOpts));
|
|
575
|
+
if (strictMutations && result.bashRuns === 0) return false;
|
|
563
576
|
}
|
|
564
577
|
|
|
565
578
|
// Surface the subagent's full report (header + step trace + fenced reason +
|
|
@@ -610,7 +623,7 @@ async function runParallelGroup(
|
|
|
610
623
|
log: (line: string) => void;
|
|
611
624
|
slug: string;
|
|
612
625
|
},
|
|
613
|
-
): Promise<{ ok: true } | { ok: false; failedTaskName: string }> {
|
|
626
|
+
): Promise<{ ok: true; mutationMayHaveOccurred?: boolean } | { ok: false; failedTaskName: string; mutationMayHaveOccurred?: boolean }> {
|
|
614
627
|
const { cwd, teamCfg, tasks, completed, strictMutations, log, slug } = ctx;
|
|
615
628
|
const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
|
|
616
629
|
|
|
@@ -654,16 +667,22 @@ async function runParallelGroup(
|
|
|
654
667
|
}
|
|
655
668
|
},
|
|
656
669
|
});
|
|
657
|
-
// Capture the FULL SubagentRunResult
|
|
658
|
-
//
|
|
670
|
+
// Capture the FULL SubagentRunResult before collapsing it to the plain
|
|
671
|
+
// ToolResult shape SubagentRegistry stores, including mutation evidence.
|
|
659
672
|
resultsByIndex.set(step.index, result);
|
|
660
|
-
return {
|
|
661
|
-
|
|
673
|
+
return {
|
|
674
|
+
success: result.success,
|
|
675
|
+
output: result.output,
|
|
676
|
+
error: result.error,
|
|
677
|
+
mutationMayHaveOccurred: result.mutationMayHaveOccurred,
|
|
678
|
+
};
|
|
679
|
+
}, { readOnly: role.readOnly });
|
|
662
680
|
idByIndex.set(step.index, record.id);
|
|
663
681
|
}
|
|
664
682
|
|
|
665
683
|
// No timeout — team runs can take as long as needed.
|
|
666
684
|
await registry.awaitIds([...idByIndex.values()]);
|
|
685
|
+
const registryMutation = registry.list().some(record => record.mutationMayHaveOccurred === true);
|
|
667
686
|
|
|
668
687
|
// Evaluate + commit each successful worktree, IN ARRAY ORDER (not completion
|
|
669
688
|
// order). Any failure stops the whole group immediately: no merges happen,
|
|
@@ -674,12 +693,12 @@ async function runParallelGroup(
|
|
|
674
693
|
const wt = worktreeByIndex.get(step.index)!;
|
|
675
694
|
if (!result) {
|
|
676
695
|
log(formatRalphStreamEvent("error", `[${step.name}] subagent produced no result`, renderOpts));
|
|
677
|
-
return { ok: false, failedTaskName: step.name };
|
|
696
|
+
return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
678
697
|
}
|
|
679
698
|
const success = evaluateSubagentResult(result, role, strictMutations, log, renderOpts);
|
|
680
699
|
if (!success) {
|
|
681
700
|
log(`${categoryBadge("error")} Parallel group step "${step.name}" failed — its worktree (${wt.path}) and the OTHER group worktrees are left in place for inspection; nothing was merged.`);
|
|
682
|
-
return { ok: false, failedTaskName: step.name };
|
|
701
|
+
return { ok: false, failedTaskName: step.name, ...(registryMutation || result.mutationMayHaveOccurred ? { mutationMayHaveOccurred: true } : {}) };
|
|
683
702
|
}
|
|
684
703
|
// An empty diff (read-only role, or a no-op) needs no commit — skip it for
|
|
685
704
|
// the merge step below without treating it as an error.
|
|
@@ -687,12 +706,12 @@ async function runParallelGroup(
|
|
|
687
706
|
const add = runGitSync(wt.path, ["add", "-A"]);
|
|
688
707
|
if (!add.ok) {
|
|
689
708
|
log(`${categoryBadge("error")} Failed to stage changes in worktree ${wt.path} for step "${step.name}": ${add.stderr.trim()}`);
|
|
690
|
-
return { ok: false, failedTaskName: step.name };
|
|
709
|
+
return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
691
710
|
}
|
|
692
711
|
const commit = runGitSync(wt.path, ["commit", "-m", step.name]);
|
|
693
712
|
if (!commit.ok) {
|
|
694
713
|
log(`${categoryBadge("error")} Failed to commit changes in worktree ${wt.path} for step "${step.name}": ${commit.stderr.trim()}`);
|
|
695
|
-
return { ok: false, failedTaskName: step.name };
|
|
714
|
+
return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
696
715
|
}
|
|
697
716
|
}
|
|
698
717
|
}
|
|
@@ -710,7 +729,7 @@ async function runParallelGroup(
|
|
|
710
729
|
`the merge was aborted automatically (no changes were auto-resolved). Resolve manually: ` +
|
|
711
730
|
`cd ${wt.path} to inspect the change, then run 'git merge ${wt.branch}' yourself from ${cwd}.\n${(merge.stderr + merge.stdout).trim()}`,
|
|
712
731
|
);
|
|
713
|
-
return { ok: false, failedTaskName: step.name };
|
|
732
|
+
return { ok: false, failedTaskName: step.name, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
714
733
|
}
|
|
715
734
|
log(`${categoryBadge("done")} Merged step "${step.name}" (branch ${wt.branch}) into the current branch.`);
|
|
716
735
|
}
|
|
@@ -725,5 +744,5 @@ async function runParallelGroup(
|
|
|
725
744
|
}
|
|
726
745
|
}
|
|
727
746
|
|
|
728
|
-
return { ok: true };
|
|
747
|
+
return { ok: true, ...(registryMutation ? { mutationMayHaveOccurred: true } : {}) };
|
|
729
748
|
}
|
|
@@ -1,30 +1,26 @@
|
|
|
1
1
|
import type { AgentLoopEvents, ToolInvocation } from "../agent/engine";
|
|
2
2
|
import type { MessengerActivityReporter } from "./types";
|
|
3
3
|
|
|
4
|
-
/**
|
|
5
|
-
* Derive one-line progress from session events: the model's declared tool intent (falling back to the
|
|
6
|
-
* tool name) and long-running lifecycle notices. Raw tool arguments, results and errors never surface.
|
|
7
|
-
*/
|
|
4
|
+
/** Derive bounded progress without exposing tool arguments, results, or provider errors. */
|
|
8
5
|
export function createMessengerActivityEvents(report: MessengerActivityReporter): AgentLoopEvents {
|
|
9
6
|
let step = 0;
|
|
10
7
|
return {
|
|
8
|
+
onStep(current) {
|
|
9
|
+
step = current;
|
|
10
|
+
report(`Step ${step} · Thinking`);
|
|
11
|
+
},
|
|
11
12
|
onAssistant(_raw: string, invocation: ToolInvocation | null) {
|
|
12
13
|
if (!invocation || invocation.tool === "done") return;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
: `Using ${invocation.tool}`;
|
|
20
|
-
report(`Step ${step} · ${intent}`);
|
|
14
|
+
const tool = /^[a-zA-Z0-9_-]{1,40}$/.test(invocation.tool) ? invocation.tool : "tool";
|
|
15
|
+
report(`Step ${step} · Running ${tool}`);
|
|
16
|
+
},
|
|
17
|
+
onToolResult(tool, success) {
|
|
18
|
+
const name = /^[a-zA-Z0-9_-]{1,40}$/.test(tool) ? tool : "tool";
|
|
19
|
+
report(`Step ${step} · ${name} ${success ? "finished" : "failed"}`);
|
|
21
20
|
},
|
|
22
21
|
onNotice(message: string) {
|
|
23
|
-
if (/compact/i.test(message))
|
|
24
|
-
|
|
25
|
-
} else if (/retry|rate limit/i.test(message)) {
|
|
26
|
-
report("Retrying after a provider error");
|
|
27
|
-
}
|
|
22
|
+
if (/compact/i.test(message)) report("Compacting context");
|
|
23
|
+
else if (/retry|rate limit/i.test(message)) report("Retrying after a provider error");
|
|
28
24
|
},
|
|
29
25
|
};
|
|
30
26
|
}
|
package/src/messenger/config.ts
CHANGED
|
@@ -61,9 +61,11 @@ export function resolveMessengerToken(
|
|
|
61
61
|
return undefined;
|
|
62
62
|
}
|
|
63
63
|
|
|
64
|
+
export const DEFAULT_MESSENGER_TOOLS = ["read", "find", "search", "write", "edit", "bash", "computer"];
|
|
65
|
+
|
|
64
66
|
export function parseMessengerConfig(value: unknown, baseDir: string): MessengerConfig {
|
|
65
67
|
const input = object(value, "Messenger config");
|
|
66
|
-
keys(input, ["cwd", "sessionDir", "toolNames", "platforms"], "Messenger config");
|
|
68
|
+
keys(input, ["cwd", "sessionDir", "toolNames", "platforms", "independentSessions"], "Messenger config");
|
|
67
69
|
const platformsInput = object(input.platforms, "platforms");
|
|
68
70
|
keys(platformsInput, PLATFORMS, "platforms");
|
|
69
71
|
if (Object.keys(platformsInput).length === 0) throw new Error("At least one messenger platform is required");
|
|
@@ -98,7 +100,7 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
|
|
|
98
100
|
}
|
|
99
101
|
platforms[platform] = config;
|
|
100
102
|
}
|
|
101
|
-
const toolNames = input.toolNames === undefined ?
|
|
103
|
+
const toolNames = input.toolNames === undefined ? DEFAULT_MESSENGER_TOOLS : input.toolNames;
|
|
102
104
|
if (
|
|
103
105
|
!Array.isArray(toolNames) ||
|
|
104
106
|
toolNames.some(name => typeof name !== "string" || !Object.hasOwn(TOOLS, name)) ||
|
|
@@ -106,6 +108,7 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
|
|
|
106
108
|
) {
|
|
107
109
|
throw new Error("toolNames must be a unique list drawn from read, grep, glob, find, search, bash, edit, write, computer");
|
|
108
110
|
}
|
|
111
|
+
const independentSessions = input.independentSessions !== undefined ? Boolean(input.independentSessions) : true;
|
|
109
112
|
return {
|
|
110
113
|
cwd: path.resolve(baseDir, input.cwd === undefined ? "." : string(input.cwd, "cwd")),
|
|
111
114
|
sessionDir: path.resolve(
|
|
@@ -114,6 +117,7 @@ export function parseMessengerConfig(value: unknown, baseDir: string): Messenger
|
|
|
114
117
|
),
|
|
115
118
|
toolNames: [...toolNames] as string[],
|
|
116
119
|
platforms,
|
|
120
|
+
independentSessions,
|
|
117
121
|
};
|
|
118
122
|
}
|
|
119
123
|
|
|
@@ -510,11 +510,13 @@ export async function setupDiscordMessenger(
|
|
|
510
510
|
|
|
511
511
|
const discord: Record<string, unknown> = { ...previous, tokenEnv, allowedUserIds: userIds, allowedChannelIds };
|
|
512
512
|
if (options.artifacts) discord.artifactDir = "./artifacts";
|
|
513
|
+
const defaultTools = ["read", "find", "search", "write", "edit", "bash", "computer"];
|
|
513
514
|
const config = {
|
|
514
515
|
...existing,
|
|
515
516
|
cwd: str(existing?.cwd) ?? "./workspace",
|
|
516
517
|
sessionDir: str(existing?.sessionDir) ?? "./sessions",
|
|
517
|
-
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames :
|
|
518
|
+
toolNames: options.toolNames ?? (Array.isArray(existing?.toolNames) ? existing.toolNames : defaultTools),
|
|
519
|
+
independentSessions: existing?.independentSessions !== undefined ? Boolean(existing.independentSessions) : true,
|
|
518
520
|
platforms: { ...platforms, discord },
|
|
519
521
|
};
|
|
520
522
|
for (const relative of [config.cwd, config.sessionDir, discord.artifactDir]) {
|
package/src/messenger/discord.ts
CHANGED
|
@@ -344,6 +344,7 @@ export function createDiscordAdapter(
|
|
|
344
344
|
}
|
|
345
345
|
};
|
|
346
346
|
const activityMessages = new Map<string, string>();
|
|
347
|
+
const activityHistory = new Map<string, string[]>();
|
|
347
348
|
const headers = (): Record<string, string> => {
|
|
348
349
|
if (!token) throw new DiscordConfigurationError("Discord bot token is missing");
|
|
349
350
|
return { Authorization: `Bot ${token}`, "Content-Type": "application/json" };
|
|
@@ -531,39 +532,39 @@ export function createDiscordAdapter(
|
|
|
531
532
|
deps.fetch,
|
|
532
533
|
);
|
|
533
534
|
},
|
|
534
|
-
async activity(message, text, signal) {
|
|
535
|
+
async activity(message, text, signal, outcome = "success") {
|
|
535
536
|
const channelId = message.threadId ?? message.channelId;
|
|
536
537
|
const key = JSON.stringify([channelId, message.messageId]);
|
|
537
538
|
const messages = `${DISCORD_API}/channels/${encodeURIComponent(channelId)}/messages`;
|
|
538
539
|
const statusId = activityMessages.get(key);
|
|
540
|
+
const previous = activityHistory.get(key) ?? [];
|
|
539
541
|
if (text === undefined) {
|
|
540
542
|
if (!statusId) return;
|
|
543
|
+
const label = outcome === "success" ? "✅ Complete" : outcome === "failure" ? "❌ Failed" : "⏹ Cancelled";
|
|
541
544
|
try {
|
|
542
545
|
await messengerFetchJson(
|
|
543
546
|
`${messages}/${encodeURIComponent(statusId)}`,
|
|
544
|
-
{ method: "
|
|
545
|
-
signal,
|
|
546
|
-
deps.fetch,
|
|
547
|
+
{ method: "PATCH", headers: headers(), body: JSON.stringify({ content: [label, ...previous].join("\n"), allowed_mentions: { parse: [] } }) },
|
|
548
|
+
signal, deps.fetch,
|
|
547
549
|
);
|
|
548
550
|
} catch (error) {
|
|
549
551
|
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
550
552
|
}
|
|
551
553
|
activityMessages.delete(key);
|
|
554
|
+
activityHistory.delete(key);
|
|
552
555
|
return;
|
|
553
556
|
}
|
|
554
|
-
|
|
557
|
+
// Keep only a short, bounded history of delivered updates in one editable message.
|
|
558
|
+
const history = [...previous, `• ${text}`].slice(-6);
|
|
559
|
+
const content = ["⏳ In progress", ...history].join("\n");
|
|
555
560
|
if (statusId) {
|
|
556
561
|
try {
|
|
557
562
|
await messengerFetchJson(
|
|
558
563
|
`${messages}/${encodeURIComponent(statusId)}`,
|
|
559
|
-
{
|
|
560
|
-
|
|
561
|
-
headers: headers(),
|
|
562
|
-
body: JSON.stringify({ content, allowed_mentions: { parse: [] } }),
|
|
563
|
-
},
|
|
564
|
-
signal,
|
|
565
|
-
deps.fetch,
|
|
564
|
+
{ method: "PATCH", headers: headers(), body: JSON.stringify({ content, allowed_mentions: { parse: [] } }) },
|
|
565
|
+
signal, deps.fetch,
|
|
566
566
|
);
|
|
567
|
+
activityHistory.set(key, history);
|
|
567
568
|
return;
|
|
568
569
|
} catch (error) {
|
|
569
570
|
if (!(error instanceof MessengerHttpError && error.status === 404)) throw error;
|
|
@@ -572,17 +573,13 @@ export function createDiscordAdapter(
|
|
|
572
573
|
}
|
|
573
574
|
const created = await messengerFetchJson(
|
|
574
575
|
messages,
|
|
575
|
-
{
|
|
576
|
-
|
|
577
|
-
headers: headers(),
|
|
578
|
-
body: JSON.stringify({ content, allowed_mentions: { parse: [] }, flags: DISCORD_ACTIVITY_FLAGS }),
|
|
579
|
-
},
|
|
580
|
-
signal,
|
|
581
|
-
deps.fetch,
|
|
576
|
+
{ method: "POST", headers: headers(), body: JSON.stringify({ content, allowed_mentions: { parse: [] }, flags: DISCORD_ACTIVITY_FLAGS }) },
|
|
577
|
+
signal, deps.fetch,
|
|
582
578
|
);
|
|
583
579
|
if (!isRecord(created) || typeof created.id !== "string" || !created.id)
|
|
584
580
|
throw new Error("Discord activity message returned no ID");
|
|
585
581
|
activityMessages.set(key, created.id);
|
|
582
|
+
activityHistory.set(key, history);
|
|
586
583
|
},
|
|
587
584
|
async send(message, text, signal) {
|
|
588
585
|
let files: { name: string; data: Uint8Array }[] = [];
|
package/src/messenger/gateway.ts
CHANGED
|
@@ -33,8 +33,8 @@ export function activityLine(text: string): string {
|
|
|
33
33
|
|
|
34
34
|
export interface ActivityReporter {
|
|
35
35
|
report: MessengerActivityReporter;
|
|
36
|
-
/** Stop updates and
|
|
37
|
-
close(): Promise<void>;
|
|
36
|
+
/** Stop updates and finalize the status; idempotent and never rejects. */
|
|
37
|
+
close(outcome?: "success" | "failure" | "cancelled"): Promise<void>;
|
|
38
38
|
}
|
|
39
39
|
|
|
40
40
|
const NO_ACTIVITY: ActivityReporter = { report() {}, close: () => Promise.resolve() };
|
|
@@ -132,7 +132,7 @@ export function createActivityReporter(
|
|
|
132
132
|
pumping = true;
|
|
133
133
|
pump = run();
|
|
134
134
|
},
|
|
135
|
-
close() {
|
|
135
|
+
close(outcome = "success") {
|
|
136
136
|
closing ??= (async () => {
|
|
137
137
|
closed = true;
|
|
138
138
|
wake?.();
|
|
@@ -140,7 +140,7 @@ export function createActivityReporter(
|
|
|
140
140
|
if (!shown) return;
|
|
141
141
|
for (let attempt = 0; ; attempt++) {
|
|
142
142
|
try {
|
|
143
|
-
await boundedActivity(bounded => activity.call(adapter, destination, undefined, bounded));
|
|
143
|
+
await boundedActivity(bounded => activity.call(adapter, destination, undefined, bounded, outcome));
|
|
144
144
|
return;
|
|
145
145
|
} catch (error) {
|
|
146
146
|
const wait =
|
|
@@ -340,6 +340,7 @@ export function createMessengerGateway(
|
|
|
340
340
|
conversationTails.set(conversation, tail);
|
|
341
341
|
await previous;
|
|
342
342
|
activity = createActivityReporter(adapter, destination, signal, activityIntervalMs);
|
|
343
|
+
activity.report("Starting request");
|
|
343
344
|
let text: string;
|
|
344
345
|
try {
|
|
345
346
|
text = await reply(destination, signal, activity.report);
|
|
@@ -353,9 +354,6 @@ export function createMessengerGateway(
|
|
|
353
354
|
}
|
|
354
355
|
if (signal.aborted) return;
|
|
355
356
|
text = "The request could not be completed. Please try again.";
|
|
356
|
-
} finally {
|
|
357
|
-
// Retire the status line alongside reply delivery rather than delaying the reply on it.
|
|
358
|
-
void activity.close();
|
|
359
357
|
}
|
|
360
358
|
if (!signal.aborted && text.trim()) {
|
|
361
359
|
// A failed send may already have delivered a chunk: never retry the entire reply.
|
|
@@ -363,9 +361,10 @@ export function createMessengerGateway(
|
|
|
363
361
|
}
|
|
364
362
|
if (!signal.aborted) status(failed ? "failure" : "success");
|
|
365
363
|
} catch {
|
|
364
|
+
failed = true;
|
|
366
365
|
if (!signal.aborted) status("failure");
|
|
367
366
|
} finally {
|
|
368
|
-
await activity.close();
|
|
367
|
+
await activity.close(failed ? "failure" : signal.aborted ? "cancelled" : "success");
|
|
369
368
|
release();
|
|
370
369
|
if (tail && conversationTails.get(conversation) === tail) conversationTails.delete(conversation);
|
|
371
370
|
resolve();
|