Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/desktop/src/components/chat/tool-meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
FileSearch,
FileText,
FileUp,
FlagOff,
FolderKanban,
FolderSearch,
Globe,
Expand Down Expand Up @@ -416,6 +417,7 @@ const GODMODE_TOOLS = new Set([
"notify_user",
"followup_schedule",
"followup_cancel",
"goal_clear",
"ask_human",
"request_approval",
"tasks_list",
Expand Down Expand Up @@ -571,6 +573,8 @@ function godmodeMeta(tool: string, input: Input, ctx: ToolContext): Omit<ToolMet
return { kind: "notify", icon: AlarmClock, title: `Will continue ${followupTime(input)}`, detail: truncate(str(input.note), 120) || undefined };
case "followup_cancel":
return { kind: "notify", icon: AlarmClockOff, title: "Cancelled its follow-up" };
case "goal_clear":
return { kind: "notify", icon: FlagOff, title: "Ended the goal", detail: truncate(str(input.reason), 120) || undefined };
case "ask_human":
return { kind: "agents", icon: MessageCircleQuestion, title: "Asked you", detail: truncate(str(input.question), 120) || undefined };
case "request_approval":
Expand Down
1 change: 1 addition & 0 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ the core, the desktop and the phone alike.
| `my_routines()`, `my_routine_set_enabled({ routineId, enabled, reason? })` | For agents without `canManageAgents`, not on a runner: list their own automations and switch one off or back on (e.g. the human asked them to stop a scheduled job). Other agents' automations stay the human's. Switching back on only undoes its own pause (its `routine.pause` audit entry is the last change), and follows the `routine_update` rule (a fill-only run makes it fill-only); audited as `routine.pause` / `routine.resume` |
| `artifact_create`, `artifact_update`, `artifact_get`, `artifact_list` | Build and change artifacts: small web apps shown next to the chat and on the Artifacts page (see Artifacts); not in condition checks or dreams, not on a runner |
| `followup_schedule({ at \| inMinutes, note })`, `followup_cancel()` | Continue this chat later on its own (see Follow-ups); not in condition checks |
| `goal_clear({ reason })` | End the chat's `/goal` when the human dropped or replaced it: the goal's check stops holding the turn up, and after the turn Godmode runs `/goal clear` in the Claude session (a local command, no model call) and notes it in the chat; the goal is read from the session's latest `goal_status` |
| `api_tools_list()`, `api_tool_docs({ tool })`, `api_tool_request({ tool, method, path, json \| form \| body, query, saveAs })` | Only for agents with API tools: list them, read one's docs, call its API with the key added by Godmode (see Integrations) |

Runs may get three more servers behind the gateway, all with the same run token: `/mcp/computer` (see Computer use),
Expand Down
21 changes: 19 additions & 2 deletions packages/core/src/mcp/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,15 +140,15 @@ import { all, get } from "../db";
import { config } from "../config";
import { link } from "../remote/runners";
import { loginFillScope } from "../browser/fill";
import { chatFillOnly, createConversation, isPrivateChat, maskSecretInChat, sendMessage } from "../services/conversations";
import { chatFillOnly, chatGoal, createConversation, isPrivateChat, maskSecretInChat, sendMessage } from "../services/conversations";
import { assignVm, createVm, getVm, listVms, sharedDirOf, startVm, stopVm, suspendVm, vmInUse, vmOfRun, vmStatus } from "../vm/service";
import { resolveVmId } from "../vm/assignments";
import { androidStatus, assignDevice, freshDevices } from "../android/service";
import { getSettings } from "../services/settings";
import { agentSettingsRefusal, applyAgentSettings, humanOnlySettings, settingsSections } from "../services/agentSettings";
import { spendReport } from "../services/spend";
import { budgetOverview, budgetSentence, exhaustedBudget } from "../services/budgets";
import { cancelRun, getRun, listRuns, markMissingLoginReported, runBrowserProfile, runChatBrowserProfile, waitForRun, runExempt } from "../runner/runner";
import { cancelRun, endGoalAfterTurn, getRun, runClaudeConfigDir, listRuns, markMissingLoginReported, runBrowserProfile, runChatBrowserProfile, waitForRun, runExempt } from "../runner/runner";
import { listGoals } from "../tasks/goals";
import { addTaskNote, createTask, findTask, getTask, listTaskEvents, listTasks, reportBlocked, sendTaskMessage, taskForConversation, updateTask } from "../tasks/service";
import { describeNow } from "../runner/prompt";
Expand Down Expand Up @@ -1797,6 +1797,23 @@ const TOOLS: ToolDef[] = [
run: (_args, { ctx }) => (cancelFollowup(ctx.conversationId) ? "Follow-up removed." : "This chat had no follow-up."),
}),

defineTool({
name: "goal_clear",
description:
"End this chat's `/goal` yourself, as the human would with `/goal clear`. Only when the goal is over without being met: the human told you to end, drop or replace it, or a decision of theirs leaves nothing to work towards. Never to get out of work that is merely hard, long or unfinished. The goal's check no longer holds this turn up: Godmode clears the goal when the turn is over and notes it in the chat with your reason. Never hand the human a task to type `/goal clear`.",
schema: z.object({
reason: z.string().min(1).max(300).describe('Why it ends, shown in the chat, e.g. "Daniel decided on 10-28 to end it"'),
}),
when: canFollowUp,
run: ({ reason }, { ctx }) => {
const goal = chatGoal(ctx.conversationId, runClaudeConfigDir(ctx.runId));
if (!goal) return "This chat has no active /goal — nothing to end.";
const refused = endGoalAfterTurn(ctx.runId, reason);
if (refused) return fail(refused);
return `The goal “${goal.length > 200 ? `${goal.slice(0, 199).trimEnd()}…` : goal}” ends when this turn is over. Finish your answer now and don't work on it any further.`;
},
}),

defineTool({
name: "artifact_create",
description: `Build an artifact: a small web app the human opens right in Godmode — a sales dashboard, a KPI tracker, a deal board, a calculator, a planner. Use it whenever they ask for a dashboard, a tracker, a tool or a page to look at or work with (not for a one-off answer or a file they asked for). It shows next to this chat and on the Artifacts page; they change it by chatting with you, and every code change is a version they can go back to.
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/runner/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -406,7 +406,7 @@ When you can't go on because ${human} has to do something themselves — solve a

if (ctx.followups) {
out.push(`### Following up later
When a task can't be finished now because you have to wait — for a reply to an email or message, a delivery, a build or deployment, a status or price change, office hours, another person — don't leave it to ${human} to remind you. Schedule a follow-up with \`followup_schedule({ at | inMinutes, note })\`, like a coworker who says "I'll check back tomorrow at 10": at that time Godmode continues this chat on its own and you pick up where you left off, with the whole conversation. Pick a realistic time (when the answer is likely there; business hours when people are involved) and write the note so you know exactly what to check and do. Then end your turn with a short summary of what you're waiting for and when you'll continue. The same holds for a \`/goal\` whose remaining parts wait on such outside events: schedule the follow-up and end the turn with a short status; Godmode pauses the goal loop until then. Don't wait inside the turn instead: no \`sleep\`, \`until\` or polling loop that runs longer than about ten minutes, in the foreground or as a background task — a run that shows no sign of life that long is stopped. A chat has one follow-up: scheduling again moves it, \`followup_cancel\` removes it. Don't schedule follow-ups for work you can do now or for things that repeat on a schedule${perms.canManageAgents ? " (those are automations)" : ""}.`);
When a task can't be finished now because you have to wait — for a reply to an email or message, a delivery, a build or deployment, a status or price change, office hours, another person — don't leave it to ${human} to remind you. Schedule a follow-up with \`followup_schedule({ at | inMinutes, note })\`, like a coworker who says "I'll check back tomorrow at 10": at that time Godmode continues this chat on its own and you pick up where you left off, with the whole conversation. Pick a realistic time (when the answer is likely there; business hours when people are involved) and write the note so you know exactly what to check and do. Then end your turn with a short summary of what you're waiting for and when you'll continue. The same holds for a \`/goal\` whose remaining parts wait on such outside events: schedule the follow-up and end the turn with a short status; Godmode pauses the goal loop until then. When ${human} ends, drops or replaces a \`/goal\`, end it yourself with \`goal_clear\`. Don't wait inside the turn instead: no \`sleep\`, \`until\` or polling loop that runs longer than about ten minutes, in the foreground or as a background task — a run that shows no sign of life that long is stopped. A chat has one follow-up: scheduling again moves it, \`followup_cancel\` removes it. Don't schedule follow-ups for work you can do now or for things that repeat on a schedule${perms.canManageAgents ? " (those are automations)" : ""}.`);
}

if (ctx.calls) {
Expand Down
83 changes: 83 additions & 0 deletions packages/core/src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,10 @@ interface Job {
private?: boolean;
/** Godmode ended it because a `/goal` kept it going with nothing to do: it succeeded. */
goalPaused?: boolean;
/** The agent ended the chat's `/goal` (`goal_clear`) when the hook had blocked this many times: Godmode clears it once the process is done. */
goalClear?: { reason: string; atCheck: number };
/** Where its Claude Code keeps sessions (GLM runs have a config folder of their own); null = the default. */
claudeConfigDir?: string | null;
/** Resolves ids in tool input for the activity label (memoized per run). */
names?: ActivityNames;
/** The human started or let it run: a used-up monthly budget doesn't hold it. */
Expand Down Expand Up @@ -1803,6 +1807,12 @@ async function spawnClaude(
if (job.acc.push(event)) scheduleDelta(job);
showContext(job);
if (job.acc.goalIdle >= GOAL_IDLE_LIMIT && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) pauseGoal(job, proc);
// The agent ended the goal: the hook's next block is only the goal it no longer pursues.
if (job.goalClear && job.acc.goalChecks > job.goalClear.atCheck && !job.goalPaused && !job.pause && !halted(job) && !job.timedOut && !job.stalled) {
job.goalPaused = true;
log.info(`run ${job.runId}: the agent ended the goal, ending the run`);
killTree(proc);
}
// Claude has read the human's answer the moment it starts replying: settle it now, so a crash later in the run
// can't hand the same answer (an approval: "Do it now") to the next turn again.
if (job.acc.answered && !job.answersSettled) {
Expand Down Expand Up @@ -1841,6 +1851,72 @@ function pauseGoal(job: Job, proc: Subprocess) {
killTree(proc);
}

/**
* `goal_clear`: the agent ends the chat's `/goal` (the human dropped it, replaced it, or it can no longer be reached).
* The goal's check doesn't hold this turn up anymore, and Godmode clears the goal in the session once the process is done.
*/
export function endGoalAfterTurn(runId: string, reason: string): string | null {
const job = jobs.get(runId);
if (!job || job.status !== "running" || job.cancelReason || job.timedOut || job.stalled) return "This run is being stopped — the goal stays. Try again in the next turn.";
job.goalClear = { reason, atCheck: job.acc.goalChecks };
return null;
}

/** The Claude Code config folder the run's sessions live in, or null for the default (or no such run). */
export function runClaudeConfigDir(runId: string): string | null {
return jobs.get(runId)?.claudeConfigDir ?? null;
}

const GOAL_CLEAR_TIMEOUT_MS = 60_000;

/** Clear the session's `/goal` as the human would with `/goal clear`: Claude Code runs it without the model. */
async function clearGoal(job: Job, cmd: string[], sessionId: string, cwd: string, env: Record<string, string | undefined>): Promise<void> {
const reason = job.goalClear?.reason ?? "";
let said = "";
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const proc = Bun.spawn({
cmd: [...cmd, "-p", "--resume", sessionId, "--output-format", "json", "--setting-sources", "", "--strict-mcp-config"],
cwd,
env,
stdin: "pipe",
stdout: "pipe",
stderr: "ignore",
detached: process.platform !== "win32",
});
job.proc = proc;
timer = setTimeout(() => killTree(proc), GOAL_CLEAR_TIMEOUT_MS);
proc.stdin.write("/goal clear");
await proc.stdin.end();
const [out] = await Promise.all([new Response(proc.stdout).text(), proc.exited]);
for (const line of out.trim().split("\n").reverse()) {
try {
const event = JSON.parse(line) as { type?: unknown; result?: unknown };
if (event.type === "result" && typeof event.result === "string") {
said = event.result;
break;
}
} catch {
/* not a line of its own */
}
}
} catch (err) {
log.warn(`run ${job.runId}: could not clear the goal of session ${sessionId}`, err);
} finally {
clearTimeout(timer);
job.proc = null;
}
const cleared = /^Goal cleared: ([\s\S]+)/.exec(said.trim());
if (cleared) {
const goal = cleared[1]!.replace(/\s+/g, " ").trim();
job.acc.addNotice("info", `Goal ended: “${goal.length > 160 ? `${goal.slice(0, 159).trimEnd()}…` : goal}”${reason ? ` — ${reason.replace(/[.!]$/, "")}` : ""}.`);
log.info(`run ${job.runId}: goal of session ${sessionId} cleared`);
} else if (!/^No goal set/.test(said.trim())) {
job.acc.addNotice("warning", "Godmode couldn't end this chat's goal. Type /goal clear to end it.");
}
scheduleDelta(job);
}

/** The model a run is recorded with: what Claude reported, but for GLM the requested id (Z.ai may name it otherwise). */
function recordedModel(job: Job, acc: { model: string | null }): string | null {
return job.model && modelProvider(job.model) === "zai" ? job.model : (acc.model ?? job.model ?? null);
Expand Down Expand Up @@ -2345,6 +2421,7 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise<Outcom
// doesn't load. With it they reach the chat as notes from the mod. It also watches the folders — the run's own copies.
if (mods?.dirs.length) env.CLAUDE_CODE_PLUGIN_DIR_WATCH = "1";
if (zai) applyZaiEnv(env, model, zai);
job.claudeConfigDir = env.CLAUDE_CONFIG_DIR ?? null;
// Scripts that drive the browser themselves (Playwright, puppeteer) get the run's own endpoint: its profile, its chat's tabs.
const cdpUrl = !vm && mcp.mcpServers[BROWSER_MCP_NAME] ? chatLeaseUrl(job.runId) : null;
if (cdpUrl) env[RUN_CDP_ENV] = cdpUrl;
Expand Down Expand Up @@ -2442,6 +2519,12 @@ async function runClaude(job: Job, agent: Agent, res: Resources): Promise<Outcom
);
}

// A Stop by the human leaves the goal as it is; a stop to send the queue now or to shut down doesn't.
if (job.goalClear && job.stoppedByHuman && !job.thenQueue) {
job.acc.addNotice("warning", "Stopped before the goal ended: it is still set.");
scheduleDelta(job);
} else if (job.goalClear) await clearGoal(job, cmd, job.acc.sessionId ?? sessionId, cwd, env);

const final = job.acc.final;
const answer = !!final && !final.isError;
// Whatever Claude Code reports as the outcome, its own line about the limit as the answer is no answer.
Expand Down
3 changes: 3 additions & 0 deletions packages/core/src/runner/stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ export class StreamAccumulator {
goalIdle = 0;
/** The hook's latest verdict, without the goal it restates. */
goalFeedback: string | null = null;
/** How often the hook blocked the end of a turn in this stretch. */
goalChecks = 0;
private compacting = false;
/** The model of the latest top-level message (subagents may run smaller ones). */
private mainModel: string | null = null;
Expand Down Expand Up @@ -718,6 +720,7 @@ export class StreamAccumulator {
*/
private onGoalCheck(text: string): void {
const busy = [...this.tasks.values()].some((t) => t.status === "running");
this.goalChecks++;
if (this.goalFeedback !== null) this.goalIdle = this.toolCalls > this.toolCallsAtGoalCheck || busy ? 0 : this.goalIdle + 1;
this.toolCallsAtGoalCheck = this.toolCalls;
// "[<the goal>]: Not satisfied. …": the verdict is what tells.
Expand Down
Loading
Loading