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.
Files changed (38) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.ja.md +17 -12
  3. package/README.ko.md +86 -27
  4. package/README.md +76 -20
  5. package/README.zh.md +17 -12
  6. package/package.json +2 -1
  7. package/src/agent/ast-edit-tool.ts +3 -1
  8. package/src/agent/engine.ts +114 -16
  9. package/src/agent/goal-tool.ts +7 -1
  10. package/src/agent/goal-verifier.ts +95 -16
  11. package/src/agent/loop-guards.ts +10 -9
  12. package/src/agent/lsp-rename-tool.ts +3 -1
  13. package/src/agent/notify/discord-daemon.ts +3 -1
  14. package/src/agent/notify/paths.ts +8 -0
  15. package/src/agent/notify/session-endpoint.ts +23 -4
  16. package/src/agent/notify/slack-daemon.ts +2 -2
  17. package/src/agent/notify/telegram-daemon.ts +223 -89
  18. package/src/agent/notify/topic-registry.ts +118 -15
  19. package/src/agent/skill-router.ts +31 -5
  20. package/src/agent/subagent-registry.ts +87 -5
  21. package/src/agent/subagent-tool.ts +133 -18
  22. package/src/agent/subagents.ts +7 -5
  23. package/src/agent/task-tool.ts +85 -14
  24. package/src/agent/tools.ts +14 -4
  25. package/src/cli.ts +1 -0
  26. package/src/commands/launch.ts +145 -17
  27. package/src/commands/messenger.ts +5 -1
  28. package/src/commands/notify.ts +5 -6
  29. package/src/commands/skills.ts +9 -0
  30. package/src/commands/team.ts +72 -53
  31. package/src/messenger/activity.ts +13 -17
  32. package/src/messenger/config.ts +6 -2
  33. package/src/messenger/discord-setup.ts +3 -1
  34. package/src/messenger/discord.ts +16 -19
  35. package/src/messenger/gateway.ts +7 -8
  36. package/src/messenger/session-pool.ts +99 -13
  37. package/src/messenger/types.ts +4 -5
  38. 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
  }
@@ -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.token = value;
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 = flags.token ?? process.env[envName]?.trim();
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(`Bot token environment variable is empty; use --token-env NAME (preferred over --token)`);
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
  }
@@ -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;
@@ -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<{ ok: boolean; reason?: string }> {
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 success = await executeTaskWithAgent({
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 (success) {
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
- async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: string; roleId?: string; strictMutations?: boolean; log: (line: string) => void }): Promise<boolean> {
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 (`runSubagentOnce`) — one way a jeo-code subagent runs,
480
- // whether launched interactively, in a fan-out batch, detached, or as a team
481
- // plan step. This gets team plan steps the same fenced-report protection
482
- // (prompt-injection-safe) and done-reason contract validation the interactive
483
- // tools already had, instead of a second hand-rolled copy of that logic.
484
- const result = await runSubagentOnce(role, buildRalphSubagentPrompt(ctx), "", ctx.cwd, {
485
- config,
486
- projectContext,
487
- onEvent: ev => {
488
- if (ev.kind === "step") {
489
- log(formatRalphStreamEvent("step", ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`, renderOpts));
490
- } else if (ev.kind === "tool") {
491
- const suffix = ev.summary ? ` — ${ev.summary}` : "";
492
- log(formatRalphStreamEvent(ev.success ? "complete" : "error", `tool ${ev.detail}${suffix}`, renderOpts));
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 evaluateSubagentResult(result, role, ctx.strictMutations ?? false, log, renderOpts);
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
- // Round-8: a mutating role finished without a successful file mutation — the
547
- // task may be legitimately read-only, but its "Changed Files:" claim is
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 write/edit/bash — treat its changed-files claim as unverified.`
552
- : `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
553
- // Round-11: under --strict-mutations, a mutating role that took NO action at
554
- // all (no write/edit/bash) is a hard failure — an empty run must not pass as
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 (doneReason/fileMutations/etc.) before
658
- // collapsing it to the plain ToolResult shape SubagentRegistry stores.
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 { success: result.success, output: result.output, error: result.error };
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
- step++;
14
- const intent =
15
- invocation.arguments &&
16
- typeof invocation.arguments.i === "string" &&
17
- invocation.arguments.i.trim()
18
- ? invocation.arguments.i.trim()
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
- report("Compacting context");
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
  }
@@ -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 ? [] : input.toolNames;
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]) {
@@ -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: "DELETE", headers: headers() },
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
- const content = `⏳ ${text}`;
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
- method: "PATCH",
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
- method: "POST",
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 }[] = [];
@@ -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 remove any shown line; idempotent and never rejects. */
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();