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
Proposal: sync per-session RUNNING state across devices ("Running Sync", 러닝중 동기화) by writing lightweight turn markers to the user's cloud storage, so that a turn started on one machine shows as RUNNING in the session list of every other machine, and disappears when the turn ends.
This builds on the existing local RUNNING indicator (the busy set in packages/core/src/chat/session.ts, surfaced as running: string[] on the sessions frame). That signal is process-local memory: it works within one app instance (VS Code panels plus sidebar, or the mobile app) but cannot cross processes. VS Code, CLI, and mobile are separate processes on separate machines, so today a deep-research turn running on the desktop is invisible from the phone.
Status: design proposal only, not scheduled.
Zero agent involvement, zero token cost
The agent never writes, renews, or even knows about markers. All signals are captured by the app from turn lifecycle events it already has:
Turn start: the moment the send button flips to stop, i.e. the same event that does busy.add (ensureHandle).
Turn end: the moment the stop button flips back to send, i.e. the existing onTurnEnd callback (busy.delete).
These are harness-side hooks. No prompt text, no extra model calls, no tokens.
Definition of "turn"
A turn starts when the user sends a message and the agent begins working, and ends when the agent has finished everything it wants to say, without the user interrupting. This matches the exact add/delete points of the busy set today, so the writer hooks already exist and nothing polls.
Concept: two writes and a hardcoded expiry
On turn start the device writes a running marker with a hardcoded expiry class. On turn end it overwrites it with an ended marker. That is the entire write protocol: exactly two writes per turn, no renewals, no heartbeat, no cleanup job.
Expiry classes are hardcoded per task type, chosen at turn start:
Default: every session's turn expires 20 minutes after start.
Deep research (or other known long-running flows): the app sets a deepResearch flag on the marker at turn start, which extends expiry to 60 minutes.
Readers render RUNNING only while state == running AND expiresAt > now. An expired marker is treated as ended by every device regardless of its state, so an abandoned marker can never spin forever by construction.
sequenceDiagram
participant A as Device A (VS Code, turn owner)
participant S as Cloud storage (user's Drive)
participant B as Device B (mobile)
Note over A: user hits send (send button flips to stop, busy.add)
alt normal turn
A->>S: write { sessionId, turnId, deviceId, state: running, startedAt, expiresAt: +20m }
else deep research turn
A->>S: write { ..., flag: deepResearch, expiresAt: +60m }
end
B->>S: session list sync (existing path)
S-->>B: sessions + markers
Note over B: running AND expiresAt in the future -> show RUNNING
Note over A: agent finishes (stop button flips back to send, onTurnEnd)
A->>S: overwrite { turnId, state: ended, endedAt }
S-->>B: next sync
Note over B: ended -> hide RUNNING
Note over A,B: crash case: Device A dies mid-turn, no ended mark is written,<br/>expiresAt passes, Device B treats it as ended automatically
Loading
Marker lifecycle:
stateDiagram-v2
[*] --> Running: turn start (write marker, expiry by class)
Running --> Ended: turn end (write ended)
Running --> Expired: expiresAt passes (crash, offline, kill)
Ended --> [*]
Expired --> [*]: readers render as ended
note right of Expired: no cleanup job, no renewals,<br/>expiry is a read-side decision
Loading
Preventing ghost RUNNING marks
Hardcoded expiry per class, enforced by readers. The rendering rule is running AND expiresAt > now. Nobody deletes stale marks; expiry is purely a display decision on the reading device, so there is no cleanup path that can fail.
The ended mark is the primary close. Expiry is only the backstop for crashes. In the normal path the onTurnEnd hook always writes ended, so other devices clear the badge on their next sync rather than waiting out the 20 minutes.
Turn-scoped IDs. Every turn gets a fresh turnId, and an ended mark only closes the matching turnId. A delayed or retried ended write from a previous turn cannot kill the badge of a newer turn on the same session.
Startup sweep scoped by deviceId. Markers carry the writer's deviceId (the session store already tracks lastDevice). When an app instance boots, it force-closes any running markers left behind by its own deviceId, so a restart after a crash cleans its own ghosts immediately instead of waiting out the expiry.
Interrupt is a turn end. The user pressing stop fires the same turn-end path, so an interrupted turn writes ended like a normal one.
Clock-skew tolerance. Readers compare expiresAt against their own clock. Expiry windows are tens of minutes while realistic skew is seconds; readers add a fixed grace margin (for example 60s). Never require clock agreement tighter than the expiry window.
Accepted tradeoffs of skipping lease renewal
Stated honestly so we are not surprised later:
A legitimate normal turn that runs longer than 20 minutes will show as ended on other devices while still running. Display-only inaccuracy; the session itself is unaffected. If this bites, the fix is adding more hardcoded classes, not renewals.
A crashed turn can show RUNNING on other devices for up to its full window (20 or 60 minutes) unless the crashed device restarts first (startup sweep). With lease renewal this would be minutes, but renewal costs periodic writes on every long turn; the fixed window costs nothing.
Renewable leases remain a possible later refinement if the fixed classes prove wrong. They are deliberately out of scope for v1.
What this is not
Not a distributed lock: two devices are not prevented from talking to the same session; the marker is display-only.
Not message sync: markers carry no chat content, only ids and timestamps.
Not an agent behavior: the model never sees or writes markers; everything hangs off the send/stop button lifecycle the app already controls.
Open questions
Marker placement: a sibling file per session vs a single running.json map for the account. A single map means one read per sync but write contention if two devices run turns simultaneously; per-session files avoid contention at the cost of extra reads.
Which flows besides deep research deserve the long class, and whether 20/60 minutes are the right defaults.
Whether the CLI surface should also write markers, or only read them, in its first iteration.
Summary
Proposal: sync per-session RUNNING state across devices ("Running Sync", 러닝중 동기화) by writing lightweight turn markers to the user's cloud storage, so that a turn started on one machine shows as RUNNING in the session list of every other machine, and disappears when the turn ends.
This builds on the existing local RUNNING indicator (the
busyset inpackages/core/src/chat/session.ts, surfaced asrunning: string[]on thesessionsframe). That signal is process-local memory: it works within one app instance (VS Code panels plus sidebar, or the mobile app) but cannot cross processes. VS Code, CLI, and mobile are separate processes on separate machines, so today a deep-research turn running on the desktop is invisible from the phone.Status: design proposal only, not scheduled.
Zero agent involvement, zero token cost
The agent never writes, renews, or even knows about markers. All signals are captured by the app from turn lifecycle events it already has:
busy.add(ensureHandle).onTurnEndcallback (busy.delete).These are harness-side hooks. No prompt text, no extra model calls, no tokens.
Definition of "turn"
A turn starts when the user sends a message and the agent begins working, and ends when the agent has finished everything it wants to say, without the user interrupting. This matches the exact add/delete points of the
busyset today, so the writer hooks already exist and nothing polls.Concept: two writes and a hardcoded expiry
On turn start the device writes a
runningmarker with a hardcoded expiry class. On turn end it overwrites it with anendedmarker. That is the entire write protocol: exactly two writes per turn, no renewals, no heartbeat, no cleanup job.Expiry classes are hardcoded per task type, chosen at turn start:
deepResearchflag on the marker at turn start, which extends expiry to 60 minutes.Readers render RUNNING only while
state == running AND expiresAt > now. An expired marker is treated as ended by every device regardless of its state, so an abandoned marker can never spin forever by construction.sequenceDiagram participant A as Device A (VS Code, turn owner) participant S as Cloud storage (user's Drive) participant B as Device B (mobile) Note over A: user hits send (send button flips to stop, busy.add) alt normal turn A->>S: write { sessionId, turnId, deviceId, state: running, startedAt, expiresAt: +20m } else deep research turn A->>S: write { ..., flag: deepResearch, expiresAt: +60m } end B->>S: session list sync (existing path) S-->>B: sessions + markers Note over B: running AND expiresAt in the future -> show RUNNING Note over A: agent finishes (stop button flips back to send, onTurnEnd) A->>S: overwrite { turnId, state: ended, endedAt } S-->>B: next sync Note over B: ended -> hide RUNNING Note over A,B: crash case: Device A dies mid-turn, no ended mark is written,<br/>expiresAt passes, Device B treats it as ended automaticallyMarker lifecycle:
stateDiagram-v2 [*] --> Running: turn start (write marker, expiry by class) Running --> Ended: turn end (write ended) Running --> Expired: expiresAt passes (crash, offline, kill) Ended --> [*] Expired --> [*]: readers render as ended note right of Expired: no cleanup job, no renewals,<br/>expiry is a read-side decisionPreventing ghost RUNNING marks
Hardcoded expiry per class, enforced by readers. The rendering rule is
running AND expiresAt > now. Nobody deletes stale marks; expiry is purely a display decision on the reading device, so there is no cleanup path that can fail.The ended mark is the primary close. Expiry is only the backstop for crashes. In the normal path the
onTurnEndhook always writesended, so other devices clear the badge on their next sync rather than waiting out the 20 minutes.Turn-scoped IDs. Every turn gets a fresh
turnId, and anendedmark only closes the matchingturnId. A delayed or retriedendedwrite from a previous turn cannot kill the badge of a newer turn on the same session.Startup sweep scoped by deviceId. Markers carry the writer's
deviceId(the session store already trackslastDevice). When an app instance boots, it force-closes anyrunningmarkers left behind by its own deviceId, so a restart after a crash cleans its own ghosts immediately instead of waiting out the expiry.Interrupt is a turn end. The user pressing stop fires the same turn-end path, so an interrupted turn writes
endedlike a normal one.Clock-skew tolerance. Readers compare
expiresAtagainst their own clock. Expiry windows are tens of minutes while realistic skew is seconds; readers add a fixed grace margin (for example 60s). Never require clock agreement tighter than the expiry window.Accepted tradeoffs of skipping lease renewal
Stated honestly so we are not surprised later:
What this is not
Open questions
running.jsonmap for the account. A single map means one read per sync but write contention if two devices run turns simultaneously; per-session files avoid contention at the cost of extra reads.