jeo-code 0.7.33 → 0.7.37

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.
@@ -29,6 +29,8 @@ import {
29
29
  } from "./subagents";
30
30
  import { resolveMaxOutputTokens } from "../ai/model-manager";
31
31
  import type { SubagentRegistry } from "./subagent-registry";
32
+ import { ensureSessionNotifyEndpoint } from "./notify/session-endpoint";
33
+
32
34
 
33
35
  /** Lifecycle event emitted while a delegated subagent runs. */
34
36
  export interface TaskSubEvent {
@@ -187,13 +189,171 @@ export function fenceSubagentReport(detail: string): string {
187
189
  ].join("\n");
188
190
  }
189
191
 
192
+ /**
193
+ * Build a `task` ToolHandler bound to a config + (optional) abort signal. The
194
+ * handler accepts `{ role?, task | prompt | assignment, context? }`.
195
+ */
196
+ export interface RunSubagentOptions {
197
+ /** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback. */
198
+ config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">;
199
+ /** Forwarded to the subagent's own runAgentLoop so Ctrl-C cancels nested work too. */
200
+ signal?: AbortSignal;
201
+ /** Optional live sink (e.g. plain-stream rendering of nested progress). */
202
+ onEvent?: (ev: TaskSubEvent) => void;
203
+ /** Mid-turn steering drain — see TaskToolOptions.steer for the exact contract. */
204
+ steer?: () => string[];
205
+ /** 1-based position within a fan-out/parallel batch, for event tagging. */
206
+ slot?: { index: number; total: number };
207
+ /** Pre-loaded project context to avoid re-scanning AGENTS.md per batch item. */
208
+ projectContext?: Awaited<ReturnType<typeof loadProjectContext>>;
209
+ }
210
+
211
+ /** `runSubagentOnce`'s result — a superset of `ToolResult` exposing the raw
212
+ * (unwrapped, unfenced) done reason and mutation-audit counters so a caller with
213
+ * its OWN pass/fail policy on top (e.g. `jeo team`'s role-gate-verdict parsing and
214
+ * `--strict-mutations`) doesn't have to re-derive them by parsing the formatted
215
+ * report text. `task`/`subagent` tool callers only ever need `success`/`output`/
216
+ * `error`, which stay structurally identical to plain `ToolResult`. */
217
+ export interface SubagentRunResult extends ToolResult {
218
+ /** Raw subagent done reason, before contract validation or fencing. */
219
+ doneReason: string;
220
+ /** True when the subagent actually called `done` (vs. exhausting its step
221
+ * budget or being force-stopped by an engine guard, e.g. a repeat-call loop). */
222
+ done: boolean;
223
+ /** Whether the done reason satisfied the role's required-marker contract. */
224
+ contractOk: boolean;
225
+ /** Required markers missing from the done reason, when `contractOk` is false. */
226
+ missingMarkers?: string[];
227
+ /** Successful write/edit/mkdir/delete calls observed (mutation audit evidence). */
228
+ fileMutations: number;
229
+ /** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
230
+ bashRuns: number;
231
+ }
232
+
233
+ /**
234
+ * Run ONE subagent role to completion against `taskText`/`context` and format its
235
+ * report (fenced against prompt injection). This is the single execution core
236
+ * shared by the `task` tool's single/fan-out/detached paths AND `jeo team`'s plan
237
+ * executor (`src/commands/team.ts`) — there is exactly one way a jeo-code subagent
238
+ * runs, whether it was launched interactively, in a batch, detached, or as a team
239
+ * plan step.
240
+ */
241
+ export async function runSubagentOnce(
242
+ role: ReturnType<typeof getSubagentRole> & {},
243
+ taskText: string,
244
+ context: string,
245
+ cwd: string,
246
+ opts: RunSubagentOptions,
247
+ ): Promise<SubagentRunResult> {
248
+ const { steer, slot, projectContext: preloadedContext, signal } = opts;
249
+ // Tag every live event with its fan-out slot so a parent monitor can tell
250
+ // task 1 from task 3 when several same-role subagents stream concurrently.
251
+ const emit = (ev: TaskSubEvent) =>
252
+ opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
253
+ const model = resolveSubagentModel(role.id, opts.config);
254
+ const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
255
+ // gjc parity: a role may pin its own reasoning budget; absent = inherit the
256
+ // session/global thinking level (the "(inherit)" row in the picker).
257
+ const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
258
+ const projectContext = preloadedContext ?? await loadProjectContext(cwd);
259
+ const memorySection = await memoryPromptSection(cwd, taskText);
260
+ const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
261
+ const history: Message[] = [
262
+ { role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
263
+ { role: "user", content: `${taskText}${context}` },
264
+ ];
265
+
266
+ const trace: string[] = [];
267
+ let lastTarget = "";
268
+ let currentStep = 0;
269
+ // Round-8 (architect ref 7-Round7Workflow): count the subagent's SUCCESSFUL
270
+ // calls so the parent can audit a "Changed Files:" claim against observed
271
+ // reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
272
+ // bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
273
+ // bash CAN mutate, so the audit message distinguishes the two cases.
274
+ let fileMutations = 0;
275
+ let bashRuns = 0;
276
+ emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
277
+ const result = await runAgentLoop(history, {
278
+ cwd,
279
+ model,
280
+ maxSteps,
281
+ maxTokens: resolveMaxOutputTokens(model, thinking),
282
+ // Per-run prompt-cache key: the subagent replays its own growing history each
283
+ // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
284
+ sessionKey: crypto.randomUUID(),
285
+ // Bounded delegation: a subagent's step contract stays exact — the parent
286
+ // owns any retry/extension decision, so the gjc retry flow is disabled here.
287
+ budget: { maxExtensions: 0 },
288
+ signal,
289
+ steer,
290
+ tools: subagentToolset(role),
291
+ events: {
292
+ onStep: n => { currentStep = n; },
293
+ onAssistant: (_raw, invocation) => {
294
+ if (invocation && invocation.tool && invocation.tool !== "done") {
295
+ lastTarget = toolTarget(invocation.tool, invocation.arguments);
296
+ trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
297
+ emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
298
+ }
299
+ },
300
+ onToolResult: (tool, success, output) => {
301
+ if (success) {
302
+ if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
303
+ else if (tool === "bash") bashRuns++;
304
+ }
305
+ const label = lastTarget || tool;
306
+ const summary = firstUsefulLine(output);
307
+ const suffix = summary ? ` — ${summary}` : "";
308
+ trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
309
+ emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
310
+ lastTarget = "";
311
+ },
312
+ // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
313
+ // parent's monitor shows WHY a subagent is pausing instead of going silent.
314
+ onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
315
+ // Mid-turn steering reached this subagent: surface it as a live beat so the
316
+ // parent's monitor shows the redirect instead of an unexplained behavior change.
317
+ onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
318
+ },
319
+ });
320
+ const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
321
+ const validation = validateSubagentDoneReason(role, reason);
322
+ const complete = result.done && validation.ok;
323
+ const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
324
+ emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
325
+ const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
326
+ const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
327
+ const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
328
+ // Parent-side audit: a mutating role that "completed" without a successful file
329
+ // mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
330
+ // bash is tracked separately: it CAN mutate, so an only-bash run downgrades to
331
+ // "verify independently" instead of the stronger UNVERIFIED.
332
+ const audit = complete && !role.readOnly && fileMutations === 0
333
+ ? bashRuns === 0
334
+ ? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
335
+ : `\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.`
336
+ : "";
337
+ return {
338
+ success: complete,
339
+ output: `${header}${body}\n\nResult:\n${fenceSubagentReport(detail)}${audit}`,
340
+ doneReason: reason,
341
+ done: result.done,
342
+ contractOk: validation.ok,
343
+ missingMarkers: validation.ok ? undefined : validation.missing,
344
+ fileMutations,
345
+ bashRuns,
346
+ };
347
+ }
348
+
190
349
  /**
191
350
  * Build a `task` ToolHandler bound to a config + (optional) abort signal. The
192
351
  * handler accepts `{ role?, task | prompt | assignment, context? }`.
193
352
  */
194
353
  export function createTaskTool(opts: TaskToolOptions): ToolHandler {
195
- /** Run ONE subagent to completion and format its result (the original single-task path). */
196
- const runOne = async (
354
+ /** Run ONE subagent via the shared execution core, binding this tool instance's
355
+ * config/signal/onEvent (the original single-task path). */
356
+ const runOne = (
197
357
  role: ReturnType<typeof getSubagentRole> & {},
198
358
  taskText: string,
199
359
  context: string,
@@ -206,98 +366,15 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
206
366
  * is cancellable independently of the parent turn. */
207
367
  signal?: AbortSignal;
208
368
  } = {},
209
- ): Promise<ToolResult> => {
210
- const { steer, slot, projectContext: preloadedContext, signal: signalOverride } = extra;
211
- // Tag every live event with its fan-out slot so a parent monitor can tell
212
- // task 1 from task 3 when several same-role subagents stream concurrently.
213
- const emit = (ev: TaskSubEvent) =>
214
- opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
215
- const model = resolveSubagentModel(role.id, opts.config);
216
- const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
217
- // gjc parity: a role may pin its own reasoning budget; absent = inherit the
218
- // session/global thinking level (the "(inherit)" row in the picker).
219
- const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
220
- const projectContext = preloadedContext ?? await loadProjectContext(cwd);
221
- const memorySection = await memoryPromptSection(cwd, taskText);
222
- const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
223
- const history: Message[] = [
224
- { role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
225
- { role: "user", content: `${taskText}${context}` },
226
- ];
227
-
228
- const trace: string[] = [];
229
- let lastTarget = "";
230
- let currentStep = 0;
231
- // Round-8 (architect ref 7-Round7Workflow): count the subagent's SUCCESSFUL
232
- // calls so the parent can audit a "Changed Files:" claim against observed
233
- // reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
234
- // bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
235
- // bash CAN mutate, so the audit message distinguishes the two cases.
236
- let fileMutations = 0;
237
- let bashRuns = 0;
238
- emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
239
- const result = await runAgentLoop(history, {
240
- cwd,
241
- model,
242
- maxSteps,
243
- maxTokens: resolveMaxOutputTokens(model, thinking),
244
- // Per-run prompt-cache key: the subagent replays its own growing history each
245
- // step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
246
- sessionKey: crypto.randomUUID(),
247
- // Bounded delegation: a subagent's step contract stays exact — the parent
248
- // owns any retry/extension decision, so the gjc retry flow is disabled here.
249
- budget: { maxExtensions: 0 },
250
- signal: signalOverride ?? opts.signal,
251
- steer,
252
- tools: subagentToolset(role),
253
- events: {
254
- onStep: n => { currentStep = n; },
255
- onAssistant: (_raw, invocation) => {
256
- if (invocation && invocation.tool && invocation.tool !== "done") {
257
- lastTarget = toolTarget(invocation.tool, invocation.arguments);
258
- trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
259
- emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
260
- }
261
- },
262
- onToolResult: (tool, success, output) => {
263
- if (success) {
264
- if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
265
- else if (tool === "bash") bashRuns++;
266
- }
267
- const label = lastTarget || tool;
268
- const summary = firstUsefulLine(output);
269
- const suffix = summary ? ` — ${summary}` : "";
270
- trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
271
- emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
272
- lastTarget = "";
273
- },
274
- // Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
275
- // parent's monitor shows WHY a subagent is pausing instead of going silent.
276
- onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
277
- // Mid-turn steering reached this subagent: surface it as a live beat so the
278
- // parent's monitor shows the redirect instead of an unexplained behavior change.
279
- onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
280
- },
369
+ ): Promise<ToolResult> =>
370
+ runSubagentOnce(role, taskText, context, cwd, {
371
+ config: opts.config,
372
+ signal: extra.signal ?? opts.signal,
373
+ onEvent: opts.onEvent,
374
+ steer: extra.steer,
375
+ slot: extra.slot,
376
+ projectContext: extra.projectContext,
281
377
  });
282
- const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
283
- const validation = validateSubagentDoneReason(role, reason);
284
- const complete = result.done && validation.ok;
285
- const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
286
- emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
287
- const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
288
- const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
289
- const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
290
- // Parent-side audit: a mutating role that "completed" without a successful file
291
- // mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
292
- // bash is tracked separately: it CAN mutate, so an only-bash run downgrades to
293
- // "verify independently" instead of the stronger UNVERIFIED.
294
- const audit = complete && !role.readOnly && fileMutations === 0
295
- ? bashRuns === 0
296
- ? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
297
- : `\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.`
298
- : "";
299
- return { success: complete, output: `${header}${body}\n\nResult:\n${fenceSubagentReport(detail)}${audit}` };
300
- };
301
378
 
302
379
  return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
303
380
  const roleArg = typeof args.role === "string" ? args.role.trim() : "";
@@ -398,6 +475,10 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
398
475
  const rec = opts.registry.launch(role.id, taskText, (signal, id) =>
399
476
  runOne(role, taskText, ctx(args.context), cwd, { signal, steer: opts.registry!.steerDrainFor(id) }),
400
477
  );
478
+ // Remote subagent visibility/control over Telegram (gjc daemon parity, see
479
+ // `src/agent/notify/`): best-effort, no-op unless `notifications.enabled`.
480
+ ensureSessionNotifyEndpoint(opts.registry, cwd);
481
+
401
482
  return {
402
483
  success: true,
403
484
  output:
@@ -189,22 +189,23 @@ function stripAnchorPrefixes(block: string): string | null {
189
189
  if (!lines.every(l => l.trim() === "" || ANCHOR_PREFIX_RE.test(l))) return null;
190
190
  return lines.map(l => l.replace(ANCHOR_PREFIX_RE, "")).join("\n");
191
191
  }
192
- // ── File-freshness guard (plan/gjc-inheritance.md B7, gjc edit/file-read-cache 계승) ──
193
- // `read` records each file's stat fingerprint; `edit`/`write` verify it before
194
- // mutating. A file changed by someone else (concurrent agent, user, formatter)
195
- // between the read and the edit is REJECTED once — with the CURRENT content
196
- // re-presented so the model can retry immediately (recovery, not just a guard).
197
- const lastReadSnapshots = new Map<string, { mtimeMs: number; size: number }>();
192
+ // ── Blind-edit guard (plan/gjc-inheritance.md B7, gjc edit/file-read-cache 계승) ──
193
+ // `read` records that this session has SEEN a path; a line-range `edit` with
194
+ // no hash anchors on either bound refuses to run against a path never read
195
+ // this session (see the narrow guard below in editTool). NOTE: this module
196
+ // does NOT reject a write/edit just because the file changed on disk since
197
+ // the last read — the agent's write always wins (2026-07: guardrail removed
198
+ // per explicit user direction to stop blocking on external/concurrent edits).
199
+ const readThisSession = new Set<string>();
198
200
  const MAX_SNAPSHOT_ENTRIES = 64;
199
201
 
200
- function recordReadSnapshot(absPath: string, st: { mtimeMs: number; size: number } | null): void {
201
- if (!st) return;
202
- if (lastReadSnapshots.size >= MAX_SNAPSHOT_ENTRIES && !lastReadSnapshots.has(absPath)) {
203
- const oldest = lastReadSnapshots.keys().next().value;
204
- if (oldest !== undefined) lastReadSnapshots.delete(oldest);
202
+ function markRead(absPath: string): void {
203
+ if (readThisSession.size >= MAX_SNAPSHOT_ENTRIES && !readThisSession.has(absPath)) {
204
+ const oldest = readThisSession.values().next().value;
205
+ if (oldest !== undefined) readThisSession.delete(oldest);
205
206
  }
206
- lastReadSnapshots.delete(absPath); // re-insert to refresh LRU order
207
- lastReadSnapshots.set(absPath, { mtimeMs: st.mtimeMs, size: st.size });
207
+ readThisSession.delete(absPath); // re-insert to refresh LRU order
208
+ readThisSession.add(absPath);
208
209
  }
209
210
 
210
211
  /** Annotated excerpt of the file's CURRENT content for recovery errors (read-format `N|`). */
@@ -221,26 +222,6 @@ function excerptForRecovery(content: string, centerLine?: number): string {
221
222
  const note = lines.length > end - start + 1 ? `\n…(showing lines ${start}-${end} of ${lines.length})` : "";
222
223
  return body + note;
223
224
  }
224
-
225
- /** Returns a recovery error when `absPath` changed since the agent last read it; null when fresh.
226
- * Refreshes the snapshot to CURRENT so the immediate retry (model just saw fresh content) passes. */
227
- async function staleReadError(absPath: string, filePath: string, verb: string): Promise<ToolResult | null> {
228
- const snap = lastReadSnapshots.get(absPath);
229
- if (!snap) return null; // never read → no guard here (see the narrow blind-replace guard in editTool)
230
- const st = await fs.stat(absPath).catch(() => null);
231
- if (!st || !st.isFile()) return null; // deleted/replaced-by-dir: let the op surface its own error
232
- if (st.mtimeMs === snap.mtimeMs && st.size === snap.size) return null;
233
- const content = await fs.readFile(absPath, "utf-8").catch(() => null);
234
- recordReadSnapshot(absPath, st); // the model sees the fresh content below → retry passes
235
- const excerpt = content !== null ? `\nCurrent content:\n${excerptForRecovery(content)}` : "";
236
- return {
237
- success: false,
238
- output: "",
239
- error:
240
- `${verb} rejected: ${filePath} changed on disk since you last read it (another agent, the user, or a formatter touched it). ` +
241
- `Re-target your ${verb.toLowerCase()} against the CURRENT content below, then retry.${excerpt}`,
242
- };
243
- }
244
225
  export async function readTool(
245
226
  filePath: string,
246
227
  lineRange?: string | number,
@@ -261,7 +242,7 @@ export async function readTool(
261
242
  return lsTool(filePath, cwd);
262
243
  }
263
244
  const content = await fs.readFile(absPath, "utf-8");
264
- if (st?.isFile()) recordReadSnapshot(absPath, st); // arm the edit/write freshness guard
245
+ if (st?.isFile()) markRead(absPath); // arm the blind-edit read-first guard
265
246
 
266
247
  if (raw) {
267
248
  // Verbatim bytes, no "N|" line prefixes (gjc `:raw`), char-capped for context safety.
@@ -339,11 +320,9 @@ export async function writeTool(
339
320
  }
340
321
  await assertMutationAllowed(filePath, cwd);
341
322
  const absPath = path.resolve(cwd, filePath);
342
- const stale = await staleReadError(absPath, filePath, "Write");
343
- if (stale) return stale;
344
323
  await fs.mkdir(path.dirname(absPath), { recursive: true });
345
324
  await fs.writeFile(absPath, content, "utf-8");
346
- recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
325
+ markRead(absPath); // own change counts as having seen the file
347
326
  return { success: true, output: `Successfully wrote ${content.length} characters to ${filePath}` };
348
327
  } catch (err: any) {
349
328
  return { success: false, output: "", error: err.message };
@@ -392,8 +371,6 @@ export async function editTool(
392
371
  }
393
372
  await assertMutationAllowed(filePath, cwd);
394
373
  const absPath = path.resolve(cwd, filePath);
395
- const stale = await staleReadError(absPath, filePath, "Edit");
396
- if (stale) return stale;
397
374
  let content = await fs.readFile(absPath, "utf-8");
398
375
 
399
376
  // Line-anchored edit parser. Modes (payload follows the directive's newline):
@@ -517,7 +494,7 @@ export async function editTool(
517
494
  // (Insert/append directives above are unaffected: they don't destroy content.
518
495
  // Checked AFTER the bounds check so an out-of-range request still reports as
519
496
  // out-of-range rather than being masked by this guard.)
520
- if (!aStart && !aEnd && !lastReadSnapshots.has(absPath)) {
497
+ if (!aStart && !aEnd && !readThisSession.has(absPath)) {
521
498
  return {
522
499
  success: false,
523
500
  output: "",
@@ -605,7 +582,7 @@ export async function editTool(
605
582
  }
606
583
 
607
584
  await fs.writeFile(absPath, content, "utf-8");
608
- recordReadSnapshot(absPath, await fs.stat(absPath).catch(() => null)); // own change ≠ stale
585
+ markRead(absPath); // own change counts as having seen the file
609
586
  return { success: true, output: `Successfully updated ${filePath}` };
610
587
  } catch (err: any) {
611
588
  return { success: false, output: "", error: err.message };
@@ -1147,7 +1124,7 @@ export async function deleteTool(
1147
1124
  return { success: false, output: "", error: `${targetPath} is a directory — pass recursive:true to remove it and its contents.` };
1148
1125
  }
1149
1126
  await fs.rm(abs, { recursive, force: false });
1150
- lastReadSnapshots.delete(abs); // a future write to this path must not be flagged stale
1127
+ readThisSession.delete(abs); // a future edit against this path must read it again first
1151
1128
  return { success: true, output: `Deleted ${st.isDirectory() ? "directory" : "file"}: ${targetPath}` };
1152
1129
  } catch (err: any) {
1153
1130
  return { success: false, output: "", error: err.message };
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
5
  import { ProviderHttpError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
 
8
9
  const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
9
10
 
@@ -306,7 +307,7 @@ export function anthropicPayload(
306
307
  if (stream) payload.stream = true;
307
308
  const system = anthropicSystemBlocks(systemPrompt, model, credential, payload, options.baseUrl);
308
309
  if (system) payload.system = system;
309
- return JSON.stringify(payload);
310
+ return JSON.stringify(sanitizeJsonStrings(payload));
310
311
  }
311
312
 
312
313
  export function anthropicRequest(
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
4
4
  import { readSse } from "../sse";
5
5
  import { providerHttpError } from "./errors";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
  import { geminiThinkingConfig, getGeminiCliHeaders, type GeminiThinkingConfig } from "./gemini";
8
9
 
9
10
  const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
@@ -204,14 +205,14 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
204
205
  request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
205
206
  }
206
207
 
207
- const body = JSON.stringify({
208
+ const body = JSON.stringify(sanitizeJsonStrings({
208
209
  project,
209
210
  model,
210
211
  request,
211
212
  requestType: "agent",
212
213
  userAgent: "antigravity",
213
214
  requestId: `agent-${randomUUID()}`,
214
- });
215
+ }));
215
216
  return {
216
217
  url: `${endpoint}/v1internal:streamGenerateContent?alt=sse`,
217
218
  headers: {
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
4
4
  import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
5
5
  import { jeoEnv } from "../../util/env";
6
6
  import { serializeToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
 
8
9
  /** Gemini 2.5+/latest models think by default and BILL thought tokens against
9
10
  * `maxOutputTokens` — a small-budget call can burn its entire budget on thoughts
@@ -205,7 +206,7 @@ export function geminiRequest(messages: Message[], options: CallOptions, credent
205
206
  const headers: Record<string, string> = oauth
206
207
  ? { "content-type": "application/json", authorization: `Bearer ${oauth}` }
207
208
  : { "content-type": "application/json" };
208
- return { url, headers, body: JSON.stringify(payload) };
209
+ return { url, headers, body: JSON.stringify(sanitizeJsonStrings(payload)) };
209
210
  }
210
211
 
211
212
  const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
@@ -236,7 +237,7 @@ export function geminiCliRequest(messages: Message[], options: CallOptions, acce
236
237
  accept: "text/event-stream",
237
238
  ...getGeminiCliHeaders(geminiModel),
238
239
  },
239
- body: JSON.stringify({ project: projectId, model: geminiModel, request: payload }),
240
+ body: JSON.stringify(sanitizeJsonStrings({ project: projectId, model: geminiModel, request: payload })),
240
241
  };
241
242
  }
242
243
 
@@ -2,6 +2,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
2
2
  import { readLines } from "../sse";
3
3
  import { providerHttpError } from "./errors";
4
4
  import { createThinkSplitter } from "../think-tags";
5
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
5
6
 
6
7
  /**
7
8
  * Resolve the Ollama base URL. `OLLAMA_HOST` is documented as a bare host:port
@@ -52,7 +53,7 @@ function ollamaRequest(messages: Message[], options: CallOptions, stream: boolea
52
53
  };
53
54
  if (options.jsonMode) payload.format = "json";
54
55
  const base = normalizeOllamaBaseUrl(options.baseUrl);
55
- return { url: `${base}/api/chat`, body: JSON.stringify(payload) };
56
+ return { url: `${base}/api/chat`, body: JSON.stringify(sanitizeJsonStrings(payload)) };
56
57
  }
57
58
 
58
59
  /** Round-5 #1: surface done_reason when a 200 carries no text (uniform contract). */
@@ -15,6 +15,7 @@ import type { CallOptions, Message } from "../types";
15
15
  import { readSse } from "../sse";
16
16
  import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
17
17
  import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
18
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
18
19
  import os from "node:os";
19
20
  import pkg from "../../../package.json";
20
21
 
@@ -141,7 +142,7 @@ export function codexResponsesRequest(
141
142
  return {
142
143
  url: `${base}/responses`,
143
144
  headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream", "user-agent": codexUserAgent() },
144
- body: JSON.stringify(payload),
145
+ body: JSON.stringify(sanitizeJsonStrings(payload)),
145
146
  };
146
147
  }
147
148
  const accountId = extractChatgptAccountId(token);
@@ -161,7 +162,7 @@ export function codexResponsesRequest(
161
162
  headers.conversation_id = promptCacheKey;
162
163
  headers["x-client-request-id"] = promptCacheKey;
163
164
  }
164
- return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(payload) };
165
+ return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(sanitizeJsonStrings(payload)) };
165
166
  }
166
167
 
167
168
  /** gjc parity (normalizeOpenAIResponsesPromptCacheKey): well-formed unicode, ≤64 chars
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
4
4
  import { providerHttpError } from "./errors";
5
5
  import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
6
6
  import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
7
+ import { sanitizeJsonStrings } from "../../util/sanitize-json";
7
8
  import { createThinkSplitter, stripLeakedReasoningTags } from "../think-tags";
8
9
 
9
10
  /** True for OpenAI reasoning models (o-series + gpt-5+ family). Digit-count agnostic
@@ -96,7 +97,7 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
96
97
  return {
97
98
  url: `${base}/chat/completions`,
98
99
  headers: { "content-type": "application/json", Authorization: `Bearer ${bearerFor(credential)}` },
99
- body: JSON.stringify(payload),
100
+ body: JSON.stringify(sanitizeJsonStrings(payload)),
100
101
  };
101
102
  }
102
103
 
package/src/cli/runner.ts CHANGED
@@ -101,6 +101,25 @@ export const COMMANDS: readonly CommandSpec[] = [
101
101
  return args => m.runMcpCommand(args);
102
102
  },
103
103
  },
104
+ {
105
+ name: "notify",
106
+ summary: "Configure/inspect remote subagent notifications over Telegram (gjc notify parity).",
107
+ usage: "notify [setup [--token <t> --chat-id <id>]|status]",
108
+ loader: async () => {
109
+ const m = await import("../commands/notify");
110
+ return args => m.runNotifyCommand(args);
111
+ },
112
+ },
113
+ {
114
+ name: "daemon",
115
+ summary: "Manage the background Telegram notification/subagent-control daemon.",
116
+ usage: "daemon [status|start|stop|reload]",
117
+ loader: async () => {
118
+ const m = await import("../commands/daemon");
119
+ return args => m.runDaemonCommand(args);
120
+ },
121
+ },
122
+
104
123
  {
105
124
  name: "skills",
106
125
  summary: "List bundled workflow skills, or sync them into ~/.jeo/skills (jeo skills <name> for details).",
@@ -155,6 +174,16 @@ export const COMMANDS: readonly CommandSpec[] = [
155
174
  return args => m.runMemoryMigrateCommand(args);
156
175
  },
157
176
  },
177
+ {
178
+ name: "notify-daemon-run",
179
+ summary: "(internal) Foreground worker for the Telegram notification daemon, spawned by 'jeo daemon start'.",
180
+ usage: "notify-daemon-run",
181
+ loader: async () => {
182
+ const m = await import("../agent/notify/telegram-daemon");
183
+ return async () => m.runNotifyDaemonForeground();
184
+ },
185
+ },
186
+
158
187
  {
159
188
  name: "state",
160
189
  summary: "Read or update workflow state receipts under .jeo/state (gjc-state parity).",
@@ -9,16 +9,20 @@ Concrete implementations of `jeo` subcommands (e.g., launch, setup, team, ultrag
9
9
  ## Key Files
10
10
  | File | Description |
11
11
  |------|-------------|
12
- | `approve.ts` | Brief description of purpose |
12
+ | `approve.ts` | `jeo approve <plan-path>` CLI + exported `approvePlan()` core (schema shape, known roles, persisted [OKAY] consensus, hash-vs-consensus match) — shared with the agent-facing `approve` tool (`src/agent/approve-tool.ts`) so the agent can flip a plan's `approved` flag itself; the identity restriction (human-only) was removed 2026-07 per explicit user direction, the content gate was not |
13
13
  | `auth.ts` | Brief description of purpose |
14
14
  | `chat.ts` | Brief description of purpose |
15
15
  | `deep-interview.ts` | Brief description of purpose |
16
+ | `daemon.ts` | `jeo daemon status\|start\|stop\|reload` — manages the background Telegram notification/subagent-control daemon (`src/agent/notify/daemon-control.ts`) |
16
17
  | `doctor.ts` | Brief description of purpose |
18
+
17
19
  | `evolve-core.ts` | Brief description of purpose |
18
20
  | `evolve.ts` | Brief description of purpose |
19
21
  | `export.ts` | Brief description of purpose |
20
22
  | `launch.ts` | The primary interactive/one-shot execution command |
21
23
  | `mcp.ts` | Brief description of purpose |
24
+ | `notify.ts` | `jeo notify setup\|status` — pairs a Telegram bot (BotFather token + `getMe` verification, private-chat pairing via `getUpdates`) and reports masked settings + daemon state |
25
+
22
26
  | `ooo-seed.ts` | Brief description of purpose |
23
27
  | `ralplan.ts` | Brief description of purpose |
24
28
  | `resume.ts` | Brief description of purpose |
@@ -28,7 +32,7 @@ Concrete implementations of `jeo` subcommands (e.g., launch, setup, team, ultrag
28
32
  | `skills.ts` | Brief description of purpose |
29
33
  | `state.ts` | Brief description of purpose |
30
34
  | `status.ts` | Brief description of purpose |
31
- | `team.ts` | Multi-agent coordination command |
35
+ | `team.ts` | `jeo team` — executes an approved ralplan plan step-by-step via the SAME `runSubagentOnce` core the `task`/`subagent` tools use; a contiguous run of steps sharing a plan `parallel_group` runs concurrently in isolated git worktrees (`runParallelGroup`), merged back in order (gjc `team` concurrency parity) |
32
36
  | `ultragoal.ts` | Brief description of purpose |
33
37
  | `update.ts` | Brief description of purpose |
34
38