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
2 changes: 1 addition & 1 deletion apps/ade-cli/src/adeRpcServer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1560,7 +1560,7 @@ describe("adeRpcServer", () => {
args: { outcome: "Shipped" },
assert: () => expect(runtime.sessionService.settleSession).toHaveBeenCalledWith(
"chat-1",
{ outcome: "Shipped" },
{ outcome: "Shipped", source: "agent_explicit" },
),
},
{
Expand Down
33 changes: 29 additions & 4 deletions apps/ade-cli/src/bootstrap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,9 +688,28 @@ export async function createAdeRuntime(args: {
laneServiceRef = laneService;
await laneService.ensurePrimaryLane();

// Late-bound because the publisher is constructed after the session/PTY
// services. Session changes still use it once publishing is attached.
let pushPublisherForPtySignals: PushPublisherService | null = null;
let ptyServiceForSessionChanges: ReturnType<typeof createPtyService> | null = null;
const sessionService = createSessionService({ db });
sessionService.onChanged((event) => {
pushEvent("runtime", { type: "terminal_session_changed", event });
const session = sessionService.get(event.sessionId);
const runtimeState = session
? ptyServiceForSessionChanges?.getRuntimeState(event.sessionId, session.status)
?? session.runtimeState
: null;
if (
session
&& (session.status !== "running" || runtimeState === "idle")
&& (
session.settleOverride === "settled"
|| (session.settleOverride !== "active" && Boolean(session.settledAt))
Comment on lines +703 to +708

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Only terminate pushes on actual settlement transitions

This listener runs after every session metadata change, so a chat that retains settledAt during an active scheduled/background turn is treated as newly settled. For example, after the chat event publishes the background turn as running, a title or status-note update emits terminal_session_changed; this condition then calls handleSessionSettled, removes the active push run, and ends its Live Activity even though the canonical lifecycle intentionally considers the session running until the turn returns to rest. Trigger termination only when the mutation actually enters settlement, or include the current at-rest runtime condition. This affects the ADE CLI runtime used by both headless and desktop socket-backed paths.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

)
) {
pushPublisherForPtySignals?.handleSessionSettled(projectId, event.sessionId);
}
});
const processRegistry = createProcessRegistryService({
db,
Expand Down Expand Up @@ -900,10 +919,8 @@ export async function createAdeRuntime(args: {
// pattern as desktop main. Without this bridge, paired phones only ever
// receive terminal snapshots, never live terminal_data push.
let syncServiceForPtyEvents: ReturnType<typeof createSyncService> | null = null;
// Same late-binding for the push publisher: it feeds tracked CLI runtime
// states (running / waiting-input from OSC 133 markers) into the phone's
// Live Activity, and it's constructed after ptyService.
let pushPublisherForPtySignals: PushPublisherService | null = null;
// The late-bound push publisher feeds tracked CLI runtime states into the
// phone's Live Activity.
const ptyService = createPtyService({
projectRoot,
transcriptsDir: paths.transcriptsDir,
Expand Down Expand Up @@ -931,6 +948,9 @@ export async function createAdeRuntime(args: {
runtimeState: signal.runtimeState,
});
},
onSessionUserInput: ({ sessionId }) => {
pushPublisherForPtySignals?.handleSessionAttentionResolved(projectId, sessionId);
},
diskPressureMonitor,
onSessionEnded: (event) => {
void sessionDeltaService.computeSessionDelta(event.sessionId).catch((error) => {
Expand All @@ -945,6 +965,7 @@ export async function createAdeRuntime(args: {
loadPty: ptyBackend ?? (() => nodePty),
disposePtyBackend: ptyBackend?.dispose
});
ptyServiceForSessionChanges = ptyService;

const testService = createTestService({
db,
Expand Down Expand Up @@ -1509,6 +1530,10 @@ export async function createAdeRuntime(args: {
title: session.title ?? null,
toolType: session.toolType ?? null,
chatSessionId: session.chatSessionId ?? null,
status: session.status,
runtimeState: session.runtimeState ?? null,
settledAt: session.settledAt ?? null,
settleOverride: session.settleOverride ?? null,
};
} catch {
return null;
Expand Down
72 changes: 70 additions & 2 deletions apps/ade-cli/src/services/push/pushPublisherService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,7 +285,15 @@ describe("createPushPublisherService flush", () => {
flushDebounceMs: 2_000,
promptFlushMs: 150,
});
const cliSessions = new Map<string, { title: string | null; toolType?: string | null; chatSessionId?: string | null }>();
const cliSessions = new Map<string, {
title: string | null;
toolType?: string | null;
chatSessionId?: string | null;
status?: string | null;
runtimeState?: string | null;
settledAt?: string | null;
settleOverride?: "settled" | "active" | null;
}>();
const detach = publisher.attachSources("scope-1", {
agentChatService: agentChatService as never,
projectName: "ADE",
Expand Down Expand Up @@ -1843,7 +1851,7 @@ describe("createPushPublisherService flush", () => {
run({ sessionId: "s-1", kind: "chat", phase: "waiting_for_input" }),
])).toBe(1);
const waitingRun = second.liveActivity[0].contentState.runs.find((r: { id: string }) => r.id === "cli-1");
expect(waitingRun.phase).toBe("waiting_for_input");
expect(waitingRun.phase).toBe("stale");

publisher.dispose();
});
Expand Down Expand Up @@ -1875,6 +1883,27 @@ describe("createPushPublisherService flush", () => {
detail: "Which account should the e2e test use?",
});

publisher.handleCliRuntimeSignal("scope-1", {
laneId: "auth-lane",
sessionId: "cli-ask-1",
runtimeState: "idle",
});
await vi.advanceTimersByTimeAsync(200);
const heartbeatPayload = publish.mock.calls.at(-1)?.[0];
expect(heartbeatPayload.liveActivity[0].contentState.runs[0]).toMatchObject({
id: "cli-ask-1",
phase: "waiting_for_input",
});

publisher.handleSessionAttentionResolved("scope-1", "cli-ask-1");
expect(publisher._debug.getPendingAlerts()).toEqual([]);
await vi.advanceTimersByTimeAsync(200);
const resolvedPayload = publish.mock.calls.at(-1)?.[0];
expect(resolvedPayload.liveActivity[0].contentState.runs[0]).toMatchObject({
id: "cli-ask-1",
phase: "running",
});

publisher.dispose();
});

Expand Down Expand Up @@ -1916,6 +1945,45 @@ describe("createPushPublisherService flush", () => {
publisher.dispose();
});

it("terminates a waiting CLI run when dismiss-and-settle resolves attention", async () => {
const { publisher, cliSessions } = makeHarness();
await publisher.start();

publisher.handleSessionAttentionRequested("scope-1", {
sessionId: "cli-settle-1",
kind: "cli",
title: "Fix auth race",
message: "Choose an account",
laneId: "auth-lane",
});
publisher.handleSessionSettled("scope-1", "cli-settle-1");

expect(publisher._debug.getPendingAlerts()).toEqual([]);
expect(publisher._debug.runs.has("cli-settle-1")).toBe(false);

cliSessions.set("cli-settle-1", {
title: "Fix auth race",
toolType: "codex",
status: "running",
settledAt: "2026-07-29T22:00:00.000Z",
});
publisher.handleCliRuntimeSignal("scope-1", {
laneId: "auth-lane",
sessionId: "cli-settle-1",
runtimeState: "running",
});
expect(publisher._debug.runs.get("cli-settle-1")?.phase).toBe("running");

publisher.handleCliRuntimeSignal("scope-1", {
laneId: "auth-lane",
sessionId: "cli-settle-1",
runtimeState: "idle",
});
expect(publisher._debug.runs.has("cli-settle-1")).toBe(false);

publisher.dispose();
});

it("drops chat-owned shells and unknown sessions from CLI run tracking", async () => {
const { publisher, publish, emit, cliSessions } = makeHarness();
cliSessions.set("shell-1", { title: "attached shell", toolType: "shell", chatSessionId: "s-1" });
Expand Down
95 changes: 88 additions & 7 deletions apps/ade-cli/src/services/push/pushPublisherService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,10 @@ export type PushPublisherSources = {
title: string | null;
toolType?: string | null;
chatSessionId?: string | null;
status?: string | null;
runtimeState?: string | null;
settledAt?: string | null;
settleOverride?: "settled" | "active" | null;
} | null;
};

Expand Down Expand Up @@ -932,6 +936,19 @@ export function createPushPublisherService(deps: PushPublisherDeps) {
runs.delete(run.sessionId);
continue;
}
const atRest = record.status !== "running" || record.runtimeState === "idle";
if (
atRest
&& (
record.settleOverride === "settled"
|| (record.settleOverride !== "active" && record.settledAt)
)
) {
run.phase = "completed";
recentRuns.set(run.sessionId, { ...run });
runs.delete(run.sessionId);
continue;
}
run.title = record.title?.trim() || run.title || null;
run.agent = providerDisplayName(record.toolType) ?? run.agent ?? "CLI";
run.metaResolved = true;
Expand Down Expand Up @@ -1548,22 +1565,49 @@ export function createPushPublisherService(deps: PushPublisherDeps) {
};

/**
* OSC 133-derived terminal state for tracked CLI sessions. Feeds the Live
* Activity only — no alert pushes: a CLI agent returns to its prompt
* (waiting-input) after EVERY turn, so alerting on it would ping the user
* once per turn. Failure alerts stay with onPtyExit's non-zero-exit path.
* Terminal runtime state for tracked CLI sessions. Prompt/marker inference
* must never raise attention: only an explicit `ade chat ask` or a
* provider-structured pending input may publish `waiting_for_input`.
*/
const onCliRuntimeSignal = (scopeKey: string, signal: PushCliRuntimeSignal): void => {
if (disposed || !signal.sessionId) return;
const existing = runs.get(signal.sessionId);
const session = scopes.get(scopeKey)?.resolveCliSession?.(signal.sessionId) ?? null;
const atRest = session?.status !== "running" || signal.runtimeState === "idle";
if (
atRest
&& (
session?.settleOverride === "settled"
|| (session?.settleOverride !== "active" && Boolean(session?.settledAt))
)
) {
if (existing) {
existing.phase = "completed";
existing.itemId = null;
markRunUpdated(existing);
recentRuns.set(signal.sessionId, { ...existing });
runs.delete(signal.sessionId);
pendingAlerts = pendingAlerts.filter(
(alert) =>
alert.dedupeKey !== `alert:${signal.sessionId}:approval`
&& alert.dedupeKey !== `alert:${signal.sessionId}:question`,
);
clearAlertDedupe(`alert:${signal.sessionId}:approval`);
clearAlertDedupe(`alert:${signal.sessionId}:question`);
scheduleFlush(true);
}
return;
}
// Exit/kill phases are owned by onPtyExit (which knows the exit code).
if (signal.runtimeState === "exited" || signal.runtimeState === "killed") return;
// Explicit/provider-structured attention owns this phase until the
// lifecycle event that resolves it. PTY heartbeats are observational and
// must not erase a real request.
if (existing?.phase === "waiting_for_input" || existing?.phase === "waiting_for_approval") return;
Comment thread
arul28 marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve attention when dismiss-and-settle runs

When an explicit CLI ask is cleared through session.settleSession with dismissPendingInput (the desktop/socket settle path), settleTerminalSession resolves it by emitting an idle runtime state, but this guard discards that authoritative update because the push run is still waiting; the only new call to handleSessionAttentionResolved is wired to PTY user input. Consequently, Dismiss & settle clears the persisted attention while the Live Activity and aggregate push state remain waiting_for_input until the process exits or somebody later types into the PTY. The fresh evidence beyond the prior PTY-response issue is this separate dismiss-and-settle resolution path, which should also invoke the resolution handler.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

// `idle` = no output for 12s with no OSC prompt marker — we can't prove
// the CLI is working OR at a prompt, so publish it as `stale` (dimmed,
// not counted active) instead of overstating it as a live running row.
const phase: AgentRunPhase = signal.runtimeState === "waiting-input"
? "waiting_for_input"
: signal.runtimeState === "idle"
const phase: AgentRunPhase = signal.runtimeState === "waiting-input" || signal.runtimeState === "idle"
? "stale"
: "running";
// Signals re-fire on a ~10s heartbeat; only a phase change is worth a
Expand Down Expand Up @@ -1886,6 +1930,43 @@ export function createPushPublisherService(deps: PushPublisherDeps) {
scheduleFlush(true);
},

handleSessionAttentionResolved(scopeKey: string | null, sessionId: string): void {
if (disposed || !sessionId) return;
const run = runs.get(sessionId);
if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return;
if (run.phase !== "waiting_for_input" && run.phase !== "waiting_for_approval") return;
run.phase = "running";

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Terminate the push run after dismiss-and-settle

In the socket session.settleSession path for a tracked CLI with an explicit ask, settleTerminalSession first emits idle, but onCliRuntimeSignal suppresses that signal while the run is awaiting attention; this callback then hard-codes the run back to running even though the session is immediately settled. The Live Activity therefore reports running until another heartbeat changes it to stale, after which it can linger until the stale-run TTL. The fresh evidence after the earlier resolution-callback fix is that the invoked handler uses the same running transition intended for ordinary PTY user responses; dismiss-and-settle needs a terminal/completed transition or removal instead.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

run.itemId = null;
markRunUpdated(run);
pendingAlerts = pendingAlerts.filter(
(alert) =>
alert.dedupeKey !== `alert:${sessionId}:approval`
&& alert.dedupeKey !== `alert:${sessionId}:question`,
);
clearAlertDedupe(`alert:${sessionId}:approval`);
clearAlertDedupe(`alert:${sessionId}:question`);
scheduleFlush(true);
},

handleSessionSettled(scopeKey: string | null, sessionId: string): void {
if (disposed || !sessionId) return;
const run = runs.get(sessionId);
if (!run || (scopeKey != null && run.scopeKey !== scopeKey)) return;
run.phase = "completed";
run.itemId = null;
markRunUpdated(run);
recentRuns.set(sessionId, { ...run });
Comment on lines +1955 to +1958

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep settled CLI runs terminal across heartbeats

When a tracked CLI is settled while its PTY remains active, this records completed in recentRuns but leaves the same run in runs; the next output or idle signal enters onCliRuntimeSignal, whose guard protects only waiting phases, and overwrites it with running or stale. A mobile/socket settlement can therefore briefly end and then reopen the Live Activity and Attention item. Remove the settled run from active tracking, or ignore observational PTY signals until genuine user activity clears settlement.

AGENTS.md reference: AGENTS.md:L35-L35

Useful? React with 👍 / 👎.

runs.delete(sessionId);
Comment on lines +1958 to +1959

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep settled runs suppressed after removing them

When a tracked CLI is settled while its PTY remains live, deleting it from runs ends the current Live Activity, but the next output signal reaches onCliRuntimeSignal with no existing run and ensureRun immediately recreates it as running/stale. Fresh evidence beyond the earlier heartbeat thread is that the new deletion has no settled tombstone or session-state guard, so settlement still does not remain terminal once the CLI emits more output.

Useful? React with 👍 / 👎.

pendingAlerts = pendingAlerts.filter(
(alert) =>
alert.dedupeKey !== `alert:${sessionId}:approval`
&& alert.dedupeKey !== `alert:${sessionId}:question`,
);
clearAlertDedupe(`alert:${sessionId}:approval`);
clearAlertDedupe(`alert:${sessionId}:question`);
scheduleFlush(true);
},

/** Force a flush soon (e.g. right after a device registers). */
poke(): void {
scheduleFlush(true);
Expand Down
Loading