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
@@ -258,14 +258,16 @@ export function subagentSystemPrompt(role: SubagentRole): string {
258
258
 
259
259
  /**
260
260
  * True when EVERY shell segment of `command` starts with one of `prefixes`
261
- * (after stripping leading env-assignments and a leading `sudo`). Splitting on
262
- * `; && || |` means a chained `… && rm -rf` cannot smuggle an un-vetted command
263
- * past a single allowed head. Command substitution (`$(…)`, backticks) can't be
264
- * statically vetted, so it is rejected outright. Empty allowlist = unconstrained.
261
+ * (after stripping leading env-assignments and a leading `sudo`). Only `&&`,
262
+ * `||`, `;`, and `|` chaining is understood; other shell operators, newlines,
263
+ * and substitutions are rejected rather than approximated. Empty allowlist =
264
+ * unconstrained.
265
265
  */
266
266
  export function bashCommandAllowed(command: string, prefixes: string[]): boolean {
267
267
  if (prefixes.length === 0) return true;
268
- if (/[`]|\$\(/.test(command)) return false; // un-vettable command substitution
268
+ if (/[$`]/.test(command)) return false; // un-vettable expansion/substitution
269
+ const unsupportedSyntax = command.replace(/&&|\|\||[;|]/g, "");
270
+ if (/[&<>(){}\\\r\n]/.test(unsupportedSyntax)) return false;
269
271
  const matches = (seg: string, p: string) =>
270
272
  seg === p || seg.startsWith(p + " ") || seg.startsWith(p + "\t");
271
273
  for (const raw of command.split(/(?:&&|\|\||[;|])/)) {
@@ -9,7 +9,7 @@
9
9
  * repo). Subagents are spawned with `subagentToolset(role)`, which never includes
10
10
  * `task` itself, so delegation cannot recurse infinitely.
11
11
  */
12
- import { runAgentLoop, isSafetyFallbackBail, stripSafetyFallbackTag, type ToolHandler } from "./engine";
12
+ import { runAgentLoop, isSafetyFallbackBail, stripSafetyFallbackTag, type AgentLoopResult, type ToolHandler } from "./engine";
13
13
  import { toolTarget } from "./step-budget";
14
14
 
15
15
  import type { ToolResult } from "./tools";
@@ -279,8 +279,11 @@ export interface RunSubagentOptions {
279
279
  modelOverride?: string;
280
280
  /** Stable detached id (from SubagentRegistry), attached to all emitted TaskSubEvents. */
281
281
  detachedId?: string;
282
+ /** SubagentRegistry to report live activity into (e.g. current tool or step). */
283
+ registry?: SubagentRegistry;
282
284
  }
283
285
 
286
+
284
287
  /** `runSubagentOnce`'s result — a superset of `ToolResult` exposing the raw
285
288
  * (unwrapped, unfenced) done reason and mutation-audit counters so a caller with
286
289
  * its OWN pass/fail policy on top (e.g. `jeo team`'s role-gate-verdict parsing and
@@ -288,6 +291,8 @@ export interface RunSubagentOptions {
288
291
  * report text. `task`/`subagent` tool callers only ever need `success`/`output`/
289
292
  * `error`, which stay structurally identical to plain `ToolResult`. */
290
293
  export interface SubagentRunResult extends ToolResult {
294
+ /** True when delegated execution observed a successful or potentially partial mutation. */
295
+ mutationMayHaveOccurred?: boolean;
291
296
  /** Raw subagent done reason, before contract validation or fencing. */
292
297
  doneReason: string;
293
298
  /** True when the subagent actually called `done` (vs. exhausting its step
@@ -297,7 +302,8 @@ export interface SubagentRunResult extends ToolResult {
297
302
  contractOk: boolean;
298
303
  /** Required markers missing from the done reason, when `contractOk` is false. */
299
304
  missingMarkers?: string[];
300
- /** Successful write/edit/mkdir/delete calls observed (mutation audit evidence). */
305
+ /** Successful workspace mutation calls observed (write/edit/mkdir/delete,
306
+ * ast_edit, or lsp_rename). */
301
307
  fileMutations: number;
302
308
  /** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
303
309
  bashRuns: number;
@@ -329,7 +335,18 @@ export async function runSubagentOnce(
329
335
  const { steer, slot, detachedId, projectContext: preloadedContext, signal } = opts;
330
336
  // Tag every live event with its fan-out slot or detached ID so a parent monitor can tell
331
337
  // task 1 from task 3 or detached tasks apart when several subagents stream concurrently.
332
- const emit = (ev: TaskSubEvent) =>
338
+ const emit = (ev: TaskSubEvent) => {
339
+ if (detachedId && opts.registry) {
340
+ if (ev.kind === "tool") {
341
+ opts.registry.setActivity(detachedId, ev.detail ? `tool: ${ev.detail}` : "running tool");
342
+ } else if (ev.kind === "thinking") {
343
+ opts.registry.setActivity(detachedId, "thinking");
344
+ } else if (ev.kind === "step") {
345
+ opts.registry.setActivity(detachedId, ev.detail ? `step: ${ev.detail}` : "next step");
346
+ } else if (ev.kind === "done") {
347
+ opts.registry.setActivity(detachedId, undefined);
348
+ }
349
+ }
333
350
  opts.onEvent?.(
334
351
  detachedId
335
352
  ? { ...ev, detachedId }
@@ -337,6 +354,8 @@ export async function runSubagentOnce(
337
354
  ? { ...ev, index: slot.index, total: slot.total }
338
355
  : ev
339
356
  );
357
+ };
358
+
340
359
  const initialModel = opts.modelOverride || resolveSubagentModel(role.id, opts.config);
341
360
  const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
342
361
  // gjc parity: a role may pin its own reasoning budget; absent = inherit the
@@ -365,6 +384,9 @@ export async function runSubagentOnce(
365
384
  let fileMutations = 0;
366
385
  let bashRuns = 0;
367
386
  let readOnlyEvidenceCalls = 0;
387
+ // The engine's evidence signal is cumulative across tool batches and includes
388
+ // failed mutators whose handlers report that state may have changed.
389
+ let mutationMayHaveOccurred = false;
368
390
  // Per-run prompt-cache key: the subagent replays its own growing history each
369
391
  // step, so a stable key (even across a model switch below — a stale cache key
370
392
  // on a NEW provider is simply unused, never wrong) gets provider cache hits
@@ -454,6 +476,9 @@ export async function runSubagentOnce(
454
476
  tools: subagentToolset(role),
455
477
  ...(failoverEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
456
478
  events: {
479
+ onEvidence: evidence => {
480
+ if (evidence.sawMutation) mutationMayHaveOccurred = true;
481
+ },
457
482
  onStep: n => { currentStep = n; },
458
483
  // Live reasoning preview (native extended-thinking models only — the JSON-protocol
459
484
  // "reasoning" field the main turn also extracts from onModelStream is intentionally
@@ -475,7 +500,7 @@ export async function runSubagentOnce(
475
500
  },
476
501
  onToolResult: (tool, success, output) => {
477
502
  if (success) {
478
- if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
503
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete" || tool === "ast_edit" || tool === "lsp_rename") fileMutations++;
479
504
  else if (tool === "bash") bashRuns++;
480
505
  else if (tool === "read" || tool === "search" || tool === "find" || tool === "ast_grep" || tool === "lsp") readOnlyEvidenceCalls++;
481
506
  }
@@ -496,12 +521,36 @@ export async function runSubagentOnce(
496
521
  });
497
522
  };
498
523
 
499
- let result = await runOnce();
524
+ const failureResult = (error: unknown): SubagentRunResult => {
525
+ const explicitSignal = typeof error === "object" && error !== null && "mutationMayHaveOccurred" in error && error.mutationMayHaveOccurred === true;
526
+ const message = error instanceof Error ? error.message : String(error);
527
+ return {
528
+ success: false,
529
+ output: "",
530
+ error: message,
531
+ doneReason: message,
532
+ done: false,
533
+ contractOk: false,
534
+ fileMutations,
535
+ bashRuns,
536
+ readOnlyEvidenceCalls,
537
+ mutationMayHaveOccurred: mutationMayHaveOccurred || explicitSignal || (!role.readOnly ? true : undefined),
538
+ };
539
+ };
540
+ const runSafely = async (): Promise<{ ok: true; result: AgentLoopResult } | { ok: false; error: unknown }> => {
541
+ try {
542
+ return { ok: true, result: await runOnce() };
543
+ } catch (error: unknown) {
544
+ return { ok: false, error };
545
+ }
546
+ };
547
+ let outcome = await runSafely();
548
+ if (!outcome.ok) return failureResult(outcome.error);
549
+ let result = outcome.result;
500
550
  // `routing.enabled` off = the model is FIXED: no failover reroute at all (the
501
551
  // engine bail predicates above are withheld too, so a 429 rides its normal
502
552
  // backoff ladder instead of bailing early with nowhere to go).
503
- for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) {
504
- const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
553
+ for (let attempt = 0; failoverEnabled && attempt < MAX_SUBAGENT_REROUTES; attempt++) { const rateLimited = !result.done && isRateLimitError(new Error(result.doneReason ?? ""));
505
554
  const safetyBail = !result.done && !rateLimited && isSafetyFallbackBail(result.doneReason);
506
555
  if (!rateLimited && !safetyBail) break;
507
556
  const previousModel = activeModel;
@@ -526,7 +575,9 @@ export async function runSubagentOnce(
526
575
  emit({ role: role.id, kind: "step", detail: `↳ refused on '${previousModel}' — switching to '${activeModel}' in case of a classifier false positive`, step: currentStep, maxSteps, model: activeModel });
527
576
  }
528
577
  attemptedModels.add(activeModel);
529
- result = await runOnce();
578
+ outcome = await runSafely();
579
+ if (!outcome.ok) return failureResult(outcome.error);
580
+ result = outcome.result;
530
581
  }
531
582
 
532
583
  const reason = stripSafetyFallbackTag(result.doneReason?.trim() || "") || `(subagent reached the ${result.steps}-step limit without signaling done)`;
@@ -543,8 +594,8 @@ export async function runSubagentOnce(
543
594
  // "verify independently" instead of the stronger UNVERIFIED.
544
595
  const audit = complete && !role.readOnly && fileMutations === 0
545
596
  ? bashRuns === 0
546
- ? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
547
- : `\n[parent audit] No successful write/edit was observed (only bash ran); bash may or may not have mutated files — verify any "Changed Files:" claims above independently.`
597
+ ? `\n[parent audit] No successful workspace mutation or bash call was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
598
+ : `\n[parent audit] No successful workspace mutation was observed (only bash ran); bash may or may not have mutated files — verify any "Changed Files:" claims above independently.`
548
599
  : "";
549
600
  return {
550
601
  success: complete,
@@ -556,6 +607,7 @@ export async function runSubagentOnce(
556
607
  fileMutations,
557
608
  bashRuns,
558
609
  readOnlyEvidenceCalls,
610
+ mutationMayHaveOccurred,
559
611
  };
560
612
  }
561
613
 
@@ -585,8 +637,10 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
585
637
  modelOverride?: string;
586
638
  /** Stable detached id from SubagentRegistry. */
587
639
  detachedId?: string;
640
+ /** SubagentRegistry to report live activity into. */
641
+ registry?: SubagentRegistry;
588
642
  } = {},
589
- ): Promise<ToolResult> =>
643
+ ): Promise<SubagentRunResult> =>
590
644
  runSubagentOnce(role, taskText, context, cwd, {
591
645
  config: opts.config,
592
646
  signal: extra.signal ?? opts.signal,
@@ -597,8 +651,10 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
597
651
  excludedCredentialScopes: extra.excludedCredentialScopes,
598
652
  modelOverride: extra.modelOverride,
599
653
  detachedId: extra.detachedId,
654
+ registry: extra.registry,
600
655
  });
601
656
 
657
+
602
658
  return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
603
659
  const roleArg = typeof args.role === "string" ? args.role.trim() : "";
604
660
  const role = roleArg ? getSubagentRole(roleArg, opts.config) : defaultSubagentRole();
@@ -689,7 +745,7 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
689
745
  // `executor`'s single resolved model), so the first worker to exclude a
690
746
  // scope saves every sibling worker the same wasted round.
691
747
  const batchExcludedScopes = new Set<string>();
692
- const results: ToolResult[] = new Array(items.length);
748
+ const results: SubagentRunResult[] = new Array(items.length);
693
749
  let next = 0;
694
750
  // #7: broadcast steering hub — each concurrent worker sees every parent
695
751
  // steer message exactly once (safe even for parallel read-only fan-out).
@@ -714,7 +770,11 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
714
770
  const ok = results.filter(r => r.success).length;
715
771
  const head = `[${role.title} fan-out] ${ok}/${items.length} completed (concurrency ${limit}).`;
716
772
  const combined = results.map((r, i) => `### Task ${i + 1}/${items.length}\n${r.output}`).join("\n\n");
717
- return { success: ok === items.length, output: `${head}\n\n${combined}` };
773
+ return {
774
+ success: ok === items.length,
775
+ output: `${head}\n\n${combined}`,
776
+ mutationMayHaveOccurred: results.some(r => r.mutationMayHaveOccurred === true),
777
+ };
718
778
  }
719
779
 
720
780
  // Single-task form.
@@ -728,8 +788,15 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
728
788
  // its own registry inbox (registry.steerDrainFor(id)) between its own steps.
729
789
  if (args.detached === true && opts.registry) {
730
790
  const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
731
- runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id), detachedId: id }),
791
+ runOne(role, taskText, ctx(args.context), cwd, {
792
+ signal,
793
+ steer: opts.registry!.steerDrainFor(id),
794
+ detachedId: id,
795
+ registry: opts.registry,
796
+ }),
797
+ { readOnly: role.readOnly },
732
798
  );
799
+
733
800
  // Remote subagent visibility/control over Telegram (gjc daemon parity, see
734
801
  // `src/agent/notify/`): best-effort, no-op unless `notifications.enabled`.
735
802
  ensureSessionNotifyEndpoint(opts.registry, cwd, opts.sessionEndpoint);
@@ -739,6 +806,10 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
739
806
  output:
740
807
  `[detached] launched ${role.title} subagent '${rec.id}'. It runs in the background — ` +
741
808
  `keep working, then use the 'subagent' tool ({action:"await"|"list"|"inspect"|"cancel", ids?}) to collect its result.`,
809
+ // A mutating detached run may change the workspace after this tool returns;
810
+ // surface that possibility immediately so the parent cannot claim a clean
811
+ // workspace before collecting and verifying the settled result.
812
+ mutationMayHaveOccurred: role.readOnly ? undefined : true,
742
813
  };
743
814
  }
744
815
  return runOne(role, taskText, ctx(args.context), cwd, { steer: opts.steer });
@@ -26,6 +26,8 @@ export interface ToolResult {
26
26
  success: boolean;
27
27
  output: string;
28
28
  error?: string;
29
+ /** Failed operations set this when an attempted filesystem write may have partially changed state. */
30
+ mutationMayHaveOccurred?: boolean;
29
31
  }
30
32
 
31
33
  /**
@@ -315,18 +317,20 @@ export async function writeTool(
315
317
  content: string,
316
318
  cwd: string = process.cwd()
317
319
  ): Promise<ToolResult> {
320
+ let mutationMayHaveOccurred = false;
318
321
  try {
319
322
  if (typeof filePath !== "string" || filePath.trim() === "") {
320
323
  return { success: false, output: "", error: 'write requires a non-empty "filePath".' };
321
324
  }
322
325
  await assertMutationAllowed(filePath, cwd);
323
326
  const absPath = path.resolve(cwd, filePath);
327
+ mutationMayHaveOccurred = true;
324
328
  await fs.mkdir(path.dirname(absPath), { recursive: true });
325
329
  await fs.writeFile(absPath, content, "utf-8");
326
330
  markRead(absPath); // own change counts as having seen the file
327
331
  return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
328
332
  } catch (err: any) {
329
- return { success: false, output: "", error: err.message };
333
+ return { success: false, output: "", error: err.message, mutationMayHaveOccurred };
330
334
  }
331
335
  }
332
336
 
@@ -363,6 +367,7 @@ export async function editTool(
363
367
  editBlock: string,
364
368
  cwd: string = process.cwd()
365
369
  ): Promise<ToolResult> {
370
+ let mutationMayHaveOccurred = false;
366
371
  try {
367
372
  if (typeof filePath !== "string" || filePath.trim() === "") {
368
373
  return { success: false, output: "", error: 'edit requires a non-empty "filePath".' };
@@ -582,11 +587,12 @@ export async function editTool(
582
587
  };
583
588
  }
584
589
 
590
+ mutationMayHaveOccurred = true;
585
591
  await fs.writeFile(absPath, content, "utf-8");
586
592
  markRead(absPath); // own change counts as having seen the file
587
593
  return { success: true, output: `Successfully updated ${filePath}` };
588
594
  } catch (err: any) {
589
- return { success: false, output: "", error: err.message };
595
+ return { success: false, output: "", error: err.message, mutationMayHaveOccurred };
590
596
  }
591
597
  }
592
598
 
@@ -1079,6 +1085,7 @@ export async function mkdirTool(
1079
1085
  dirPath: string,
1080
1086
  cwd: string = process.cwd()
1081
1087
  ): Promise<ToolResult> {
1088
+ let mutationMayHaveOccurred = false;
1082
1089
  try {
1083
1090
  if (typeof dirPath !== "string" || dirPath.trim() === "") {
1084
1091
  return { success: false, output: "", error: 'mkdir requires a non-empty "dirPath".' };
@@ -1089,10 +1096,11 @@ export async function mkdirTool(
1089
1096
  if (existing && !existing.isDirectory()) {
1090
1097
  return { success: false, output: "", error: `Path exists and is not a directory: ${dirPath}` };
1091
1098
  }
1099
+ mutationMayHaveOccurred = true;
1092
1100
  await fs.mkdir(abs, { recursive: true });
1093
1101
  return { success: true, output: `Directory ready: ${dirPath}` };
1094
1102
  } catch (err: any) {
1095
- return { success: false, output: "", error: err.message };
1103
+ return { success: false, output: "", error: err.message, mutationMayHaveOccurred };
1096
1104
  }
1097
1105
  }
1098
1106
 
@@ -1108,6 +1116,7 @@ export async function deleteTool(
1108
1116
  cwd: string = process.cwd(),
1109
1117
  recursive: boolean = false
1110
1118
  ): Promise<ToolResult> {
1119
+ let mutationMayHaveOccurred = false;
1111
1120
  try {
1112
1121
  if (typeof targetPath !== "string" || targetPath.trim() === "") {
1113
1122
  return { success: false, output: "", error: 'delete requires a non-empty "path".' };
@@ -1124,11 +1133,12 @@ export async function deleteTool(
1124
1133
  if (st.isDirectory() && !recursive) {
1125
1134
  return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
1126
1135
  }
1136
+ mutationMayHaveOccurred = true;
1127
1137
  await fs.rm(abs, { recursive, force: false });
1128
1138
  readThisSession.delete(abs); // a future edit against this path must read it again first
1129
1139
  return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
1130
1140
  } catch (err: any) {
1131
- return { success: false, output: "", error: err.message };
1141
+ return { success: false, output: "", error: err.message, mutationMayHaveOccurred };
1132
1142
  }
1133
1143
  }
1134
1144
 
package/src/cli.ts CHANGED
@@ -49,6 +49,7 @@ process.on("unhandledRejection", fatal);
49
49
  process.stdout.on("error", fatal);
50
50
  process.stderr.on("error", fatal);
51
51
 
52
+
52
53
  try {
53
54
  const code = await dispatch(process.argv.slice(2), { appName: APP_NAME, version: VERSION });
54
55
  if (code !== 0) process.exit(code);
@@ -62,7 +62,7 @@ import { rememberModelPatch, recentModelsForDisplay } from "../agent/model-recen
62
62
  import { describeModel, describeAllProviders, describeProvider, resolveProvider, thinkingMaxTokens, resolveMaxOutputTokens, thinkingToReasoningEffort, discoverModels, flattenModels, resolveSelection, catalogMetadata, catalogByProvider, liveProviderCatalogModels, resolveRoleModel, CODEX_MODELS, qualifyModelId, modelServableWithConfig, isLocalProviderReachable } from "../ai";
63
63
  import { rehydrateLiveModels, writeModelCache } from "../ai/model-cache";
64
64
  import type { ProviderModelsResult, PickEntry, ProviderName, ModelRole, ThinkLevel } from "../ai";
65
- import { readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate } from "../agent/goal-verifier";
65
+ import { GoalStateCorruptError, readGoalState, writeGoalState, clearGoalState, verifyGoal, applyEvidenceGate, applyPendingEvidence, type GoalState } from "../agent/goal-verifier";
66
66
  import { routePrompt, deriveCacheSessionKey, warnOnce, tierModelPool, selectFromPool, PROMPT_TIERS, withRoutingTierSetting, inferTierForModel, credentialScopeFor, resolveVerifierModel, type PromptTier, type RouteDecision, type RoutingConfig } from "../agent/prompt-router";
67
67
  import { RouteHistory } from "../agent/route-history";
68
68
 
@@ -114,7 +114,7 @@ import type { ImageAttachment } from "../ai/types";
114
114
  import { renderMarkdownTables } from "../tui/components/markdown-table";
115
115
 
116
116
  import { stripMarkdown } from "../tui/components/markdown-text";
117
- import { summarizeForgeInvocation } from "../tui/components/forge";
117
+ import { createMessengerActivityEvents } from "../messenger/activity";
118
118
  import { formatDuration, formatUsage } from "../tui/components/duration";
119
119
 
120
120
  import { bashTool } from "../agent/tools";
@@ -122,6 +122,7 @@ import { bashTool } from "../agent/tools";
122
122
  import { loadProjectContext, withProjectContext } from "../agent/context-files";
123
123
  import { maybeCompact, historyTokens, buildHandoffDocument } from "../agent/compaction";
124
124
  import * as path from "node:path";
125
+ import { tmpdir } from "node:os";
125
126
  import * as fs from "node:fs";
126
127
  import { listThemes, resolveTheme, themeGradient, accentPaint, accentShadowPaint } from "../tui/components/themes";
127
128
  import {
@@ -971,6 +972,15 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
971
972
  // Session-scoped registries for DETACHED subagents, background jobs, and monitors (gjc parity).
972
973
  // Created ONCE for the whole launch session lifetime (not per-turn) so detached
973
974
  // runs persist across turns and remain controllable/inspectable.
975
+ let pendingMutationVerification = false;
976
+ try {
977
+ pendingMutationVerification = (await readGoalState(cwd))?.pendingMutationVerification === true;
978
+ } catch (error) {
979
+ if (!(error instanceof GoalStateCorruptError)) throw error;
980
+ pendingMutationVerification = true;
981
+ console.error(`${error.message}. Run /goal clear to remove it; fresh verification is still required.`);
982
+ }
983
+
974
984
  const subagentRegistry = new SubagentRegistry();
975
985
  const jobRegistry = new JobRegistry();
976
986
  let activeTui: LaunchTui | null = null;
@@ -1024,9 +1034,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1024
1034
  // `step N/M` display seed: the explicit --max-steps cap, else the dynamic budget's
1025
1035
  // rolling base — the engine's onBudget event keeps the denominator honest as it grows.
1026
1036
  const initialStepLimit = flags.maxSteps > 0 ? flags.maxSteps : initialDynamicStepLimit();
1027
- // Plain (non-TTY / --no-tui) progress sink — the cmd-mode equivalent of the live TUI.
1028
- const streamEvents = flags.literalPrompt
1029
- ? { onStep: () => { process.stderr.write("JEO_MESSENGER_STEP\n"); } }
1037
+ // Literal workers send progress without exposing tool arguments or output.
1038
+ const streamEvents = (flags.literalPrompt || flags.literalWorker)
1039
+ ? createMessengerActivityEvents(text => process.stderr.write(`JEO_MESSENGER_ACTIVITY ${text}\n`))
1030
1040
  : createStreamEvents(initialStepLimit);
1031
1041
  let queueBusyInput: ((chunk: string) => boolean) | undefined;
1032
1042
  let queueBusyPasteActive: (() => boolean) | undefined;
@@ -1716,10 +1726,61 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1716
1726
  // Todos checklist used to end a finished turn stuck at "✓0 ◐1 ·4 / 5"
1717
1727
  // because nothing ever forced the model to update item statuses).
1718
1728
  let turnTodos: { title: string; status: string }[] = [];
1729
+ const onEvidence = async (
1730
+ evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
1731
+ ): Promise<void> => {
1732
+ if (evidence.sawVerification && !evidence.verificationStale) {
1733
+ pendingMutationVerification = false;
1734
+ } else if (evidence.sawMutation) {
1735
+ pendingMutationVerification = true;
1736
+ }
1737
+
1738
+ let goalState: GoalState | null;
1739
+ try {
1740
+ goalState = await readGoalState(cwd);
1741
+ } catch (error) {
1742
+ if (error instanceof GoalStateCorruptError) return;
1743
+ throw error;
1744
+ }
1745
+ if (!goalState) return;
1746
+ const updatedState = applyPendingEvidence(
1747
+ { ...goalState, pendingMutationVerification },
1748
+ evidence,
1749
+ );
1750
+ pendingMutationVerification = updatedState.pendingMutationVerification === true;
1751
+ await writeGoalState(updatedState, cwd);
1752
+ };
1753
+
1719
1754
  const onBeforeDone = async (
1720
1755
  reason: string,
1721
1756
  evidence: { sawMutation: boolean; sawVerification: boolean; verificationStale: boolean },
1722
1757
  ): Promise<string | null> => {
1758
+ const runningJobs = jobRegistry.running();
1759
+ if (runningJobs.length > 0) {
1760
+ const jobs = runningJobs.map(record => `${record.id} (${record.command})`).join(", ");
1761
+ return (
1762
+ `Cannot finish while background job(s) are still running: ${jobs}. ` +
1763
+ `Wait for them to exit or cancel them, then call done again.`
1764
+ );
1765
+ }
1766
+ const runningMonitors = monitorRegistry.running();
1767
+ if (runningMonitors.length > 0) {
1768
+ const monitors = runningMonitors.map(record => `${record.id} (${record.command})`).join(", ");
1769
+ return (
1770
+ `Cannot finish while background monitor(s) are still running: ${monitors}. ` +
1771
+ `Wait for them to exit or cancel them, then call done again.`
1772
+ );
1773
+ }
1774
+
1775
+ const inFlightMutators = subagentRegistry.inFlightMutators();
1776
+ if (inFlightMutators.length > 0) {
1777
+ const agents = inFlightMutators.map(record => `${record.id} (${record.role})`).join(", ");
1778
+ return (
1779
+ `Cannot finish while mutating subagent(s) are still in flight: ${agents}. ` +
1780
+ `Wait for them to settle after completion or cancellation, then call done again.`
1781
+ );
1782
+ }
1783
+
1723
1784
  const unfinished = turnTodos.filter(t => t.status !== "done");
1724
1785
  if (turnTodos.length > 0 && unfinished.length > 0) {
1725
1786
  return (
@@ -1728,8 +1789,22 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1728
1789
  `(drop items that no longer apply), then call done again.`
1729
1790
  );
1730
1791
  }
1792
+ let goalState: GoalState | null;
1793
+ try {
1794
+ goalState = await readGoalState(cwd);
1795
+ } catch (error) {
1796
+ if (error instanceof GoalStateCorruptError) {
1797
+ return `Cannot finish: persisted goal state is corrupt. Run /goal clear, then verify any workspace mutations before calling done again.`;
1798
+ }
1799
+ throw error;
1800
+ }
1801
+ if ((pendingMutationVerification || evidence.sawMutation) && !(evidence.sawVerification && !evidence.verificationStale)) {
1802
+ return (
1803
+ `Cannot finish: a workspace mutation from this session still lacks a successful, non-stale verification. ` +
1804
+ `Run a relevant test/build/typecheck/lint after the last change, then call done again.`
1805
+ );
1806
+ }
1731
1807
 
1732
- const goalState = await readGoalState(cwd);
1733
1808
  if (goalState && goalState.condition) {
1734
1809
  if (tui) tui.events().onNotice?.("[Goal Verifier] Running goal verification...");
1735
1810
  const llmVerdict = await verifyGoal(goalState.condition, history, resolveVerifierModel(turnConfig));
@@ -1737,7 +1812,11 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1737
1812
  // judge still needs mutation/verification evidence to back it up —
1738
1813
  // see applyEvidenceGate's own docs (goal-verifier.ts). NOT_MET/
1739
1814
  // IMPOSSIBLE pass through unchanged; this only ever tightens MET.
1740
- const verdict = applyEvidenceGate(llmVerdict, evidence);
1815
+ const effectiveEvidence = {
1816
+ ...evidence,
1817
+ pendingMutation: goalState.pendingMutationVerification === true,
1818
+ };
1819
+ const verdict = applyEvidenceGate(llmVerdict, effectiveEvidence);
1741
1820
 
1742
1821
  goalState.verdicts.push({
1743
1822
  at: Date.now(),
@@ -1895,7 +1974,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
1895
1974
  // with the reroute loop below, which `routingEnabled` also gates.
1896
1975
  ...(routingEnabled ? { rateLimitFallbackAvailable, safetyFallbackAvailable } : {}),
1897
1976
 
1898
- events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone }, persistTurnTail), opik),
1977
+ events: wrapEvents(withStepPersistence({ ...withToolDetailCapture(tui ? tui.events() : streamEvents), onBeforeDone, onEvidence }, persistTurnTail), opik),
1978
+
1899
1979
  });
1900
1980
  const routeFailureReason = (doneReason: unknown): string | null => {
1901
1981
  const message = String(doneReason ?? "");
@@ -2236,6 +2316,8 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
2236
2316
  console.log("JEO_MESSENGER_ERROR");
2237
2317
  break;
2238
2318
  }
2319
+
2320
+
2239
2321
  }
2240
2322
  } finally { input.close(); }
2241
2323
  return;
@@ -3152,6 +3234,9 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
3152
3234
  let typedLine = ""; // the user-typed line (restored after readline's history nav)
3153
3235
  let pendingSelection: string | undefined; // command chosen via arrows, applied on Enter
3154
3236
  let pendingImages: ImageAttachment[] = []; // clipboard images attached to the next message (ctrl+v)
3237
+ // Remote image paths are already ingested before prompt injection. Count the
3238
+ // associated text so the general typed-path scanner does not read it again.
3239
+ const remoteAttachmentTextCounts = new Map<string, number>();
3155
3240
  let pasteInFlight = false; // guard concurrent ctrl+v clipboard reads
3156
3241
  let imagePasteInFlight = false;
3157
3242
  let idleDirtyCount: number | undefined; // git dirty count refreshed once per prompt
@@ -4508,13 +4593,31 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4508
4593
  // ingestion mechanism, not a parallel one. Bare paths never contain
4509
4594
  // whitespace here (daemon-generated temp names), so simple whitespace
4510
4595
  // joining is a safe token boundary for the path-token scanner.
4511
- const withPaths = msg.imagePaths?.length ? `${msg.text} ${msg.imagePaths.join(" ")}`.trim() : msg.text;
4512
- const attached = await attachImagePaths(withPaths, pendingImages.length + 1);
4513
- const cleanedText = attached.images.length ? attached.text : msg.text;
4596
+ const remoteImagePaths = msg.imagePaths ?? [];
4597
+ // Never scan remote text for local paths: a Telegram user must not be
4598
+ // able to make the REPL read arbitrary files by mentioning one. Only
4599
+ // daemon-owned temp attachments are eligible for image ingestion.
4600
+ const tempRoot = path.resolve(tmpdir());
4601
+ const validImagePaths = remoteImagePaths.filter((filePath) => {
4602
+ if (!path.isAbsolute(filePath)) return false;
4603
+ const relative = path.relative(tempRoot, path.resolve(filePath));
4604
+ const parts = relative.split(path.sep);
4605
+ return parts.length === 2 && parts[0]!.startsWith("jeo-telegram-") && parts[1] !== "" && parts[1] !== "." && parts[1] !== "..";
4606
+ });
4607
+ if (validImagePaths.length !== remoteImagePaths.length) return false;
4608
+ const attached = validImagePaths.length
4609
+ ? await attachImagePaths(validImagePaths.join(" "), pendingImages.length + 1)
4610
+ : { text: msg.text, images: [] };
4611
+ // Attachment parsing receives paths only; preserve the remote message
4612
+ // text verbatim so an image never replaces the user's prompt.
4613
+ const cleanedText = msg.text;
4514
4614
  // Attachment normalization must not expose a remote slash command that
4515
4615
  // bypasses the local approval prompt or changes local execution policy.
4516
- if (cleanedText.trimStart().startsWith("/")) return false;
4517
- if (attached.images.length) pendingImages.push(...attached.images);
4616
+ if (cleanedText.trimStart().startsWith("/") || cleanedText.trimStart().startsWith("!")) return false;
4617
+ if (attached.images.length) {
4618
+ pendingImages.push(...attached.images);
4619
+ remoteAttachmentTextCounts.set(cleanedText, (remoteAttachmentTextCounts.get(cleanedText) ?? 0) + 1);
4620
+ }
4518
4621
 
4519
4622
  if (interactiveTurnActive && currentTurnSteer) {
4520
4623
  // Busy: steer the running turn exactly like a local mid-turn Enter.
@@ -4537,7 +4640,10 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4537
4640
  };
4538
4641
  sessionNotifyEndpoint.onConfigCommand = (cmd): void => {
4539
4642
  if (cmd.verbosity !== undefined) notifyVerbosity = cmd.verbosity;
4540
- if (cmd.redact !== undefined) notifyRedact = cmd.redact;
4643
+ if (cmd.redact !== undefined) {
4644
+ notifyRedact = cmd.redact;
4645
+ sessionNotifyEndpoint.setRedact(cmd.redact);
4646
+ }
4541
4647
  };
4542
4648
  }
4543
4649
 
@@ -4660,7 +4766,7 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4660
4766
  // /login→/provider login, /settings→/config, /subagent(s)→/agents.
4661
4767
  input = normalizeSlashAlias(input);
4662
4768
  pendingSelection = undefined;
4663
- navMatches = [];
4769
+ navMatches = [];
4664
4770
  navIdx = -1;
4665
4771
  if (input === "/discord" || input.startsWith("/discord ")) {
4666
4772
  const parts = input.split(/\s+/);
@@ -4711,11 +4817,17 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4711
4817
  }
4712
4818
  continue;
4713
4819
  }
4820
+
4821
+ const remoteAttachmentCount = remoteAttachmentTextCounts.get(input) ?? 0;
4822
+ if (remoteAttachmentCount > 0) {
4823
+ if (remoteAttachmentCount === 1) remoteAttachmentTextCounts.delete(input);
4824
+ else remoteAttachmentTextCounts.set(input, remoteAttachmentCount - 1);
4825
+ }
4826
+ if (remoteAttachmentCount === 0 && input && !input.startsWith("/") && !input.startsWith("!")) {
4714
4827
  // File attachment: a dragged-and-dropped image file arrives as its path in
4715
4828
  // the typed text. Read any image path(s), attach them, and swap the path for
4716
4829
  // an [image #N] tag (continuing the clipboard-image numbering) — the same
4717
4830
  // scheme Ctrl+V uses. Skipped for slash commands and `!` shell escapes.
4718
- if (input && !input.startsWith("/") && !input.startsWith("!")) {
4719
4831
  const dropped = await attachImagePaths(input, pendingImages.length + 1);
4720
4832
  if (dropped.images.length > 0) {
4721
4833
  pendingImages.push(...dropped.images);
@@ -4956,7 +5068,14 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4956
5068
  if (input === "/goal" || input.startsWith("/goal ")) {
4957
5069
  const arg = input.substring(5).trim();
4958
5070
  if (!arg) {
4959
- const current = await readGoalState(cwd);
5071
+ let current: GoalState | null;
5072
+ try {
5073
+ current = await readGoalState(cwd);
5074
+ } catch (error) {
5075
+ if (!(error instanceof GoalStateCorruptError)) throw error;
5076
+ console.error(`${error.message}. Run /goal clear to remove it.`);
5077
+ continue;
5078
+ }
4960
5079
  if (current) {
4961
5080
  console.log(`Current goal: "${current.condition}" (set at ${new Date(current.setAt).toLocaleTimeString()})`);
4962
5081
  } else {
@@ -4970,10 +5089,19 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
4970
5089
  console.log("Goal cleared.");
4971
5090
  continue;
4972
5091
  }
5092
+ let previous: GoalState | null;
5093
+ try {
5094
+ previous = await readGoalState(cwd);
5095
+ } catch (error) {
5096
+ if (!(error instanceof GoalStateCorruptError)) throw error;
5097
+ console.error(`${error.message}. Run /goal clear before setting a new goal.`);
5098
+ continue;
5099
+ }
4973
5100
  const state = {
4974
5101
  condition: arg,
4975
5102
  setAt: Date.now(),
4976
5103
  verdicts: [],
5104
+ ...(previous?.pendingMutationVerification ? { pendingMutationVerification: true } : {}),
4977
5105
  };
4978
5106
  await writeGoalState(state, cwd);
4979
5107
  console.log(`Goal set to: "${arg}"`);