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
7 changes: 7 additions & 0 deletions apps/cli/src/agent/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,13 @@ arrive: context/message-flow.md "Upstream".

- `agent-client.ts` — the ACP connection: initialize/session lifecycle, client
capabilities (fs, elicitation), permission/fs request handling, update callbacks.
Goal session-info updates use provider-neutral `_meta.goal`; keep the
`_meta.codex.goal` reader only as a compatibility fallback for older Codex adapters.
A present neutral field wins, including `null`, so malformed new metadata is not
silently hidden by a legacy duplicate. Validate neutral snapshots against
`controlMethod: _session/goal`; normalize neutral `limited` to the legacy durable
`blocked` status so older readers can consume mixed-version history without
inventing a provider-specific limit reason.
Its built-in `lody` stdio MCP config is an explicit environment allowlist, not
ordinary child-process inheritance. Keep the public CLI deployment endpoints
(`LODY_AUTH_URL`, `LODY_AUTH_SITE_URL`, `LODY_SERVER_URL`) in that config so
Expand Down
69 changes: 56 additions & 13 deletions apps/cli/src/agent/agent-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,7 @@ export type AcpSessionStartTarget = {
resumeSessionId?: ACPSessionId;
};

const ThreadGoalStatusSchema = z.enum([
const LegacyCodexGoalStatusSchema = z.enum([
'active',
'paused',
'blocked',
Expand All @@ -339,10 +339,24 @@ const ThreadGoalStatusSchema = z.enum([
'complete',
'cleared',
]);
const CodexGoalSnapshotSchema = z.object({
const LegacyCodexGoalSnapshotSchema = z.object({
objective: z.string(),
status: ThreadGoalStatusSchema,
status: LegacyCodexGoalStatusSchema,
tokenBudget: z.number().nullable().optional(),
tokensUsed: z.number().optional(),
timeUsedSeconds: z.number().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
});
const NeutralGoalSnapshotSchema = z.object({
objective: z.string(),
status: z.enum(['active', 'paused', 'blocked', 'limited', 'complete']),
controlMethod: z.literal('_session/goal'),
tokenBudget: z.number().nullable().optional(),
tokensUsed: z.number().optional(),
timeUsedSeconds: z.number().optional(),
createdAt: z.number().optional(),
updatedAt: z.number().optional(),
});

const CodexRetryErrorSchema = z.object({
Expand Down Expand Up @@ -760,7 +774,7 @@ export class AgentClient implements acp.Client {

const notification = parseSessionNotification(params);

this.handleCodexGoalSessionInfoUpdate(notification);
this.handleGoalSessionInfoUpdate(notification);
this.handleCodexWarningSessionInfoUpdate(notification);
this.handleAgentSessionTitleUpdate(notification);

Expand All @@ -783,20 +797,40 @@ export class AgentClient implements acp.Client {
return;
}

private handleCodexGoalSessionInfoUpdate(notification: AcpSessionNotification): void {
if (!this.isCodexAgent() || notification.update.sessionUpdate !== 'session_info_update') {
private handleGoalSessionInfoUpdate(notification: AcpSessionNotification): void {
if (notification.update.sessionUpdate !== 'session_info_update') {
return;
}

const codexMeta = notification.update._meta?.codex;
if (typeof codexMeta !== 'object' || codexMeta === null || !('goal' in codexMeta)) {
const meta = notification.update._meta;
if (typeof meta !== 'object' || meta === null) {
return;
}

const parsed = z.object({ goal: CodexGoalSnapshotSchema.nullable() }).safeParse(codexMeta);
let goalContainer: unknown;
let source: 'ACP' | 'legacy Codex';
if ('goal' in meta) {
goalContainer = meta;
source = 'ACP';
} else {
const codexMeta = meta.codex;
if (
!this.isCodexAgent() ||
typeof codexMeta !== 'object' ||
codexMeta === null ||
!('goal' in codexMeta)
) {
return;
}
goalContainer = codexMeta;
source = 'legacy Codex';
}

const goalSchema = source === 'ACP' ? NeutralGoalSnapshotSchema : LegacyCodexGoalSnapshotSchema;
const parsed = z.object({ goal: goalSchema.nullable() }).safeParse(goalContainer);
if (!parsed.success) {
this.logger.debug(
`[${this.options.sessionId}] Dropping invalid Codex goal session info: ${parsed.error.message}`
`[${this.options.sessionId}] Dropping invalid ${source} goal session info: ${parsed.error.message}`
);
return;
}
Expand All @@ -807,12 +841,21 @@ export class AgentClient implements acp.Client {
return;
}

const goal = parsed.data.goal;
this.options.onThreadGoalUpdated?.({
type: 'goal',
threadId,
objective: sanitizeGoalObjective(parsed.data.goal.objective),
status: parsed.data.goal.status,
tokenBudget: parsed.data.goal.tokenBudget ?? null,
objective: sanitizeGoalObjective(goal.objective),
// The neutral extension collapses provider-specific limit reasons into
// `limited`. Normalize to the older generic blocked state at the durable
// boundary: mixed-version readers can consume it without falsely claiming
// that a usage or token budget was the specific limiting resource.
status: goal.status === 'limited' ? 'blocked' : goal.status,
tokenBudget: goal.tokenBudget ?? null,
...(goal.tokensUsed !== undefined ? { tokensUsed: goal.tokensUsed } : {}),
...(goal.timeUsedSeconds !== undefined ? { timeUsedSeconds: goal.timeUsedSeconds } : {}),
...(goal.createdAt !== undefined ? { createdAt: goal.createdAt } : {}),
...(goal.updatedAt !== undefined ? { updatedAt: goal.updatedAt } : {}),
});
}

Expand Down
24 changes: 12 additions & 12 deletions apps/cli/src/agent/managed-agent-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,43 +269,43 @@ const RUNTIMES: Record<ManagedRuntimeName, RuntimeDefinition> = {
platforms: {
'darwin-arm64': {
fileName: 'codex-package-aarch64-apple-darwin.tar.zst',
sha256: '24067128ccca24e4c582df7fabca06c107d9802e3ccc1280c6fa5dc3186f4a29',
size: 87286963,
sha256: '4b0a3c2967f6db11b67dac0a6f0630327077888157b2020b5b1159e8d4a5a01c',
size: 77491677,
compression: 'zstd',
cmd: 'bin/codex',
},
'darwin-x64': {
fileName: 'codex-package-x86_64-apple-darwin.tar.zst',
sha256: 'b15c6922599097c24e700563e59feb0b59cc84b60eadf036ea38d4118e900c9c',
size: 95382310,
sha256: '373c728c0912b7c65af742bb649f0ff056300f8e8a33a619be0bab62f2afc6ba',
size: 85008508,
compression: 'zstd',
cmd: 'bin/codex',
},
'linux-arm64': {
fileName: 'codex-package-aarch64-unknown-linux-musl.tar.zst',
sha256: '8f27d60343abd8a6016516974f8b55e512b1a3b614841596fd548745b9c4c3ed',
size: 90024268,
sha256: '692ff5c5d9eb86774c0448d930895e46659eee6a2c12f46b97399da458c1e8b7',
size: 80608473,
compression: 'zstd',
cmd: 'bin/codex',
},
'linux-x64': {
fileName: 'codex-package-x86_64-unknown-linux-musl.tar.zst',
sha256: '8ed235186b6a53e62d8228057fba5c5da4c63eeafff046fc1161b9c2298461f9',
size: 96762435,
sha256: '494368127e27ab625b2a5e759f83447e1d7d00b6ae49a5e2c17df5fd7c4aac1d',
size: 86804357,
compression: 'zstd',
cmd: 'bin/codex',
},
'win32-arm64': {
fileName: 'codex-package-aarch64-pc-windows-msvc.tar.zst',
sha256: 'ab915ae7861c86148078484df27c9a4900411ed25ad4435613f235c50acc4151',
size: 101491458,
sha256: '0c9d43e1ce013900850c82ab558cbafae9e8b05363689ab68cc0b4c87ac2b267',
size: 88080469,
compression: 'zstd',
cmd: 'bin/codex.exe',
},
'win32-x64': {
fileName: 'codex-package-x86_64-pc-windows-msvc.tar.zst',
sha256: '608c193cb0723af1759e44747229c3a68e38988042abd7cf0f6efb9de6ef6b92',
size: 108999653,
sha256: '5931e080440a0bff1413d55b62921e26238f5410d82d79e5327202bfef1833a4',
size: 95093149,
compression: 'zstd',
cmd: 'bin/codex.exe',
},
Expand Down
9 changes: 5 additions & 4 deletions apps/cli/src/lib/message-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@ import {
getServerNow,
CODE_COLLAB_V2_TEXT_LIMITS,
isActiveSessionStatus,
isSessionGoalWorking,
isSessionGoalActive,
resolveLatestSessionGoalFromHistory,
resolveProjectGitHubRepo,
type RepoId,
Expand Down Expand Up @@ -9855,15 +9855,16 @@ export class MessageHandler {
}

/**
* Active Codex goals run as background ACP turns. They may be idle from
* Lody's user-turn perspective, but killing the session kills goal progress.
* Persistent active goals may drive a later autonomous ACP cycle even while
* no prompt is running. They are not a live-presence signal, but evicting the
* ACP process would discard that resumable session state.
*/
async hasActiveGoal(sessionId: SessionId): Promise<boolean> {
const sessionDoc = await this.workspaceDocument.getOrCreateSessionDoc(sessionId);
const meta = await sessionDoc.getMetaState();
const legacyMeta = meta as SessionLegacyMetaFields | null | undefined;
const historyGoal = resolveLatestSessionGoalFromHistory(await sessionDoc.getHistory());
return isSessionGoalWorking(historyGoal ?? legacyMeta?.latestGoal);
return isSessionGoalActive(historyGoal ?? legacyMeta?.latestGoal);
}

/**
Expand Down
4 changes: 4 additions & 0 deletions apps/cli/src/session/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,10 @@ delegation proofs or a shared-machine gate without a new product and security de
- `session-dispatch-logic.ts` — pure decision functions for the watcher (testable).
- `session-execution-service.ts` — runs one turn end-to-end: ACP prompt, turn ids,
lifecycle/error handling, GitHub/local project setup, and post-turn diffStats.
Goal lifecycle is independent from prompt lifecycle: an `active` session goal does
not suppress turn completion or completion notification once the current prompt is
quiescent. Use live execution/presence for current-work signals; goal activity may
still protect history rewrites or an in-memory runtime that can resume autonomously.
It is the per-session execution mutex: never mint a second visible turn while a
`TurnRuntimeState` is registered. User-dispatch turns derive assistant entry ids
from `userTurnId` (`assistant:<userTurnId>`), so a retried/recovered dispatch reuses
Expand Down
8 changes: 4 additions & 4 deletions apps/cli/src/session/session-edit-and-resend-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import {
buildPendingUserHistoryEntry,
getServerNow,
getSessionRoomId,
isSessionGoalWorking,
isSessionGoalActive,
normalizeSessionInputBlocks,
resolveLatestSessionGoalFromHistory,
sessionEditAndResendFailure,
Expand Down Expand Up @@ -162,7 +162,7 @@ export class SessionEditAndResendService {
const legacyMeta = meta as SessionMeta & SessionLegacyMetaFields;
const latestGoal = resolveLatestSessionGoalFromHistory(history) ?? legacyMeta.latestGoal;
const execution = this.deps.executionService.getExecutionSnapshot(spec.sessionId);
if (meta.autoReview || execution.hasActiveAutomation || isSessionGoalWorking(latestGoal)) {
if (meta.autoReview || execution.hasActiveAutomation || isSessionGoalActive(latestGoal)) {
return sessionEditAndResendFailure(
spec,
'ACTIVE_AUTOMATION',
Expand Down Expand Up @@ -254,7 +254,7 @@ export class SessionEditAndResendService {
if (
freshMeta.autoReview ||
freshExecution.hasActiveAutomation ||
isSessionGoalWorking(freshGoal)
isSessionGoalActive(freshGoal)
) {
await this.closePrepared(runtime, preparedSessionId);
preparedSessionId = null;
Expand Down Expand Up @@ -365,7 +365,7 @@ export class SessionEditAndResendService {
const currentGoal =
resolveLatestSessionGoalFromHistory(currentHistory) ??
(commitMeta as SessionMeta & SessionLegacyMetaFields).latestGoal;
if (isSessionGoalWorking(currentGoal)) {
if (isSessionGoalActive(currentGoal)) {
throw new Error(
'[ACTIVE_AUTOMATION] A session goal started before history replacement.'
);
Expand Down
41 changes: 0 additions & 41 deletions apps/cli/src/session/session-execution-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,17 +29,13 @@ import {
type ProjectRef,
resolveBaseBranchPreference,
resolveProjectGitHubRepo,
resolveLatestSessionGoalFromHistory,
isSessionGoalWorking,
getSessionRoomId,
getServerNow,
SessionCreateRequestValidated,
SessionHistoryInput,
type SessionId,
type SessionInputBlock,
type SessionTurnInputConfig,
type SessionGoalMessage,
type SessionLegacyMetaFields,
type SessionMeta,
SessionStatusFactory,
SessionChatRequestValidated,
Expand Down Expand Up @@ -1487,30 +1483,6 @@ export class SessionExecutionService {
}
}

private async resolveLatestGoalForCompletionNotification(
sessionId: SessionId,
sessionDoc: SessionDocument
): Promise<SessionGoalMessage | null> {
let metaGoal: SessionGoalMessage | null = null;
try {
const meta = await sessionDoc.getMetaState();
metaGoal = (meta as SessionLegacyMetaFields | null | undefined)?.latestGoal ?? null;
} catch (error) {
this.deps.logger.debug(
`[${sessionId}] Failed to read latest goal meta before completion notification: ${formatErrorMessage(error)}`
);
}

try {
return resolveLatestSessionGoalFromHistory(await sessionDoc.getHistory()) ?? metaGoal;
} catch (error) {
this.deps.logger.debug(
`[${sessionId}] Failed to read latest goal history before completion notification: ${formatErrorMessage(error)}`
);
return metaGoal;
}
}

private async evictForTurnStart(sessionId: SessionId): Promise<MemoryPressureEvictionResult> {
return await this.deps.evictForMemoryPressure(sessionId);
}
Expand Down Expand Up @@ -2565,19 +2537,6 @@ export class SessionExecutionService {
return;
}

const latestGoal = await this.runTurnFinalizationStage(
sessionId,
turnId,
'resolveLatestGoalForCompletionNotification',
async () => await this.resolveLatestGoalForCompletionNotification(sessionId, sessionDoc)
);
if (isSessionGoalWorking(latestGoal)) {
this.deps.logger.debug(
`[${sessionId}] Skipping session completion notification because a thread goal is still active`
);
return;
}

await this.runTurnFinalizationStage(sessionId, turnId, 'notifySessionCompleted', async () => {
await this.deps.turnFinalization.notifySessionCompleted(sessionId, userId, turnId);
});
Expand Down
Loading