[codex] integrate goal command workflow (#1025)

* feat: integrate goal command workflow

* fix: keep goal done visible

* fix: add goal done slash command

* fix: promote queued message on run start
This commit is contained in:
ekko
2026-05-25 19:26:23 +08:00
committed by GitHub
parent 0eab6a1125
commit badb17cf8e
30 changed files with 1535 additions and 85 deletions
@@ -110,6 +110,7 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
command: string
handled: boolean
type?: string
action?: string
message?: string
output?: string
notice?: string
@@ -120,6 +121,30 @@ export interface AgentBridgeCommandResult extends AgentBridgeResponse {
retry?: boolean
retry_input?: AgentBridgeMessage
title?: string
kickoff_prompt?: string
clear_goal_continuations?: boolean
max_turns?: number
}
export interface AgentBridgeGoalEvaluation extends AgentBridgeResponse {
session_id: string
handled: boolean
active?: boolean
status?: string | null
should_continue?: boolean
continuation_prompt?: string | null
verdict?: string
reason?: string
message?: string
}
export interface AgentBridgeGoalPause extends AgentBridgeResponse {
session_id: string
handled: boolean
active?: boolean
status?: string | null
reason?: string
message?: string
}
export class AgentBridgeError extends Error {
@@ -419,6 +444,15 @@ export class AgentBridgeClient {
})
}
goalEvaluate(sessionId: string, finalResponse: string, profile?: string): Promise<AgentBridgeGoalEvaluation> {
return this.request<AgentBridgeGoalEvaluation>({
action: 'goal_evaluate',
session_id: sessionId,
final_response: finalResponse,
...(profile ? { profile } : {}),
})
}
getOutput(runId: string, cursor = 0, eventCursor = 0, options: AgentBridgeRequestOptions = {}): Promise<AgentBridgeOutput> {
return this.request<AgentBridgeOutput>({
action: 'get_output',
@@ -474,6 +508,15 @@ export class AgentBridgeClient {
})
}
goalPause(sessionId: string, reason: string, profile?: string): Promise<AgentBridgeGoalPause> {
return this.request<AgentBridgeGoalPause>({
action: 'goal_pause',
session_id: sessionId,
reason,
...(profile ? { profile } : {}),
})
}
steer(sessionId: string, text: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'steer',
@@ -518,6 +561,14 @@ export class AgentBridgeClient {
})
}
status(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'status',
session_id: sessionId,
...(profile ? { profile } : {}),
})
}
destroy(sessionId: string, profile?: string): Promise<AgentBridgeResponse> {
return this.request({
action: 'destroy',
@@ -1482,6 +1482,11 @@ class AgentPool:
arg = parts[1] if len(parts) > 1 else ""
with _profile_env(profile):
if name == "goal":
return self._dispatch_goal_command(session_id, arg)
if name == "subgoal":
return self._dispatch_subgoal_command(session_id, arg)
try:
try:
from agent.skill_bundles import (
@@ -1544,6 +1549,222 @@ class AgentPool:
"message": f"not a supported bridge command: /{name}",
}
def _goal_max_turns_from_config(self) -> int:
try:
from hermes_cli.config import load_config
goals_cfg = (load_config() or {}).get("goals") or {}
return int(goals_cfg.get("max_turns", 20) or 20)
except Exception:
return 20
def _goal_manager(self, session_id: str):
from hermes_cli.goals import GoalManager
return GoalManager(
session_id=session_id,
default_max_turns=self._goal_max_turns_from_config(),
)
def _dispatch_goal_command(self, session_id: str, arg: str) -> dict[str, Any]:
mgr = self._goal_manager(session_id)
clean_arg = str(arg or "").strip()
lower = clean_arg.lower()
if not clean_arg or lower == "status":
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "goal_status",
"message": mgr.status_line(),
}
if lower == "pause":
state = mgr.pause(reason="user-paused")
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "pause",
"message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.",
"clear_goal_continuations": True,
}
if lower == "resume":
state = mgr.resume()
prompt = mgr.next_continuation_prompt() if state else None
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "resume",
"message": f"▶ Goal resumed: {state.goal}" if state else "No goal to resume.",
"kickoff_prompt": prompt,
"max_turns": state.max_turns if state else None,
}
if lower in {"clear", "stop", "done"}:
had = mgr.has_goal()
mgr.clear()
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "clear",
"message": "✓ Goal cleared." if had else "No active goal.",
"clear_goal_continuations": True,
}
try:
state = mgr.set(clean_arg)
except ValueError as exc:
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "set",
"message": f"Invalid goal: {exc}",
}
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "set",
"message": (
f"⊙ Goal set ({state.max_turns}-turn budget): {state.goal}\n"
"After each turn, a judge model will check if the goal is done. "
"Hermes keeps working until it is, you pause/clear it, or the budget is exhausted."
),
"kickoff_prompt": state.goal,
"max_turns": state.max_turns,
}
def _dispatch_subgoal_command(self, session_id: str, arg: str) -> dict[str, Any]:
mgr = self._goal_manager(session_id)
clean_arg = str(arg or "").strip()
if not mgr.has_goal():
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal",
"message": "No active goal. Set one with /goal <text>.",
}
if not clean_arg:
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_status",
"message": f"{mgr.status_line()}\n{mgr.render_subgoals()}",
}
tokens = clean_arg.split(None, 1)
verb = tokens[0].lower()
rest = tokens[1].strip() if len(tokens) > 1 else ""
if verb == "remove":
if not rest:
message = "Usage: /subgoal remove <n>"
else:
try:
idx = int(rest.split()[0])
removed = mgr.remove_subgoal(idx)
message = f"✓ Removed subgoal {idx}: {removed}"
except ValueError:
message = "/subgoal remove: <n> must be an integer (1-based index)."
except (IndexError, RuntimeError) as exc:
message = f"/subgoal remove: {exc}"
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_remove",
"message": message,
}
if verb == "clear":
try:
prev = mgr.clear_subgoals()
message = f"✓ Cleared {prev} subgoal{'s' if prev != 1 else ''}." if prev else "No subgoals to clear."
except RuntimeError as exc:
message = f"/subgoal clear: {exc}"
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_clear",
"message": message,
}
try:
text = mgr.add_subgoal(clean_arg)
idx = len(mgr.state.subgoals) if mgr.state else 0
message = f"✓ Added subgoal {idx}: {text}"
except (ValueError, RuntimeError) as exc:
message = f"/subgoal: {exc}"
return {
"session_id": session_id,
"command": "subgoal",
"handled": True,
"type": "goal",
"action": "subgoal_add",
"message": message,
}
def evaluate_goal(self, session_id: str, final_response: str, profile: str | None = None) -> dict[str, Any]:
with _profile_env(profile):
mgr = self._goal_manager(session_id)
if not mgr.is_active():
return {
"session_id": session_id,
"handled": True,
"active": False,
"should_continue": False,
"continuation_prompt": None,
"message": "",
"verdict": "inactive",
}
decision = mgr.evaluate_after_turn(str(final_response or ""), user_initiated=True)
return {
"session_id": session_id,
"handled": True,
"active": mgr.is_active(),
**decision,
}
def pause_goal(self, session_id: str, reason: str, profile: str | None = None) -> dict[str, Any]:
with _profile_env(profile):
clean_reason = str(reason or "").strip() or "paused"
mgr = self._goal_manager(session_id)
state = mgr.pause(reason=clean_reason)
return {
"session_id": session_id,
"command": "goal",
"handled": True,
"type": "goal",
"action": "pause",
"active": mgr.is_active(),
"status": state.status if state else None,
"reason": clean_reason,
"message": f"⏸ Goal paused: {state.goal}" if state else "No goal set.",
"clear_goal_continuations": True,
}
def get_result(self, run_id: str) -> dict[str, Any]:
with self._lock:
record = self._runs.get(run_id)
@@ -1785,6 +2006,29 @@ class BridgeServer:
req.get("profile"),
)
if action == "goal_evaluate":
session_id = str(req.get("session_id") or "").strip()
if not session_id:
raise ValueError("session_id is required")
return self.pool.evaluate_goal(
session_id,
str(req.get("final_response") or ""),
req.get("profile"),
)
if action == "goal_pause":
session_id = str(req.get("session_id") or "").strip()
if not session_id:
raise ValueError("session_id is required")
return self.pool.pause_goal(
session_id,
str(req.get("reason") or ""),
req.get("profile"),
)
if action == "status":
return self.pool.status(str(req.get("session_id") or ""))
if action == "destroy":
return self.pool.destroy(str(req.get("session_id") or ""))
@@ -2359,7 +2603,7 @@ class BridgeBroker:
profile = self._profile_for_run(str(req.get("run_id") or ""))
return self._forward(profile, req)
if action in {"interrupt", "steer", "command", "get_history", "destroy"}:
if action in {"interrupt", "steer", "command", "goal_evaluate", "goal_pause", "status", "get_history", "destroy"}:
session_id = str(req.get("session_id") or "")
profile = self._profile_for_session(session_id, req.get("profile"))
resp = self._forward(profile, req)
@@ -64,6 +64,12 @@ export async function handleAbort(
} catch (err) {
logger.warn(err, '[chat-run-socket][abort] failed to interrupt CLI bridge for session %s', sessionId)
}
try {
await bridge.goalPause?.(sessionId, 'user-interrupted', state.profile)
state.queue = state.queue.filter(item => !item.goalContinuation)
} catch (err) {
logger.debug(err, '[chat-run-socket][abort] goal pause-on-interrupt skipped for session %s', sessionId)
}
} else if (state.abortController) {
state.abortController.abort()
}
@@ -27,7 +27,7 @@ import {
recordBridgeToolCompleted,
} from './bridge-message'
import { summarizeToolArguments } from './response-utils'
import type { ContentBlock, SessionState } from './types'
import type { ContentBlock, QueuedRun, SessionState } from './types'
import type { ChatMessage } from '../../../lib/context-compressor'
import { resolveBridgeRunModelConfig, type RunModelGroup } from './model-config'
import { filterBridgeToolCallMarkupDelta, flushPendingToolCallMarkup } from './bridge-delta'
@@ -349,6 +349,7 @@ export async function handleBridgeRun(
dequeueNextQueuedRun,
fullInstructions,
{ model: resolvedModel, provider: resolvedProvider },
data.model_groups,
)
if (chunk.done) break
}
@@ -485,6 +486,7 @@ async function applyBridgeChunkAsync(
dequeueNextQueuedRun: (socket: Socket, sessionId: string, fallbackProfile?: string) => void,
instructions: string,
modelContext: { model?: string | null; provider?: string | null },
modelGroups?: RunModelGroup[],
): Promise<void> {
if (state.activeRunMarker !== runMarker) {
bridgeLogger.info({
@@ -737,11 +739,13 @@ async function applyBridgeChunkAsync(
replaceState(sessionMap, sessionId, 'compression.completed', payload)
emit('compression.completed', payload)
} else if (evType === 'status') {
emit('agent.event', {
const payload = {
...ev,
event: 'agent.event',
run_id: chunk.run_id,
...ev,
})
}
replaceState(sessionMap, sessionId, 'agent.event', payload)
emit('agent.event', payload)
}
}
@@ -812,19 +816,15 @@ async function applyBridgeChunkAsync(
outputTokens: usage.outputTokens,
profile: state.profile,
})
const nextQueuedRun = state.queue.length > 0 ? state.queue[0] : undefined
state.isWorking = Boolean(nextQueuedRun)
const terminalError = bridgeTerminalError(chunk)
const hadQueuedRunBeforeGoalEvaluation = state.queue.length > 0
state.isWorking = hadQueuedRunBeforeGoalEvaluation
state.isAborting = false
if (nextQueuedRun) {
state.profile = nextQueuedRun.profile || profile
state.source = nextQueuedRun.source
} else {
state.profile = undefined
}
state.profile = hadQueuedRunBeforeGoalEvaluation ? (state.queue[0]?.profile || profile) : undefined
state.source = hadQueuedRunBeforeGoalEvaluation ? state.queue[0]?.source : state.source
state.runId = undefined
state.activeRunMarker = undefined
state.events = []
const terminalError = bridgeTerminalError(chunk)
const eventName = terminalError ? 'run.failed' : 'run.completed'
const payload = {
event: eventName,
@@ -838,8 +838,157 @@ async function applyBridgeChunkAsync(
queue_remaining: state.queue.length,
}
emit(eventName, payload)
if (state.queue.length > 0) {
if (!terminalError) {
await maybeEnqueueGoalContinuation({
nsp,
socket,
sessionId,
state,
bridge,
profile,
modelContext,
modelGroups,
instructions,
finalResponse: bridgeFinalResponse(chunk, state),
})
}
if (state.queue.length > 0 && !state.activeRunMarker) {
const nextQueuedRun = state.queue[0]
state.isWorking = true
state.profile = nextQueuedRun.profile || profile
state.source = nextQueuedRun.source
dequeueNextQueuedRun(socket, sessionId)
} else if (!state.activeRunMarker) {
state.isWorking = false
state.profile = undefined
}
}
function bridgeFinalResponse(chunk: AgentBridgeOutput, state: SessionState): string {
const result = chunk.result && typeof chunk.result === 'object' && !Array.isArray(chunk.result)
? chunk.result as Record<string, unknown>
: null
const finalResponse = result && typeof result.final_response === 'string'
? result.final_response
: ''
return finalResponse || chunk.output || state.bridgeOutput || ''
}
function hasRealQueuedRun(state: SessionState): boolean {
return state.queue.some(item => !item.goalContinuation)
}
async function maybeEnqueueGoalContinuation(args: {
nsp: ReturnType<Server['of']>
socket: Socket
sessionId: string
state: SessionState
bridge: AgentBridgeClient
profile: string
modelContext: { model?: string | null; provider?: string | null }
modelGroups?: RunModelGroup[]
instructions: string
finalResponse: string
}) {
const finalResponse = args.finalResponse || ''
if (!finalResponse.trim()) return
if (hasRealQueuedRun(args.state)) return
let decision
try {
decision = await args.bridge.goalEvaluate(args.sessionId, finalResponse, args.profile)
} catch (err) {
logger.warn(err, '[chat-run-socket] /goal evaluation failed for session %s', args.sessionId)
return
}
if (isGoalJudgeUnavailable(decision.reason)) {
emitGoalStatus(
args.nsp,
args.socket,
args.sessionId,
args.state,
'judge_unavailable',
'Goal judge is not configured; automatic goal continuation was skipped. The goal remains active, but Hermes cannot mark it done automatically.',
)
return
}
const message = typeof decision.message === 'string' ? decision.message.trim() : ''
if (message) emitGoalStatus(args.nsp, args.socket, args.sessionId, args.state, decision.verdict || 'goal', message)
if (!decision.should_continue) return
if (hasRealQueuedRun(args.state)) return
const prompt = typeof decision.continuation_prompt === 'string'
? decision.continuation_prompt.trim()
: ''
if (!prompt) return
const next: QueuedRun = {
queue_id: `goal_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
input: prompt,
displayInput: null,
storageMessage: prompt,
model: args.modelContext.model || undefined,
provider: args.modelContext.provider || undefined,
model_groups: args.modelGroups,
instructions: undefined,
profile: args.profile,
source: 'cli',
goalContinuation: true,
}
args.state.queue.push(next)
}
function isGoalJudgeUnavailable(reason?: string | null): boolean {
const value = String(reason || '').toLowerCase()
return value.includes('no auxiliary client configured') || value.includes('auxiliary client unavailable')
}
function emitGoalStatus(
nsp: ReturnType<Server['of']>,
socket: Socket,
sessionId: string,
state: SessionState,
action: string,
message: string,
) {
const now = Math.floor(Date.now() / 1000)
const id = addMessage({
session_id: sessionId,
role: 'command',
content: message,
timestamp: now,
})
state.messages.push({
id: id || `goal_${now}_${state.messages.length}`,
session_id: sessionId,
role: 'command',
content: message,
timestamp: now,
})
nsp.to(`session:${sessionId}`).emit('session.command', {
event: 'session.command',
session_id: sessionId,
command: 'goal',
ok: true,
action,
message,
terminal: false,
})
if (!nsp.adapter.rooms.get(`session:${sessionId}`)?.size && socket.connected) {
socket.emit('session.command', {
event: 'session.command',
session_id: sessionId,
command: 'goal',
ok: true,
action,
message,
terminal: false,
})
}
}
@@ -135,6 +135,8 @@ export class ChatRunSocket {
bridge: this.bridge,
profile: runProfile,
model: data.model,
provider: data.provider,
model_groups: data.model_groups,
instructions: data.instructions,
queueId: data.queue_id,
runQueuedItem: this.runQueuedItem.bind(this),
@@ -393,12 +395,10 @@ export class ChatRunSocket {
}
private serializeQueuedMessages(queue: QueuedRun[]) {
return queue.map(item => ({
return queue.filter(item => item.displayInput !== null).map(item => ({
id: item.queue_id,
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
content: item.displayInput === null
? (item.storageMessage || '')
: contentBlocksToString(item.displayInput ?? item.input),
content: contentBlocksToString(item.displayInput ?? item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
}))
@@ -40,7 +40,7 @@ export async function resolveBridgeRunModelConfig(options: {
const candidateProvider = sessionProvider || requestedProvider
const hasGroups = Array.isArray(options.modelGroups) && options.modelGroups.length > 0
const candidateAvailable = hasGroups && hasModelInGroups(options.modelGroups, candidateProvider, candidateModel)
const shouldUseDefault = !candidateModel || !candidateProvider || !candidateAvailable
const shouldUseDefault = !candidateModel || !candidateProvider || (hasGroups && !candidateAvailable)
return shouldUseDefault
? resolveDefaultModelConfig(options.profile)
: { model: candidateModel, provider: candidateProvider }
@@ -15,6 +15,8 @@ type CommandName =
| 'abort'
| 'queue'
| 'plan'
| 'goal'
| 'subgoal'
| 'clear'
| 'title'
| 'compress'
@@ -34,6 +36,8 @@ interface SessionCommandContext {
bridge: AgentBridgeClient
profile: string
model?: string
provider?: string
model_groups?: Array<{ provider: string; models: string[] }>
instructions?: string
queueId?: string
runQueuedItem: (socket: Socket, sessionId: string, next: QueuedRun, fallbackProfile?: string) => void
@@ -45,6 +49,8 @@ const COMMAND_ALIASES: Record<string, CommandName> = {
abort: 'abort',
queue: 'queue',
plan: 'plan',
goal: 'goal',
subgoal: 'subgoal',
clear: 'clear',
title: 'title',
compress: 'compress',
@@ -120,24 +126,30 @@ export async function handleSessionCommand(
case 'status': {
const row = getSession(sessionId)
const bridgeStatus = await getBridgeSessionStatus(ctx, sessionId)
const bridgeRunning = bridgeStatus?.running === true
const isWorking = state.isWorking || bridgeRunning
const runId = state.runId || state.activeRunMarker || bridgeStatus?.currentRunId || null
emitCommand({
action: 'status',
terminal: !state.isWorking,
terminal: !isWorking,
message: [
`Status: ${state.isWorking ? 'running' : 'idle'}`,
`Status: ${isWorking ? 'running' : 'idle'}`,
`source: ${state.source || row?.source || 'cli'}`,
`profile: ${state.profile || ctx.profile || row?.profile || 'default'}`,
`model: ${ctx.model || row?.model || '-'}`,
`queue: ${state.queue.length}`,
`run: ${state.runId || state.activeRunMarker || '-'}`,
].join(', '),
isWorking: state.isWorking,
`run: ${runId || '-'}`,
bridgeStatus ? `bridge: ${bridgeRunning ? 'running' : 'idle'}` : null,
].filter(Boolean).join(', '),
isWorking,
isAborting: Boolean(state.isAborting),
queueLength: state.queue.length,
source: state.source || row?.source || 'cli',
profile: state.profile || ctx.profile || row?.profile || 'default',
model: ctx.model || row?.model || null,
runId: state.runId || state.activeRunMarker || null,
runId,
bridgeStatus,
})
return
}
@@ -161,6 +173,8 @@ export async function handleSessionCommand(
queue_id: queueId,
input: command.args,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
@@ -170,13 +184,7 @@ export async function handleSessionCommand(
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: state.queue.map(item => ({
id: item.queue_id,
role: 'user',
content: contentBlocksToString(item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
})),
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
emitCommand({
action: 'queue',
@@ -221,6 +229,8 @@ export async function handleSessionCommand(
displayRole: 'command',
storageMessage: displayCommand,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
@@ -233,15 +243,7 @@ export async function handleSessionCommand(
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: state.queue.map(item => ({
id: item.queue_id,
role: typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user',
content: item.displayInput === null
? (item.storageMessage || '')
: contentBlocksToString(item.displayInput ?? item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
})),
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
return
}
@@ -255,6 +257,88 @@ export async function handleSessionCommand(
return
}
case 'goal':
case 'subgoal': {
const isGoalSet = command.name === 'goal'
&& Boolean(command.args)
&& !['status', 'pause', 'resume', 'clear', 'stop', 'done'].includes(command.args.toLowerCase())
if (state.isWorking && isGoalSet) {
emitCommand({
ok: false,
action: 'goal',
terminal: false,
message: 'Agent is running. Use /goal status, /goal pause, or /goal clear mid-run, or /abort before setting a new goal.',
})
return
}
const bridgeCommand = `${command.name}${command.args ? ` ${command.args}` : ''}`
let result
try {
result = await ctx.bridge.command(sessionId, bridgeCommand, ctx.profile)
} catch (err) {
emitCommand({
ok: false,
action: command.name,
terminal: !state.isWorking,
message: `Goal command failed: ${err instanceof Error ? err.message : String(err)}`,
})
return
}
if (result.clear_goal_continuations) {
const removed = removeGoalContinuationRuns(state)
if (removed > 0) emitQueuedState(ctx, sessionId, state)
}
const kickoffPrompt = typeof result.kickoff_prompt === 'string' ? result.kickoff_prompt.trim() : ''
const bridgeStatus = result.action === 'goal_status' || result.action === 'status'
? await getBridgeSessionStatus(ctx, sessionId)
: null
const message = formatGoalStatusMessage(String(result.message || ''), bridgeStatus)
const resultAction = String(result.action || command.name)
const action = (command.name === 'goal' || command.name === 'subgoal') && resultAction === 'clear'
? `${command.name}_clear`
: resultAction
emitCommand({
action,
terminal: !state.isWorking && !kickoffPrompt,
started: Boolean(kickoffPrompt),
message,
type: result.type || 'goal',
maxTurns: result.max_turns,
bridgeStatus,
})
if (!kickoffPrompt) return
const next: QueuedRun = {
queue_id: ctx.queueId || `queue_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`,
input: kickoffPrompt,
displayInput: null,
storageMessage: kickoffPrompt,
model: ctx.model,
provider: ctx.provider,
model_groups: ctx.model_groups,
instructions: ctx.instructions,
profile: ctx.profile,
source: 'cli',
originSocketId: ctx.socket.id,
}
if (state.isWorking) {
state.queue.push(next)
emitQueuedState(ctx, sessionId, state)
return
}
ctx.runQueuedItem(ctx.socket, sessionId, next, ctx.profile)
return
}
case 'clear': {
if (command.args === '--history') {
if (state.isWorking) {
@@ -462,6 +546,79 @@ function clearTransientRunState(state: SessionState) {
state.isAborting = false
}
function removeGoalContinuationRuns(state: SessionState): number {
const before = state.queue.length
state.queue = state.queue.filter(item => !item.goalContinuation)
return before - state.queue.length
}
function emitQueuedState(ctx: SessionCommandContext, sessionId: string, state: SessionState) {
emitToSession(ctx.nsp, ctx.socket, sessionId, 'run.queued', {
event: 'run.queued',
session_id: sessionId,
queue_length: state.queue.length,
queued_messages: serializeVisibleQueuedMessages(state.queue),
})
}
function serializeVisibleQueuedMessages(queue: QueuedRun[]) {
return queue.filter(item => item.displayInput !== null).map(item => ({
id: item.queue_id,
role: item.displayRole || (typeof item.displayInput === 'string' && item.displayInput.trim().startsWith('/') ? 'command' : 'user'),
content: contentBlocksToString(item.displayInput ?? item.input),
timestamp: Math.floor(Date.now() / 1000),
queued: true,
}))
}
type BridgeSessionStatus = {
exists: boolean
running: boolean
currentRunId: string | null
messageCount: number
}
async function getBridgeSessionStatus(ctx: SessionCommandContext, sessionId: string): Promise<BridgeSessionStatus | null> {
try {
const raw = await ctx.bridge.status(sessionId, ctx.profile) as Record<string, unknown>
return {
exists: raw.exists === true,
running: raw.running === true,
currentRunId: typeof raw.current_run_id === 'string' && raw.current_run_id.trim()
? raw.current_run_id
: null,
messageCount: typeof raw.message_count === 'number' && Number.isFinite(raw.message_count)
? raw.message_count
: 0,
}
} catch (err) {
logger.debug({ err, sessionId }, '[chat-run-socket] bridge status lookup failed')
return null
}
}
function formatGoalStatusMessage(message: string, bridgeStatus: BridgeSessionStatus | null): string {
if (!bridgeStatus) return message
const lines = [message]
if (bridgeStatus.running) {
const progress = parseGoalTurnProgress(message)
lines.push(progress
? `Current turn: ${Math.min(progress.used + 1, progress.max)}/${progress.max} running (completed turns: ${progress.used}/${progress.max}; count updates after the judge).`
: 'Current turn: running (turn count updates after the judge).')
}
lines.push(`Run: ${bridgeStatus.running ? 'running' : 'idle'}${bridgeStatus.currentRunId ? ` (${bridgeStatus.currentRunId})` : ''}`)
return lines.filter(Boolean).join('\n')
}
function parseGoalTurnProgress(message: string): { used: number; max: number } | null {
const match = message.match(/\b(\d+)\s*\/\s*(\d+)\s+turns\b/i)
if (!match) return null
const used = Number(match[1])
const max = Number(match[2])
if (!Number.isFinite(used) || !Number.isFinite(max) || max <= 0) return null
return { used, max }
}
function ensureCommandSession(sessionId: string, ctx: SessionCommandContext) {
if (getSession(sessionId)) return
createSession({
@@ -38,6 +38,7 @@ export interface QueuedRun {
profile: string
source?: ChatRunSource
originSocketId?: string
goalContinuation?: boolean
}
export interface SessionState {