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.
- package/CHANGELOG.md +52 -1
- package/README.ja.md +4 -4
- package/README.ko.md +4 -4
- package/README.md +4 -4
- package/README.zh.md +4 -4
- package/package.json +2 -1
- package/src/agent/AGENTS.md +5 -2
- package/src/agent/approve-tool.ts +42 -0
- package/src/agent/config-schema.ts +17 -0
- package/src/agent/notify/AGENTS.md +47 -0
- package/src/agent/notify/daemon-control.ts +158 -0
- package/src/agent/notify/paths.ts +41 -0
- package/src/agent/notify/session-endpoint.ts +196 -0
- package/src/agent/notify/telegram-api.ts +83 -0
- package/src/agent/notify/telegram-daemon.ts +358 -0
- package/src/agent/plan.ts +27 -1
- package/src/agent/state.ts +9 -0
- package/src/agent/task-tool.ts +174 -93
- package/src/agent/tools.ts +19 -42
- package/src/ai/providers/anthropic.ts +2 -1
- package/src/ai/providers/antigravity.ts +3 -2
- package/src/ai/providers/gemini.ts +3 -2
- package/src/ai/providers/ollama.ts +2 -1
- package/src/ai/providers/openai-responses.ts +3 -2
- package/src/ai/providers/openai.ts +2 -1
- package/src/cli/runner.ts +29 -0
- package/src/commands/AGENTS.md +6 -2
- package/src/commands/approve.ts +63 -54
- package/src/commands/daemon.ts +45 -0
- package/src/commands/launch.ts +54 -4
- package/src/commands/notify.ts +109 -0
- package/src/commands/ralplan.ts +11 -1
- package/src/commands/team.ts +266 -79
- package/src/util/sanitize-json.ts +42 -0
package/src/agent/task-tool.ts
CHANGED
|
@@ -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
|
|
196
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
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:
|
package/src/agent/tools.ts
CHANGED
|
@@ -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
|
-
// ──
|
|
193
|
-
// `read` records
|
|
194
|
-
//
|
|
195
|
-
//
|
|
196
|
-
//
|
|
197
|
-
|
|
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
|
|
201
|
-
if (!
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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())
|
|
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
|
-
|
|
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 && !
|
|
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
|
-
|
|
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
|
-
|
|
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).",
|
package/src/commands/AGENTS.md
CHANGED
|
@@ -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` |
|
|
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` |
|
|
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
|
|