You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Today a standing goal is a single bare string. Agent.goal is ?[]const u8 (src/agent.zig:138), and the whole /goal surface in src/commands_session.zig tryHandle (L123-138) only knows three moves: bare /goal shows it, /goal <text> sets it, /goal clear//goal off nulls it.
The concrete thing that breaks: there is no way to temporarily suspend a goal without destroying it. The per-turn steering note fires unconditionally the moment the objective is non-null. goalSteeringNote returns "" only when goal == null (src/repl_glue.zig:98, const g = goal orelse return ""), and mainloop appends it every turn whenever ctx.root.goal is set (src/mainloop.zig:434-438). The REPL path likewise folds the objective straight into the system prompt whenever params.goal.len > 0 (src/repl_glue.zig:182-185). So the only lever a user has to stop goal steering for a quick side task is /goal clear, which deletes the objective text (src/commands_session.zig:128). To come back they must retype the whole objective from memory.
There is also nothing to represent "this goal exists but is on hold." Persistence stores only the string: saveSession writes "goal" as a scalar (src/session.zig:231-232) and loadSession reads it back as a bare string or null (src/session.zig:265, :293). Even if we wanted a pause, there is no status to persist, so resume could never bring a goal back in a suspended state.
How codex handles this
codex does not model a goal as a string. ThreadGoal is a record { objective, status, token_budget, tokens_used, time_used_seconds }, and status is the ThreadGoalStatus enum { Active, Paused, Blocked, Complete, BudgetLimited, UsageLimited }. The GoalExtension (codex-rs/ext/goal, behind Feature::Goals) exposes /goal <objective>, /goal clear, /goal edit, /goal pause, and /goal resume.
Two properties are the ones worth copying here:
Status is durable controller state, not a prompt. Goal rows live in a dedicated store (goals_1.sqlite, table thread_goals, columns thread_id, goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms) and survive across separate CLI processes. On resume, GoalExtension.on_thread_resume -> runtime.restore_after_resume() -> get_thread_goal re-marks an Active goal active in accounting, but a Paused goal stays Paused (test thread_resume_keeps_paused_goal_paused).
Steering is gated on that status.on_turn_start reads the current ThreadGoal and injects steering items only for an appropriate status (e.g. continuation_steering_item is authorized by an Active goal in the DB, not by a prompt string that merely says "keep going"). A paused goal injects nothing.
Proposed codegraff shape
Promote the bare string into a small structured record, persisted in the existing session JSON (codegraff has no separate goal DB, and this issue does not add one; the session file is the store, mirroring codex's thread_goals columns as JSON fields).
src/agent.zig (replacing the field at src/agent.zig:138):
(Subset of codex's enum: Active/Paused/Blocked/Complete. BudgetLimited/UsageLimited are deliberately left out because there is no budget in this issue.)
src/commands_session.zig tryHandle (extend the L123-138 block):
/goal <text> sets objective, status = .active, stamps created_ms/updated_ms, saves. The mainloop first-turn behavior (src/mainloop.zig:136-140) is preserved: setting an active goal still runs it as the first turn.
/goal pause sets status = .paused, updates updated_ms, saves, prints confirmation. Objective is kept.
/goal resume sets status = .active, updates updated_ms, saves, prints confirmation. Re-arms steering for subsequent turns; it does not force an autonomous turn (see non-goals).
/goal status prints objective + status + created/updated. Bare /goal also shows status.
/goal clear//goal off still nulls the whole record.
Gate the steering on status == .active:
src/mainloop.zig:434-438: pass the objective to goalSteeringNote only when the goal is present and active; a paused goal yields no note. (Either add a status check at the call site or thread the status into goalSteeringNote.)
src/repl_glue.zig:182-185: fold the objective into the system prompt only for an active goal.
Persistence, src/session.zig:
saveSession (src/session.zig:231-232): write "goal" as a nested object { objective, status, created_ms, updated_ms } instead of a scalar.
loadSession (src/session.zig:265, :293): accept both the new object form and the legacy bare-string form (a plain string -> { objective: s, status: .active, timestamps: now }), so old sessions keep resuming.
hasMeaningfulState (src/session.zig:199-200): still treat any non-null goal as meaningful; a paused goal is still worth persisting.
Invariant to mirror: a session saved with a paused goal reloads paused and does not steer (the codegraff analog of thread_resume_keeps_paused_goal_paused).
Everything stays turn-driven: no daemon, no background loop. Status is read at turn assembly time exactly where goalSteeringNote is already called.
Acceptance criteria
Agent.goal is a structured record (objective + GoalStatus enum {active,paused,blocked,complete} + created_ms + updated_ms), replacing the bare ?[]const u8 at src/agent.zig:138, and all existing call sites compile against it.
/goal pause sets status to paused, updates updated_ms, persists, and prints a confirmation; the objective is retained (not cleared).
/goal resume sets status to active, updates updated_ms, persists, and prints a confirmation.
/goal status (and bare /goal) prints objective, current status, and created/updated timestamps.
Per-turn steering is suppressed for a non-active goal: with a paused goal, goalSteeringNote at src/mainloop.zig:434 produces no note and replTurnCb at src/repl_glue.zig:182 does not fold the objective into the system prompt.
/goal <text> still sets an active goal and runs it as the first turn (src/mainloop.zig:136-140 behavior preserved; interactive single-turn /goal remains the default).
saveSession writes the structured goal object; loadSession accepts both the new object form and the legacy bare-string form (bare string loads as active).
Unit test: a session persisted with a paused goal reloads with status paused and yields no steering note (codegraff analog of codex thread_resume_keeps_paused_goal_paused).
Unit test: goalSteeringNote (or the gate at its call site) returns empty for a paused goal and non-empty for an active goal (extends the existing test at src/repl_glue.zig:149).
No separate goal database. The session JSON stays the store; we mirror codex's thread_goals columns as JSON fields, we do not add goals_1.sqlite.
No /goal edit in this PR (re-issuing /goal <text> already replaces the objective). An objective-preserving edit that keeps status can be a follow-up.
No scheduler / run-at-future-time. codex has no deferred "run again at T" mechanism and neither do we; /goal resume re-arms steering, it does not auto-launch a turn. A continuation-turn-on-resume (codex continuation_steering_item) is an explicit follow-up, not part of this PR.
Problem
Today a standing goal is a single bare string.
Agent.goalis?[]const u8(src/agent.zig:138), and the whole/goalsurface insrc/commands_session.zigtryHandle (L123-138) only knows three moves: bare/goalshows it,/goal <text>sets it,/goal clear//goal offnulls it.The concrete thing that breaks: there is no way to temporarily suspend a goal without destroying it. The per-turn steering note fires unconditionally the moment the objective is non-null.
goalSteeringNotereturns""only whengoal == null(src/repl_glue.zig:98,const g = goal orelse return ""), and mainloop appends it every turn wheneverctx.root.goalis set (src/mainloop.zig:434-438). The REPL path likewise folds the objective straight into the system prompt wheneverparams.goal.len > 0(src/repl_glue.zig:182-185). So the only lever a user has to stop goal steering for a quick side task is/goal clear, which deletes the objective text (src/commands_session.zig:128). To come back they must retype the whole objective from memory.There is also nothing to represent "this goal exists but is on hold." Persistence stores only the string:
saveSessionwrites"goal"as a scalar (src/session.zig:231-232) andloadSessionreads it back as a bare string or null (src/session.zig:265, :293). Even if we wanted a pause, there is no status to persist, so resume could never bring a goal back in a suspended state.How codex handles this
codex does not model a goal as a string.
ThreadGoalis a record{ objective, status, token_budget, tokens_used, time_used_seconds }, andstatusis theThreadGoalStatusenum{ Active, Paused, Blocked, Complete, BudgetLimited, UsageLimited }. TheGoalExtension(codex-rs/ext/goal, behindFeature::Goals) exposes/goal <objective>,/goal clear,/goal edit,/goal pause, and/goal resume.Two properties are the ones worth copying here:
Status is durable controller state, not a prompt. Goal rows live in a dedicated store (
goals_1.sqlite, tablethread_goals, columnsthread_id, goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms) and survive across separate CLI processes. On resume,GoalExtension.on_thread_resume->runtime.restore_after_resume()->get_thread_goalre-marks anActivegoal active in accounting, but aPausedgoal stays Paused (testthread_resume_keeps_paused_goal_paused).Steering is gated on that status.
on_turn_startreads the currentThreadGoaland injects steering items only for an appropriate status (e.g.continuation_steering_itemis authorized by anActivegoal in the DB, not by a prompt string that merely says "keep going"). A paused goal injects nothing.Proposed codegraff shape
Promote the bare string into a small structured record, persisted in the existing session JSON (codegraff has no separate goal DB, and this issue does not add one; the session file is the store, mirroring codex's
thread_goalscolumns as JSON fields).src/agent.zig(replacing the field at src/agent.zig:138):(Subset of codex's enum:
Active/Paused/Blocked/Complete.BudgetLimited/UsageLimitedare deliberately left out because there is no budget in this issue.)src/commands_session.zigtryHandle (extend the L123-138 block):/goal <text>setsobjective,status = .active, stampscreated_ms/updated_ms, saves. The mainloop first-turn behavior (src/mainloop.zig:136-140) is preserved: setting an active goal still runs it as the first turn./goal pausesetsstatus = .paused, updatesupdated_ms, saves, prints confirmation. Objective is kept./goal resumesetsstatus = .active, updatesupdated_ms, saves, prints confirmation. Re-arms steering for subsequent turns; it does not force an autonomous turn (see non-goals)./goal statusprints objective + status + created/updated. Bare/goalalso shows status./goal clear//goal offstill nulls the whole record.Gate the steering on
status == .active:goalSteeringNoteonly when the goal is present and active; a paused goal yields no note. (Either add astatuscheck at the call site or thread the status intogoalSteeringNote.)Persistence,
src/session.zig:saveSession(src/session.zig:231-232): write"goal"as a nested object{ objective, status, created_ms, updated_ms }instead of a scalar.loadSession(src/session.zig:265, :293): accept both the new object form and the legacy bare-string form (a plain string ->{ objective: s, status: .active, timestamps: now }), so old sessions keep resuming.hasMeaningfulState(src/session.zig:199-200): still treat any non-null goal as meaningful; a paused goal is still worth persisting.thread_resume_keeps_paused_goal_paused).Everything stays turn-driven: no daemon, no background loop. Status is read at turn assembly time exactly where
goalSteeringNoteis already called.Acceptance criteria
Agent.goalis a structured record (objective+GoalStatusenum{active,paused,blocked,complete}+created_ms+updated_ms), replacing the bare?[]const u8at src/agent.zig:138, and all existing call sites compile against it./goal pausesets status to paused, updatesupdated_ms, persists, and prints a confirmation; the objective is retained (not cleared)./goal resumesets status to active, updatesupdated_ms, persists, and prints a confirmation./goal status(and bare/goal) prints objective, current status, and created/updated timestamps.goalSteeringNoteat src/mainloop.zig:434 produces no note andreplTurnCbat src/repl_glue.zig:182 does not fold the objective into the system prompt./goal <text>still sets an active goal and runs it as the first turn (src/mainloop.zig:136-140 behavior preserved; interactive single-turn/goalremains the default).saveSessionwrites the structured goal object;loadSessionaccepts both the new object form and the legacy bare-string form (bare string loads as active).thread_resume_keeps_paused_goal_paused).goalSteeringNote(or the gate at its call site) returns empty for a paused goal and non-empty for an active goal (extends the existing test at src/repl_glue.zig:149)./clearand/newstill drop the whole goal viaresetConversationSteering(src/commands_session.zig:59-62); the existing ultracode codeword is scanned on the assembled message (goal note included) and goal/ultracode steering survives /clear — banner fires on prompts that never typed it #178 test still passes./goal pause//goal resume//goal statuswith no goal set print a friendly "no active goal" message rather than erroring.Scope & non-goals
token_budget/tokens_used/time_used_seconds, noBudgetLimited/UsageLimitedstatuses. That is the separate budget issue (concrete replacement for Governed runs (beyond codex): aggregate attempts, time, tokens, spend, and descendant tool budgets #217).completehere is a status a human/model can set; nothing auto-verifies it.thread_goalscolumns as JSON fields, we do not addgoals_1.sqlite./goal editin this PR (re-issuing/goal <text>already replaces the objective). An objective-preserving edit that keeps status can be a follow-up./goal resumere-arms steering, it does not auto-launch a turn. A continuation-turn-on-resume (codexcontinuation_steering_item) is an explicit follow-up, not part of this PR.Relationship to existing issues
statusenum that survives resume. It does not close Governed runs: durable attempt ledger, retry policy, and named terminal states #219 — there is no per-attempt ledger here; a full attempt ledger with terminal states can build on thisGoalrecord.--max-tool-calls+ dup suppression), Worktree isolation for parallel agents/sessions (no file collisions) #114 (-w/--worktreeisolation), codex/gpt-5.x: a single-turn tool-output burst overflows the context window before auto-compaction fires (no pre-send local-estimate gate) #193 (pre-send context estimation + compaction), or Define trace/trajectory file ownership and preserve JSONL integrity across concurrent processes #185 (trace integrity). It also does not touch the SDK/harness-levelScheduleWakeupscheduling that already exists; this is purely in-session goal status.