Session Operations#
Each operation is written out end to end, from trigger to disk to frontend.
Startup#
When the process starts, SessionStore runs a one-time initialization:
- Read
index.jsonand load it into the in-memory_indexdict - If the file does not exist or JSON parsing fails → scan the meta.json of every session directory to rebuild
_index, then writeindex.json - Iterate over
_indexand reset everystatus=runningtoidle(crash recovery) - Clean up empty shells: a session with 0 messages and created more than 1 hour ago → delete the directory + delete the registry entry
- Clean up expired archives:
archived=Trueandupdated_atolder than 90 days → delete - Capacity check: if the registry exceeds 1000 entries → delete the oldest archived sessions in ascending
updated_atorder
Handling of half-broken sessions:
- Has meta.json but no history/ → treated as an empty shell, deleted in step 4
- Has history/ but no meta.json → the scan in step 2 cannot read meta.json, so it is not registered and is treated as nonexistent
Creating a session#
Three entry points can trigger creation:
| Entry point | Scenario |
|---|---|
dispatcher.process_user_turn |
When a user sends a message and the session does not exist, create it |
channel handler |
Create it when a channel message arrives |
session_context |
When the CLI / research harness enters the context and the session does not exist, create it |
No other place creates a session.
Full flow#
Caller calls create_session(session_id, agent_id, source=..., ...)
→ Create the <state>/sessions/<session_id>/ directory
→ Write meta.json (id, agent_id, title, created_at, updated_at, source, status="idle", ...)
→ Write the registry: _index[session_id] = summary entry
→ Atomically write the registry to disk (temp file → os.rename)
→ No broadcast (the frontend discovers the new session via list_sessions)
Atomicity#
For the dispatcher and the channel handler, creation and writing the first message are atomic — append_message is called immediately after creation, so no empty shell is produced.
session_context creates the session in __enter__ (because the subsequent ContextVar loading needs a valid session). If it exits abnormally before writing any message, an empty shell is produced, which is handled by the startup cleanup.
Main project binding#
A session's main working directory is the path of the project it is bound to. The binding is chosen freely while the chat is still a draft, and freezes when the first real message commits — from that turn onwards the conversation's cwd, its permission fence baseline, and the location of its session repo (<project>/.openprogram/sessions/<id>/) all stay put. That is the whole point of freezing: those three follow the main directory, and only create_session can place the repo, so a later switch would leave the repo stranded in the old project while the model worked in the new one.
Additional working directories are the opposite and are meant to be: they are added and removed at any point in the session's life. See additional-working-directories.md.
Where the choice is made#
draft chat, project picked in the composer chip
→ pendingProjectsByChat[chatKey] (frontend store, nothing sent yet)
→ first chat frame carries project_id
→ handle_chat creates the session WITH that project → repo lands inside it
→ chat_ack sends the idempotent set_session_project (label + reverse index)
set_session_project accepts a bind naming the project the session already has, at any age — that is what the post-ack idempotent bind above sends, and it arrives after the first turn is committed. A bind naming a different project on a session that already has turns is rejected with project is frozen after the first turn (ws_actions/project.py:FROZEN_ERROR). The composer chip matches: for a session with an id it renders the bound project read-only instead of the picker.
Repairing a missing directory#
A frozen main directory can still be relocated, and only for repair: the folder was moved or renamed on disk and the project now points at nothing. project_workdir_for returns None in that state — it never silently substitutes the default project's home directory — so the turn's tool cwd falls back to the session's own workdir/, and project_path_missing names the vanished path. list_projects ships that as path_missing per project, which is what turns the composer chip orange and puts "Locate folder…" in its menu.
The repair is the relocate_project ws action:
relocate_project {session_id, project_id, path}
→ project_store.relocate_project: validate the new directory, rewrite the
project's path, KEEP its id (sessions, settings and the frozen binding all
hang off the id)
→ record_relocate: append the graph node below
→ project_relocated {ok, old_path, path, node_id} + a fresh projects_list
It changes the project's path, never the session→project binding, which is why it stays legal after the freeze. Every session bound to that project follows the move. The default project refuses to relocate: its path is the home directory, restored on every get_default_project read.
The relocate record node#
The move changes where every later turn runs, so it is recorded in the session graph rather than mutating the registry silently (openprogram/store/project/relocate_node.py):
| Field | Value |
|---|---|
role |
code |
name |
project/relocate |
caller |
ROOT |
predecessor |
None |
output |
<old path> → <new path> |
metadata |
{display: "runtime", project_id, old_path, new_path} |
The shape follows context/system_prompt (dag/overview.md §3, §7): the write invariant constrains only conversational nodes, and because caller is set the store leaves head where it is — recording a relocation never moves the branch tip. The name is deliberately not under context/: those are pipeline machinery hidden from the transcript, while a relocation is a user action worth seeing.
Writing a message#
Caller calls append_message(session_id, msg)
→ Write the message to the DAG (Git history/)
→ If msg.role == "user":
→ preview = take the first 80 characters of msg.content
→ _index[session_id]["preview"] = preview
→ _index[session_id]["updated_at"] = time.time()
→ Mark the registry dirty (write to disk with a 5-second debounce)
→ No broadcast (message content is pushed through a separate streaming channel)
preview truncation#
def _truncate(text: str | None, max_len: int = 80) -> str | None:
if not text:
return None
t = text.strip().replace("\n", " ")
return t[:77] + "…" if len(t) > max_len else t
Throttling registry writes to disk#
When append_message updates the registry, memory is updated immediately, but the disk write is debounced (at most one write per 5 seconds). The registry is flushed on process exit. If the process is SIGKILLed and the flush fails, recovery is simply a matter of rebuilding from meta.json at startup, losing at most 5 seconds of preview updates.
Other operations (create, update, delete) write to disk atomically and immediately.
Updating fields#
Updates to the title, status, pinned, archived, unread, and other fields all go through the same path:
Caller calls update_session(session_id, title="New title", pinned=True, ...)
→ Write meta.json (only the fields that were passed in are updated)
→ Update the corresponding fields in _index[session_id] + updated_at
→ Atomically write the registry to disk
The broadcast is initiated by the WebSocket handler layer via _broadcast after it calls update_session; both rename and flags go through the broadcast:
→ Broadcast session_updated:
{"type": "session_updated", "data": {"id": "<session_id>", "title": "New title", "pinned": true}}
→ After the frontend's handleSessionUpdated receives it, it patches the corresponding session and re-renders
data contains only the changed fields, and the frontend does an incremental patch.
When status is written#
The dispatcher writes status during the turn lifecycle:
| Timing | Value written |
|---|---|
| Turn start | update_session(session_id, status="running") |
| Turn ends normally (foreground) | update_session(session_id, status="idle") |
| Turn ends normally (background) | update_session(session_id, status="done", unread=True) |
| Turn fails | update_session(session_id, status="failed") |
| Waiting for user input | update_session(session_id, status="needs_input") |
Naming#
Naming has only one authoritative implementation: openprogram/agent/dispatcher/titles.py. Naming from all entry points (WS / fn-form / channel / CLI / spawn) converges on finalize_turn end → _maybe_auto_title; there is no second truncation/lock logic. Title writes all go through update_session(session_id, title=...), following the full "Updating fields" flow above.
Lock markers (authoritative, only two + one internal counter)#
| Marker | Type | Meaning | Who sets it |
|---|---|---|---|
_user_titled |
bool | User manually renamed → permanent lock, auto-naming never runs again | Only the rename operation sets it, when the user has entered a name |
_auto_titled |
bool | Auto-naming has produced at least one title (first-round truncation or any LLM write) → a "don't re-truncate" dedup bit | Only _maybe_auto_title sets it |
_title_gen_count |
int | Internal counter for progressive renaming (which entry of _RETITLE_AT_TURNS was hit) |
Internal to _maybe_auto_title, not an entry-point lock |
There is no single marker that means both "truncated" and "permanently locked": one marker doing both would conflict with the two-phase flow, where truncation is expected to be superseded by the LLM title. Entry points therefore do not truncate or set a lock of their own; _maybe_auto_title handles phase 1 (truncation) and phase 2 (LLM) uniformly. The only lock an entry point may set is _user_titled, set by the rename operation.
Auto-naming (progressive, two phases)#
Auto-naming triggers multiple times as the conversation evolves, producing more precise titles as context accumulates.
Trigger thresholds: at the 1st, 6th, 16th, and 40th assistant reply (_RETITLE_AT_TURNS).
finalize_turn end → _maybe_auto_title:
1. Check _user_titled → if the user manually renamed, never auto-rename
2. Count the current number of assistant messages → skip if no threshold is hit
3. First time (turn 1, phase 1 immediate truncation):
a. title = _title_from_text(user's first message)
(strip [attachment:]/<attachment-preview>/<file> markers → take the first line → truncate to 50 chars, appending … if it overflows)
→ update_session(session_id, title=truncated value, _auto_titled=True, _title_gen_count=1)
b. Start a background daemon thread to call the LLM (phase 2)
4. Subsequent thresholds (turn 6/16/40):
a. Directly start a background daemon thread
b. The LLM input takes the most recent 20 messages (not just the first round)
5. Background thread (phase 2):
→ Race check: give up if _user_titled
→ On the first time, also check whether title is still the phase 1 truncated value (give up writing if it has been changed)
→ Write update_session(session_id, title=LLM result, _auto_titled=True, _title_gen_count=N+1)
→ Broadcast session_updated
channel (WeChat / Discord, etc.)#
Channel conversation naming is exactly the same as for ordinary conversations, going through the same two-phase LLM naming. The channel side does not perform any additional operation / lock / intervention on the title content (it does not set _user_titled, does not set _auto_titled, and does not pre-truncate). The source identifier is only added as a bracketed brand prefix in the frontend display layer (e.g. [WeChat] Weekend plan discussion); it does not go into the title itself.
Empty conversations#
Creation and writing the first message are atomic, so empty conversations are not normally produced; should one occur, it is handled by startup cleanup or manual deletion. The naming layer does no special filtering for empty conversations.
User-initiated rename#
- Manually enter a new name →
update_session(session_id, title=new name, _user_titled=True)After_user_titledis set, auto-naming stops permanently. - Have the LLM regenerate (click the button, title is empty) →
_llm_rename()→update_session(session_id, title=LLM result)_user_titledis not set, and auto-naming continues.
For the details of LLM title generation (prompt, parameters, post-processing), see name.md.
Listing#
The frontend sends the WebSocket message {"action": "list_sessions"}
→ handle_list_sessions:
→ session_store.list_sessions():
→ Iterate over the in-memory _index.values()
→ Drop archived rows unless include_archived=True
→ Filter by filters
→ Sort by updated_at in descending order
→ Return rows[offset:offset+limit]
→ Fill in the project field (mapped from the project directory)
→ Send {"type": "sessions_list", "data": rows}
→ The frontend renders the sidebar and the Chats page
Purely an in-memory operation; it does not touch disk.
Archived rows#
list_sessions hides archived sessions by default — archiving only bounds the
list if the default listing honours it. Two ways to see them:
include_archived=Truereturns archived and active rows togetherarchived=Truereturns only the archived ones
Maintenance passes that must visit every session regardless of the flag —
the memory scan, run-state repair, "clear all", channel-binding lookup,
agent addressing — pass include_archived=True explicitly. The sidebar
payload also sends every row and lets the client switch between the active,
archived, and all views without a round trip.
Fields returned per session#
The 15 fields in the registry + preview + project (filled in during listing), 17 in total. See storage.md for the complete list.
Deleting#
Caller calls delete_session(session_id)
→ Delete the entire <state>/sessions/<session_id>/ directory
→ Delete _index[session_id]
→ Atomically write the registry to disk
→ Broadcast session_deleted:
{"type": "session_deleted", "session_id": "<session_id>"}
→ After the frontend receives it, remove it from the list
The registry operation is internal to delete_session. The broadcast is initiated by the WebSocket handler layer via _broadcast.
Archiving#
Archiving keeps the session list from growing without end. It is a single boolean on the session meta — nothing is deleted, nothing is moved, and the operation is reversible at any time.
Caller calls set_archived(session_id, True)
→ update_session(session_id, archived=True)
→ Follows the full "Updating fields" flow
→ After the frontend receives the broadcast, it filters the display
set_archived returns False for an unknown session id, so the CLI and the
REST endpoints can report a missing session instead of silently succeeding.
What archiving does not touch#
updated_at records the last time a message was appended, and archiving
appends nothing — so it leaves the timestamp alone. This is the
index consistency contract: an archived session
returns to exactly its old place in the list when unarchived, rather than
jumping to the top. Messages, branches, and history files are untouched;
get_messages keeps working while a session is archived.
Entry points#
| Surface | Operation |
|---|---|
| WebSocket | {"action": "update_session_flags", "session_id": ..., "archived": true} |
| REST | POST /api/sessions/archive / POST /api/sessions/unarchive, body {"session_id": ...} |
| CLI | openprogram sessions archive <id> / openprogram sessions unarchive <id> |
All three write the same flag through set_archived and broadcast
session_updated, so every open tab agrees.
Archived sessions are subject to the startup-time data maintenance constraints: 90-day expiration + the 1000 capacity cap. Active sessions are not affected.
Writing the registry to disk (general)#
All registry-to-disk writes use atomic writes:
Write to the temp file index.json.tmp
→ os.rename(index.json.tmp, index.json)
This prevents file corruption caused by crashes.