Runtime Local Work
Wework runtime local work surfaces Codex and Claude Code work that already exists on a user's device. It does not import that work into central TaskResource or Subtask rows, and it no longer depends on the Backend projects table to build the sidebar list. The list comes from runtime threads returned by online device executors and is shown in two groups:
Project
LocalTask
Conversation
LocalTask
Ownershipβ
- Project is a display group inferred from the runtime workspace information returned by the executor.
- Conversation is a Codex chat thread that does not belong to a Project workspace.
- LocalTask is executor-local state and remains on the device.
- A Project's runtime identity is a workspace key derived from
deviceId + workspacePath, not a centralprojects.id. Wework may create temporary UI ids inside components, but those ids must not be written back to Backend or placed in URLs as project identity. - A LocalTask's stable identity is
deviceId + localTaskId.workspacePathis only device-workspace context for list grouping, task creation, and right-side workspace tools; task URLs, IM notification subscriptions, and native Codex update deduplication do not use the path as an identity field. - The executor returns
workspaceKindto distinguish Project work from Conversations. Codex App-style directories such as~/Documents/Codex/YYYY-MM-DD/<name>are marked aschatand shown under "Conversations"; other workspaces are shown under "Projects".
Rust executor keeps a device-side JSON LocalTask index for runtime work:
$WEGENT_EXECUTOR_HOME/runtime-work/index.json
Codex tasks are discovered and controlled through the codex app-server --stdio JSON-RPC protocol. The executor stores the Wegent localTaskId, workspace, title, status, and real Codex threadId mapping in its local index so app-mode task creation can recover after a restart. The full transcript remains authoritative in Codex app-server thread/turns/list plus thread/items/list responses and is not synced to the central database.
localTaskId is the Wegent-side local task identity, not the provider runtime's session id. When the frontend, Backend, and executor need to pass provider session identity, they must use the opaque runtimeHandle, such as a Codex threadId, Claude Code sessionId, or OpenCode sessionId, or an explicit providerSessionId. runtime.tasks.transcript must not treat localTaskId as a provider session id when there is no LocalTask index mapping and no runtimeHandle; optimistic tasks that are still being created should return an empty local transcript until create/link completes.
My Tasks association and lifecycleβ
After a non-ephemeral LocalTask is created and accepted by the executor scheduler, the executor associates it with the local system project My Tasks in the background. runtime.tasks.create does not wait for this association: the runtime task returns and starts first, then the executor idempotently creates the Issue and system binding in one SQLite IMMEDIATE transaction. Repeated association attempts must reuse the same binding and must not create duplicate Issues.
The executor exclusively owns automatic writes for the default My Tasks association. The Wework frontend only reads the binding created by the executor and must not call todos.create, todos.bind, or implement a second automatic association path for the default project. An explicitly selected project or existing Issue may still have a separate user binding; system and user bindings have distinct types and may coexist.
The executor is also the only source that projects Issue lifecycle status. After the background association commits, the executor reads the LocalTask's current state and reuses the shared runtime-status projection for queued, running, terminal, and archived states. Because a task may finish while its binding is being created, the association worker must not reuse the state captured at task creation, and the frontend must not write compensating in-progress or review states. Ephemeral tasks do not create My Tasks Issues.
List Refreshβ
Wework requests the task list on startup, explicit refresh, or device-state changes. It no longer refreshes the list through a fixed polling interval.
- Wework requests
GET /api/runtime-work. - Backend reads the current user's online devices and calls
runtime.tasks.listover each device WebSocket RPC channel. - The executor refreshes local Codex threads through persistent Codex app-server
thread/listcalls and merges the device-side JSON LocalTask index. - The executor returns
workspaceKind, workspace path, task title, update time, and device status. - Backend performs light aggregation and returns the result to Wework without reading or matching the Backend
projectstable. - Wework renders Projects and Conversations from the runtime work response, while each LocalTask is still opened and notified by
deviceId + localTaskId.
Selecting or opening a LocalTask is read-only. When Wework calls read APIs such
as runtime.tasks.transcript or runtime.tasks.goal.get to restore pane state,
the executor may hydrate local cache fields but must not change the LocalTask
updatedAt. Only operations that change task content or lifecycle, such as
sending a message, renaming, or an actual goal status transition, may advance
task activity time. Otherwise, merely viewing a task would change list ordering
and the displayed update time.
The runtime.tasks.list response has two workspace levels. The outer workspace is the sidebar Project grouping, so Codex git worktree tasks should be grouped under their shared repository root. The inner LocalTask is the actual execution directory and must keep its own workspacePath. When that directory is a git worktree, the LocalTask must carry workspaceKind: worktree and worktreeId; the sidebar worktree icon, bottom terminal cwd, and right-side workspace tools all derive from LocalTask fields. A worktree LocalTask must not turn the parent workspace into a worktree, and the LocalTask path must not be overwritten with the parent workspace path.
The executor does not poll or push task lists to Backend by itself. Offline devices do not contribute LocalTasks. Wework may show an offline mapped workspace, but it does not keep a central cache of local tasks.
When there is only one device, Wework does not show an IP next to Project names. When there are multiple devices, the local device still omits the IP, while online remote devices show a usable non-loopback runtime transfer host or client IP with a green online dot. Remote project and remote host pickers also use that IP/host as the primary display label; the device id is only a technical fallback when no network address is available.
Searchβ
Wework searches device-local work with:
POST /api/runtime-work/search
Backend fans out runtime.tasks.search only to the current user's online or busy devices. It does not read central TaskResource, Subtask, or cached history rows. The executor searches local task titles and transcripts, then returns snippets, message metadata, update time, device name, workspace path, and the transient task address. For Codex history threads, the transient task address must include runtimeHandle.threadId so opening a search result can load the original conversation through the transcript RPC without depending on a matching link already existing in the local task index.
Search results are merged by updatedAt descending and capped by the request limit. includeArchived is passed to the executor so it can decide whether archived LocalTasks are included. When the request includes projectId, Backend derives the Project from each workspace path and only returns results under that Project; workspaceKind: chat Conversation results have no Project owner.
The frontend search dialog opens the runtime address from the result, then restores workspace context from the latest runtime work list. The dialog only keeps recent query results in memory to avoid repeating the same RPC while the dialog is open; cached results are not written to Backend and do not replace executor-side transcript reads.
Cloud Device Attachment Transportβ
Wework cannot pass a desktop-local file path directly to a cloud device. When creating a task, continuing a conversation, guiding, interrupting and sending, or editing a message, Wework first uploads local files through the Backend attachment API when the target is a cloud/remote device. It then places the positive attachment IDs and attachment metadata without local paths in the device RPC. Local/app devices continue to use host paths directly and do not perform this cloud promotion.
Before starting a Codex turn, the cloud executor downloads attachments that do not have a local_path through the authenticated Backend executor-download endpoint and merges the device-local paths back into the execution request. Attachments that already have device-local paths are not downloaded again. Download failures use the existing failed-attachment handling and never fall back to desktop paths.
Image handling after download depends on the selected model:
- Native multimodal models receive the downloaded image input.
- When a text-only primary model has a vision sidecar, the sidecar receives the image and produces a description, while the primary model receives only that description. Repeated images may use the executor-side vision-description cache.
The desktop cloud-vision E2E scenario uses a real remote Project and device RPC. It covers the first sidecar request, a sidecar cache hit, and native multimodal image input so a local-executor flow cannot be mistaken for cloud-device validation.
Open And Continueβ
When a user opens a LocalTask, Wework calls Backend:
POST /api/runtime-work/transcript
Backend forwards deviceId + localTaskId to the owning device with runtime.tasks.transcript. Native Codex tasks are located through their Codex session path or session-file discovery. Non-Codex/imported tasks may use workspacePath as a local-index lookup hint, or locate the task from the local LocalTask index by localTaskId. The executor reads the native runtime transcript and returns normalized messages.
Cloud and remote devices return runtime RPC results through two Socket.IO acknowledgement hops: executor β Backend and Backend β Wework. The executor compresses first-hop ACKs larger than 512 KiB in the existing gzip+base64+json envelope. Backend decodes them and applies service processing such as device ID projection; if the browser-facing result still exceeds 512 KiB, Backend compresses the second-hop ACK in the same envelope and Wework decodes it before resolving the caller. Local App IPC always keeps the original JSON response. Every compressed envelope must remain below the Socket.IO 1 MB limit. If it cannot, the sender returns a small runtime_rpc_response_too_large error instead of transmitting an oversized packet that disconnects the device and its heartbeat. The executor runs the potentially slow capability-sync callback in a separate task so it cannot block the Socket.IO receive loop and connection keepalive.
Codex Transcript Read Path And Performanceβ
Wework uses one primary read path for local Codex conversations so list, open, and refresh do not each implement separate transcript logic:
- The list path calls persistent Codex app-server
thread/listwithrecency_atdescending sort, thearchivedfilter, anduseStateDbOnlyto read thread metadata without scanning JSONL transcripts. The executor keeps a small-window cache for repeated list requests, invalidates it after thread-management operations or local task state changes, then merges the device-side LocalTask index. - Open and refresh first call
thread/read(includeTurns=false)for thread metadata and select the read path from its explicithistoryMode. Paginated threads use lightweight turn metadata andthread/timeline/list; ordinary v2 reads bound both item count and page bytes without hydrating an entire long turn. - Explicit legacy threads do not support
thread/items/list. Ordinary v2 reads project their canonical rollout with the existing legacy reducer and context window, checking cancellation per record on the shared worker pool. Synthetic IDs, rollback, and terminal state remain intact. Pages retain at most 100 bodies and 2 MiB, allowing one indivisible oversized item alone. Metadata is still scanned to EOF without accumulating all historical bodies. - Native and legacy pages return opaque cursors belonging to their selected read path. The frontend passes them back unchanged without deriving global message positions. The executor reconciles active snapshots by item identity, then produces messages, tool blocks, thinking blocks, and file changes through shared normalization. Full export remains explicit.
- Unsupported-API errors never trigger a read-path switch. A selected native API or legacy rollout failure returns an error without falling back to cached bodies or
thread/read(includeTurns=true).
The list, read, and thread-management paths share one persistent Codex app-server connection, avoiding per-RPC child process startup. New threads explicitly set historyMode=paginated; the Codex version bundled with Wework must support thread/turns/list and thread/items/list, otherwise it is an unsupported runtime version.
The local-device proxy is process-level configuration for this shared Codex app-server, not per-task configuration. After the local executor starts, Wework synchronizes the current proxy through runtime.codex.runtime_config.update before plugin listing, model listing, rate-limit reads, or task RPCs can start the app-server for the first time. Whichever RPC triggers startup must launch the single app-server with that proxy environment, and later tasks reuse the same process. When the user changes or disables the proxy, Wework includes the new configuration in the app-server restart request. An auxiliary RPC without task context must never start the shared process with a default environment that bypasses the configured proxy.
Transcript pages are merged only by stable message ID. Provider cursors do not encode global message offsets, so the frontend must not derive messageIndex or missing ranges from them. Complete turn navigation separately requests turn IDs, order, and prompt/response summaries through thread/turns/list(itemsView=summary) without loading historical tool output. Summaries retain the original requestβs userMessageId so guidance-only pages cannot replace its marker; replay summaries prefer explicitly final assistant messages. Navigation associates loaded messages by turn ID; turn ordinals and page-local message indexes must not be matched. Assistant-only pages highlight the marker owning the response's turn ID, and guidance follows its original request in message order. Unloaded markers retain summaries for hover previews. After loading a navigation target, request its virtual row before waiting for the DOM anchor to scroll and settle the loading state.
When a user continues a LocalTask, Wework calls:
POST /api/runtime-work/send
Backend forwards runtime.tasks.send. The executor resumes the runtime session from the local LocalTask's opaque runtime handle. Codex tasks use the saved threadId to call app-server thread/resume, then send the new turn with turn/start. Messages and status come from the Codex thread transcript; the executor JSON index stores task-link metadata only. Streaming Responses events carry the current LocalTask local_task_id, the turn task_id, and subtask_id; the Wework entry layer maps the local task into the unified task identity and treats subtask_id as the turn identity for the message reducer, without a separate message_id. These events do not carry workspacePath.
Editing the last completed user message uses replacement-turn semantics. Wework generates a new stable clientUserMessageId and sends the original provider turn ID as retrySourceTurnId. The executor does not call the deprecated thread/rollback, which paginated threads do not support. It resumes the thread to restore event subscription, starts the replacement turn, and records the old turn in runtimeHandle.supersededTranscriptTurnIds. The live UI replaces the old turn immediately, and paginated transcript reads filter it so refreshing or reopening the task does not restore the old prompt and response.
Before turn/start, the executor starts a watchdog for the first meaningful progress event so a Codex turn that stalls during startup, such as MCP initialization, cannot leave Wework showing "Thinking" forever. The default timeout is 180 seconds and can be adjusted with WEGENT_CODEX_TURN_STARTUP_TIMEOUT_SECONDS. A user-input echo, an error marked for retry, or a subagent event does not count as progress. The watchdog is disabled as soon as the first assistant, reasoning, or tool item arrives, so a long-running tool that has already started is not terminated. When startup times out, the executor stops the stalled shared app-server, completes the current turn with an explicit error, and starts a new process when the user retries or sends another message. Wework must preserve the original user message and failure card, and retry the exact input associated with the failed turn instead of leaving a blank assistant message or sending an older prompt.
To diagnose a reply whose text is complete while the sidebar and composer still show a running state, correlate packaged-app logs by the same deviceId + taskId + subtaskId. Electron first records receiving and forwarding response.completed, response.failed, or response.incomplete; the local chat stream then records how many subscriptions matched the terminal event; the pane layer records whether it accepted the terminal event or dropped it because the task or device did not match; finally, Workbench Provider records dispatch of runtime_task_settled. These entries contain only runtime identities, event types, and block counts, never response content or credentials. The first missing entry identifies the boundary before which terminal-state propagation stopped.
Frontend lifecycle projectionβ
The sidebar also identifies each unique task by deviceId + taskId. When creation moves a task from its project root to a managed worktree, move the optimistic entry instead of retaining it in both workspaces. If the executor list arrives before the create response, the late optimistic projection must preserve the confirmed executor grouping and state. e2e:desktop --segment sidebar-task-identity delays a real create response to cover this ordering and verifies one sidebar row after submission and subsequent refresh.
Wework maintains one shared runtime task lifecycle state for each deviceId + taskId. The visible pane owns write access for stream events. A hidden Provider that remains mounted for layout preservation and fast switching may keep subscribing and reading, but it cannot write turnStarted, turnSettled, goal status, or executor snapshots. The sidebar, composer, and task execution overlay all read the same immutable store snapshot; they must not derive another running state from Provider-private state or a cached work list.
Task summaries from runtime.tasks.list, task creation responses, stream events, and local caches pass through the same projection rules before entering the store:
running: falsewith a non-nullcompletedAtis authoritative completion. Even if an upstream payload still carriesstatus: active, it is normalized todone,failed,cancelled, orarchived.- An optimistic
activeprojection cannot replace confirmed completion. - Only a non-optimistic snapshot with
running: true, a running thread or turn status, and a nullcompletedAtrepresents a new active turn. - Task identity uses only
deviceId + taskId; workspace paths, tab ids, and hidden layout instances cannot create a second lifecycle identity.
The completion flow is therefore singular: an executor/Backend task snapshot or terminal stream event enters the shared lifecycle store, the projection converges to idle, and every UI consumer removes its running state from the same store update.
Wework must not periodically read transcripts for running tasks to detect a missed terminal event. The normal lifecycle consumes only the stream. The resident coordinator settles the shared lifecycle store directly from a terminal event's deviceId + taskId, so completion does not depend on a mounted task pane and cannot be overwritten by a task-list read that arrives while the Executor and provider are still finishing the same turn. Only explicit stale-projection signals such as executor.event_lagged and runtime transport replacement trigger one runtime.tasks.list reconciliation. Concurrent anomaly signals share one in-flight request, and signals received while it is in flight coalesce into at most one serial trailing reconciliation. Reconciliation uses the executor's persisted running, status, completedAt, threadStatus, and turnStatus fields directly; it must not derive task completion from transcripts, turn items, or rollout JSONL.
Every continuation request must carry the current model selection. The Wework model selector is the source of truth for the turn being sent: whichever model the user selects for that turn becomes the modelId, modelType, and model options in runtime.tasks.send. The executor must not restore a model from a previous request and must not cache model selection. If a request has neither a full executionRequest nor a modelId, the executor must return bad_request instead of falling back to a default model. Local IPC and remote WebSocket only transport the same canonical model selection; local devices invoking remote models and remote devices invoking remote models both enter the same executor backend execution logic. Task creation and continuation must reuse that single model selection path.
If the current LocalTask is still replying, Wework queues new user input locally instead of sending concurrent runtime.tasks.send calls. Users can remove queued messages, or choose to stop the current reply and send the queued message from the queue panel. That first calls:
POST /api/runtime-work/cancel
Backend forwards deviceId + localTaskId as runtime.tasks.cancel. The Rust executor Codex app-server path currently does not interrupt an in-flight turn across processes, so app mode returns accepted: false; the frontend should keep the queued state or wait for the current turn to finish. If turn/interrupt support is added later, it must still identify the task only by deviceId + localTaskId; workspacePath remains device-directory context.
When the current Codex LocalTask is still replying, Wework can also send a queued user message as native guidance:
POST /api/runtime-work/guidance
Backend only validates the user, device, and LocalTask ownership, then forwards deviceId + localTaskId, the user text, and the frontend-generated clientGuidanceId as device RPC runtime.tasks.guidance. The executor must find the running Codex turn and append the user text through Codex app-server's native guidance capability. If there is no active turn to guide, it should return no_active_turn; the frontend then sends the same message as a regular follow-up. runtime.tasks.guidance does not create central Task or Subtask rows, and it must not use workspacePath as task identity.
The frontend must insert the local guidance user message at the current streaming assistant position immediately when guidance sending starts, not after runtime.tasks.guidance returns. That insertion splits the active assistant into a frozen "before guidance" message and a continuing "after guidance" message. The continuing assistant keeps the original subtaskId so later stream events land after the guidance message. Later chat:chunk or chat:done events may still carry full text, so the frontend trims the text prefix recorded at split time. This keeps live streaming order consistent with the refreshed transcript order.
Persisted messages for a native Codex task have one source: thread/turns/list plus thread/items/list. The executor must not merge runtimeHandle.messages or another LocalTask cache into the Provider transcript; missing persisted messages must be fixed in the Codex event-recording or paginated-read path. The frontend may maintain an unpersisted in-memory live projection. When guidance is applied in the background, it must settle the guidance queue and add the confirmed user message to the source conversation's live projection so reopening before the Provider covers the running turn does not lose the message. Once the Provider covers the same turn, the paginated transcript takes over as a whole; the live projection must neither persist nor merge into Provider transcript pages.
The live stream and Provider snapshot can represent the same assistant text differently within one turn. For example, realtime events may produce block:text while a restored legacy conversation returns assistant_text. When merging a turn, the frontend must treat items with identical content and these complementary types as one message, replacing the live representation with the snapshot representation. Merging only by item id renders the same streamed text twice after following up in an older conversation. Same-type items with identical text remain distinct by id so genuinely repeated model output is preserved. runtimeConversationTurns unit tests and the real Electron streaming-text E2E cover this boundary.
Users can also manually compact a local Codex LocalTask from the composer's context-usage control:
runtime.tasks.compact
The Wework App calls runtime.tasks.compact only through local executor IPC; there is no Backend HTTP endpoint for this action. The executor must use the LocalTask's opaque runtimeHandle.threadId to call Codex app-server thread/resume, then call thread/compact/start; it must not send /compact as a normal user message. Manual compaction uses an independent runtime subtask id, <localTaskId>-context-compact, so the UI can render the context_compaction tool block as its own completed message without settling an active assistant turn.
If the current pane is still replying, Wework should block manual compaction and ask the user to wait for the current reply to finish. Automatic Codex context compaction remains part of the current turn's subtask; the frontend may display the context_compaction block, but it must not emit assistant_done or settle the current reply just because that block completed.
Continuing a LocalTask may include already uploaded attachment ids that are in the ready state. Backend verifies those attachments belong to the current user and converts them into executor attachment metadata. The executor downloads and converts the files on the target device before passing them to the runtime. The frontend never sends local attachment paths directly to Backend or executor.
Archived Conversationsβ
Archived conversations are also device-side state. Backend only validates the user, device, and workspace, then dispatches the request to the target executor. It does not read or write TaskResource.STATE_ARCHIVED, and it does not call the central /tasks/archived flow. Wework builds the archived list only from runtime Projects and Conversations, so the page does not show data outside the current Codex Lite sidebar scope.
The archive HTTP APIs are:
POST /api/runtime-work/archived-conversations/list
POST /api/runtime-work/archived-conversations/archive
POST /api/runtime-work/archived-conversations/archive-project
POST /api/runtime-work/archived-conversations/archive-all
POST /api/runtime-work/archived-conversations/unarchive
POST /api/runtime-work/archived-conversations/delete
POST /api/runtime-work/archived-conversations/delete-bulk
POST /api/runtime-work/archived-conversations/cleanup-preview
POST /api/runtime-work/archived-conversations/cleanup
For native Codex conversations, the executor archives, unarchives, and deletes through app-server thread/archive, thread/unarchive, and thread/delete; renaming uses thread/name/set. Archived lists come from the state DB threads.archived filter and are merged with the JSON LocalTask index. List responses normalize id, localTaskId, threadId, title, Project name, workspace path, device, source, and timestamp fields, and include grouped Project counts. Immediately after archive or unarchive, the local override from the device-side LocalTask index participates in the list if the state DB has not caught up yet, so the UI does not briefly lose the item. Project grouping must use the Project root or groupWorkspacePath; different worktrees under the same Project must not appear as separate Projects.
If Codex thread/list returns a dirty state DB thread whose rollout file can no longer be located, thread/archive returns no rollout found for thread id .... The executor treats only that explicit error as cleanup: it calls Codex thread/delete to remove the leftover thread record and writes a local deleted marker with TTL and count limits, so later lists no longer show the unusable record. It does not pretend that dirty thread became an archived conversation.
Deleting archived conversations uses a two-phase strategy. Foreground delete and delete-bulk first write an executor-local tombstone so the item disappears from lists immediately. The actual Codex thread/delete, LocalTask index deletion, and worktree/attachment/log file cleanup run one item at a time in an executor background worker. The worker must wait for the current app-server thread/delete to finish before starting the next one. If a delete is slow, it records a slow-operation log instead of using a client-side timeout to stack more thread/delete calls; otherwise Codex thread store pressure can make archived list refreshes wait for a long time. The frontend submits bulk deletes in small batches and stores progress outside the page component, so leaving and re-entering Settings still shows the current delete progress.
cleanup-preview and cleanup only target leftover files for archived LocalTasks, including executor-managed Git worktree directories, LocalTask records, session logs, local attachments recorded in the runtime handle, and local attachment draft paths. Cleanup targets must be derived from the archived item's deviceId + workspacePath + localTaskId + threadId/runtimeHandle and pass path-safety checks. The executor may delete only files under managed executor directories, standalone chat directories, or local attachment draft directories. It must not clean regular Project roots, unarchived conversations, running tasks, or archived items that the frontend did not submit.
When the archived LocalTask uses an Executor-managed Git worktree, Wework calls the device-scoped runtime.worktrees.delete with the LocalTask's own workspacePath after archive succeeds. Even when the sidebar groups the task under the Project root, that grouping path must not become the deletion target. The Executor writes tracked, staged, unstaged, and non-ignored untracked files to a hidden refs/wegent/worktree-snapshots/* reference before Git removes the worktree, then removes the now-empty outer <worktreeId> directory. A snapshot failure must preserve the directory and return an error; uncommitted changes must not be discarded. When a task is unarchived or receives another message after its directory was cleaned, the Executor restores the original path from the snapshot reference. This lifecycle applies only to runtime LocalTask worktrees and does not mutate the Project root workspace. A LocalTask created by forking a message may share its source task's managed worktree; deleting one archived task must clean only that task's attachments while another LocalTask still references the path.
Worktree settings and lifecycleβ
Worktree settings are device-scoped state persisted in $WEGENT_EXECUTOR_HOME/runtime-work/worktrees.json; they do not belong in browser preferences or Backend user settings. An empty configured root resolves to the current Executor workspace worktrees directory. Automatic cleanup is enabled by default and retains 15 worktrees. Changing the root affects only future worktrees. Previous roots remain in knownRoots so existing worktrees can still be listed, restored, and cleaned safely.
Wework manages worktrees through device-scoped runtime.worktrees.settings.get/update and runtime.worktrees.prepare/list/delete/restore/prune RPCs. Creation targets are fixed to <resolvedRoot>/<worktreeId>/<repositoryName>. Lists are grouped by repository and include linked LocalTasks. The creation ID owns the path, snapshot, and lifecycle metadata, while the execution lease records the LocalTask that is actually running. This lets a fork continue in an idle source worktree while preventing two tasks from writing to the same worktree concurrently; after an Executor restart, the lease task is the task marked as interrupted. Deletion archives linked tasks and captures a snapshot first. Archived-file cleanup validates paths through the Worktree Manager's current root and knownRoots instead of relying on fixed legacy directory names; scanning known roots also removes empty <worktreeId> directories left by older versions. Automatic cleanup runs after creation and settings updates and only removes least-recently-used worktrees above the retention limit when they are explicitly linked exclusively to archived tasks. Worktrees without a task record in the current Executor are never auto-cleaned. Continuing a cleaned task restores it on demand. Isolated Executor instances derive their default worktree directory from their own WEGENT_EXECUTOR_HOME, preventing test or development instances from managing production worktrees.
The Project actions menu can create a permanent worktree from the current Git workspace HEAD and immediately register the new directory as a separate Project. These requests pass permanent: true through runtime.worktrees.prepare; the Executor persists the flag in worktrees.json, and automatic cleanup candidate selection must exclude permanent worktrees. Permanent means the worktree is not removed automatically because linked tasks were archived or the retention limit was exceeded; users can still delete it explicitly through Project removal or worktree management.
In packaged Wework App local-first mode, selecting, dropping, or pasting an existing local file sets the attachment's local_path to its source path without creating a copy. Later reads use the file at that path; moving or deleting the source can make it unavailable. Removing an attachment does not delete its source file. Screenshots and pasted content without a source path are still saved under $WEGENT_EXECUTOR_HOME/workspace/attachments/draft. Local execution receives local attachments through executor IPC. Cloud and remote execution still upload attachments before sending, use Backend attachmentIds, and remove local paths from the request.
Local references and temporary attachments have no fixed total file size limit. Content without a source path is written locally in bounded chunks, retaining size, offset, and file integrity validation; disk write failures are reported directly. The cloud attachment API owns cloud upload limits. Attachment errors are isolated by composer scope and can be dismissed individually without clearing drafts or valid attachments. Reselecting the same filename clears its previous error, and delayed failures from earlier attempts cannot overwrite the new attempt's state.
Images use a browser object URL as soon as they enter the attachment area and release it when the attachment is sent, removed, or the composer is reset. Image attachments keep local_preview_url so sent messages can preview them through the desktop local-file capability, and Codex receives the same path as a localImage input. Text-like local attachments are not injected in full; the executor adds only a bounded preview of the first 10 lines or 4 KiB, whichever comes first, plus the Local File Path so Codex can read the full file when needed. Wework keeps text_length and text_preview on local attachment metadata so refreshes can still render compact text-preview attachments. When Wework is connected to Backend and uses uploaded attachments, persisted attachment ids remain the source of truth after refresh.
In the Electron app, clicking a text attachment before or after sending opens the full original file in the read-only attachment preview panel. Workspace and global external-app preferences do not affect this preview, and failed reads can be retried in the panel.
When rendering a message that already has persisted image attachments, Wework prefers those attachment previews and ignores local image file mentions embedded in the Codex prompt. This avoids showing both the uploaded attachment and a temporary local path. Codex local image mentions are used only as a same-device preview fallback when no attachment record exists. If the current environment cannot convert the local path through the desktop local-file URL converter, or the converted image fails to load, the frontend does not display that local path.
When the executor discovers a user message from a native Codex session, it writes local image paths from local_images, localImages, or images into the user-visible text so refreshes still show which files the user mentioned. If those paths are readable on the current device, have an image MIME type, and are no larger than 5 MB, the executor also creates ready attachments used only for transcript rendering and stores local_preview_url as a data URL. This preview attachment is not a persisted Backend attachment, and it is not uploaded or synced to the central database.
Native Codex tasks have one additional rule: transcript refreshes trust only Codex's own session transcript. runtimeHandle.messages from a fork package or the executor JSON index is only an import-time snapshot and must not be used as a fallback for native Codex transcripts; otherwise Wework can show stale messages or lose follow-up turns after refresh. Non-SDK native tasks may still use the executor JSON index as their local transcript source.
One Codex user turn can contain an attachment wrapper, application context, and a user-message event at the same time. The executor must first extract the user-visible request text, then use it to merge duplicate messages from that turn while retaining clientUserMessageId and attachment metadata. Turn-navigation IDs prefer clientUserMessageId; previews for loaded turns must be derived from the same visible text and must not expose runtime-injected content such as # Files mentioned by the user or <application_context>.
Rich references sent by Wework, such as skill or plugin tokens, may be normalized to plain text in the Codex provider transcript. To preserve the Codex transcript as the single message source, Wework persists only each reference token and target by clientUserMessageId in LocalTask runtimeHandle.userMessagePresentations; it does not store a second user-message body. After reading the provider transcript, the executor keeps provider content as the only body and adds only the reference ranges and targets that the transcript cannot express to the message with the same clientUserMessageId. Those non-overlapping additions are returned as presentationReferences; without a stable client user message ID, the executor does not guess a match. This composition applies to running, successful, and failed tasks, and Wework combines the body and presentation references at the rendering boundary so reopening a completed task keeps skill and plugin references rendered as tokens instead of degrading to raw $plugin:skill or @Plugin text.
Assistant messages in a runtime transcript may include a fileChanges summary. The Rust executor Codex app-server path uses app-server notifications as the source for turn events. If diff notifications are connected later, runtime.tasks.create, runtime.tasks.send, and runtime.tasks.transcript must normalize them onto the message as fileChanges. This lets the frontend show the file changes card under the current assistant message without waiting for the next list refresh.
Historical transcripts consistently use subtaskId to identify an assistant message's subtask, but Backend-forwarded cloud task IDs are numbers while local executor turn IDs are strings. Wework must normalize both value types to strings at the restoration mapping boundary because tool-call blocks and file-change blocks depend on that identity and must not disappear because of its transport type.
When Wework renders a file changes card for a runtime LocalTask, it does not call the central Task API. It uses the current task's deviceId + workspacePath to execute device commands turn_file_changes_review or turn_file_changes_revert, so review and revert run in the actual device directory that produced the LocalTask. Native Codex transcripts normalize each turn diff into a complete unified patch, persist it at turn-file-changes/codex/<turnId>, and mark the card revertible only after the artifact is written successfully. The device command must still full-match the artifact id and verify workspace and patch checksum from metadata; it must not accept arbitrary paths. If the local artifact is missing or the revert conflicts, the frontend writes that status back into the current transcript message instead of leaving a stale actionable state on screen.
Workspace Tool Contextβ
After Wework opens a LocalTask, the right-side file, review, and terminal tools resolve their device and directory from the current LocalTask's device and directory context:
- The LocalTask
workspacePathreturned byruntime.tasks.listwins, so a Codex worktree is not treated as a separate Project. - If the LocalTask maps to a Project, environment info and review still receive that Project, but Git commands run in the LocalTask's actual directory.
- If the LocalTask does not map to a Project, the local terminal can still open as long as the device is online and the directory is accessible. IDE capabilities that depend on Project APIs still require Project context.
- Terminals opened for runtime LocalTasks must start a device-scoped PTY from the current LocalTask's
deviceId + workspacePathand must not fall back to the Project's default bound device; otherwise cross-device worktrees open on the wrong machine.
The bottom terminal panel state is also scoped to the current workspace-tool context. A terminal opened from LocalTask A must not be reused as LocalTask B's terminal after switching tasks; switching back to A restores A's terminal state. When no LocalTask is selected and only a local Project is selected, the terminal cwd is that Project's local path. In local App mode, missing Backend connectivity must not display a cloud-device prompt or fall back to $HOME.
Create Tasksβ
Wework creates a new runtime task with:
POST /api/runtime-work/create
Backend resolves the target device and directory from either a Project mapping or a standalone device workspace, builds a transient execution request, and calls device RPC runtime.tasks.create. This flow does not db.add() any TaskResource or Subtask.
In packaged Wework App local-first mode, task creation does not go through the Backend HTTP API. Wework builds the minimal executionRequest required by the executor inside the frontend local service from the selected deviceId + workspacePath, sends it through a Electron IPC command to the executor sidecar app IPC channel, and the executor directly runs runtime.tasks.create. The payload must include workspacePath, the user message, runtime model configuration, and local user context; if no workspace path is available, Wework must fail before calling the executor. This path still uses only the app UI and executor as local processes and does not start a local Backend.
For Project-backed task creation, Wework has only two execution workspace sources: current_workspace uses the Project root, while git_worktree calls runtime.worktrees.prepare on the target device. The path is derived from that device's Worktree settings, runtime task id, and Project directory name; the UI must not compose arbitrary target paths. A worktree create request may carry an explicit branch. When no branch is provided, the default branch must be the current Git branch of the Project root, not the Git default branch and not a HEAD label. The branch list is only a selectable display surface: the current branch should be first, and the remaining branches should preserve Git's returned order.
A temporary side conversation is an in-place fork of its parent LocalTask, not a
new workspace-planning request. Wework passes the parent task's deviceId,
workspacePath, and Codex threadId through sideSource, and the create
request must clear the Project's currently selected git_worktree strategy.
The Executor also treats sideSource as the authoritative directory binding:
even if a malformed request includes workspace_source=git_worktree, it uses
the parent workspacePath directly for thread/fork and must not create
another worktree. A side conversation therefore executes in the same directory
whether its parent thread uses the Project root or an existing worktree. If
sideSource lacks a nonblank threadId or workspacePath, or if the top-level
requested workspace conflicts with the parent workspace, the Executor rejects
the create request instead of falling back to the requested workspace, a
Project workspace, or a standalone conversation directory.
Before calling create, Wework generates a client-side localTaskId and sends it to Backend as localTaskId. Backend only forwards that value to the target device; it does not write it to the central database. The frontend immediately opens the runtime URL from deviceId + localTaskId, renders the user message, and shows the waiting state. If the device returns a different localTaskId, the frontend switches to the device-confirmed address. This lets a newly created task appear before the Backend RPC completes or the next list refresh runs, and queued sends wait until the current waiting state becomes a real assistant turn before continuing.
The runtime owns persistence for newly created tasks:
- Claude Code creates an executor JSON LocalTask and stores the transcript and runtime handle in that index.
- Codex creation first returns the Wegent-side
localTaskIdso the frontend can open the task and receive stream events immediately. After app-serverthread/startandturn/startcreate the real Codex thread, the executor writes thelocalTaskId -> threadIdmapping into the JSON LocalTask index for later send/resume calls. - Codex creation and continuation do not cache the full transcript in the executor JSON index. After an executor restart, the executor recovers task links through
thread/listplus the local index, then reads transcripts throughthread/turns/listandthread/items/list. - Codex creation still streams over the LocalTask Responses event channel with
response.created, text/tool deltas, andresponse.completed/error. Those events use thelocalTaskIdreturned by create, so the frontend does not need to wait for the next list refresh to show the running reply. - Codex app-server input supports
input_text,input_image, andlocalImageprompt blocks. Backend attachment-id download and sandbox-path rewriting remain separate from local-first attachments: local App mode sends same-device attachment records through executor IPC, while cloud/Backend paths continue to use uploaded attachment ids. - If Codex response completion includes
file_changesorfileChanges, the executor stores it on the current assistant message'sfileChangesfield, and later transcript refreshes continue to show the same file changes card. - Codex app-server
imageGenerationitems must not be treated as ordinary text tool output. The executor stores the complete image result in the tool blockrender_payload, together with the revised prompt and generated-file path, and Wework renders the image directly from that payload. Image data must bypass the regular tool-output truncation window or the base64 will be corrupted during live display or transcript restoration. view_imageonly indicates that the model inspected an existing workspace image. Wework shows that image inside the expandable tool details but must not add it to the generated-image gallery at the end of the assistant message; onlyimageGenerationresults are final image artifacts.
Project-backed creation uses a runtime workspace reference:
- Wework sends a workspace key or an explicitly selected
deviceId + workspacePath; it does not send a centralprojectId. - Backend validates the workspace against the current user's online runtime workspace list and resolves the trusted
deviceId + workspacePath. - If no project is selected, Wework uses the local device's empty workspace context for a regular conversation; that state has no remote IP and does not write
projectId=0into the URL. - Creating a blank project creates a directory under
~/Documentson the target device. If the name already exists, the frontend must ask the user to rename it instead of treating the existing directory as the project.
Empty projects are runtime-owned as well. After Wework creates or selects a directory, it calls the workspace open/register flow so the executor includes that workspace in the runtime.tasks.list project group. The project should be visible even before the directory has any LocalTask or Codex conversation. This flow does not write TaskResource, Subtask, or Backend projects rows.
Fork And Cross-Device Transferβ
When Wework forks a runtime task, it only offers target workspaces that belong to the source task's Project:
- When a message action forks into a new task, Wework passes the selected completed turn as
lastTurnIdto the executor. The executor may call Codexthread/forkwhile the source task is running a later turn;lastTurnIddefines the history boundary, and the fork must not stop, cancel, or mutate the source task's active turn. - When the executor creates the forked task, it must inherit the source task's
runtime_project_keyandruntime_workspace_roots. These fields are routing metadata for Project grouping and workspace filtering; without them, the fork succeeds but the new task is incorrectly excluded from its Project. - Other Device Workspaces already bound to that Project can be used directly.
- An online device that is not yet bound to that Project must first use the same device-directory preparation flow as project creation and editing: choose a directory on the device, then choose whether that Project path is a
worktreeor a regularworkspace. - Backend writes the Device Workspace mapping through
POST /api/runtime-work/device-workspaces/preparebefore continuing the fork. - A Device Workspace
labelcan storeworktreeorworkspace. Runtime work list responses prefer that label asworkspaceKind, so the frontend does not treat a worktree under the same Project as another Project and does not show unrelated Project or unmapped directories as fork targets. - If the Project has
gitconfiguration, Backend first verifies that the source and target workspaces have the same Git remote and that the source taskHEADcommit is reachable in the target repository. After that, the target device does not import the task into the Project root. It creates or reuses a detached Git worktree underworktrees/<transferId>/<projectDir>for the target Project workspace, then binds the forked LocalTask to that worktree path. This prevents the fork from mutating the target Project root, while list refresh can still group the worktree task under the same Project. - Git forks copy task context, Codex session state, required session files, and a lightweight Git patch generated from the public base commit. The patch covers source-workspace local commits, uncommitted tracked-file changes, and non-ignored untracked files as an overlay; it does not archive and upload the full Git repository directory to object storage. If Git requirements are not met, Backend uses the regular archive transfer path.
- If the Project is not Git-backed, the fork uses executor direct archive transfer and only falls back to object storage when direct transfer is unavailable.
- Direct archive transfer only tries the TCP peer host observed from the Backend WebSocket connection and the runtime transfer host reported by the executor. The executor validates the peer with a token probe before upload, so a NAT/proxy-reported business address is not trusted by itself. If direct transfer is unavailable and object storage is not configured, Backend returns 503 instead of silently falling into an unusable S3 path.
The forked task identity still uses deviceId + localTaskId. workspacePath is only target-directory and workspace-tool context.
Projects And Conversationsβ
Wework no longer shows "Unmapped Device Workspaces". Every thread returned by the executor must be grouped into either "Projects" or "Conversations":
- Tasks with
workspaceKind: chatare shown under "Conversations". - Other tasks are grouped as "Projects" by workspace name.
- The "Conversations" section is always visible, even when empty, and supports the same collapse and expand interaction as "Projects".
IM Notificationsβ
Runtime tasks can send notifications to IM sessions, but notification state is keyed by deviceId + localTaskId; no DB Task is created, and workspacePath is not part of the notification key.
- In IM,
/notify onenables the current user's global runtime task notification target for the current IM session. /notify offdisables global notifications, and/notify statusreports the current state.- A single IM session can subscribe to one runtime task and receive only that task's updates.
- The executor sends
runtime:eventover the device WebSocket. Backend delivers onlyresponse.completed,response.failed,response.incomplete, anderrorterminal updates according to active task bindings, task subscriptions, and global notification settings. Successful terminal events without reply content are not sent. - Notification identity uses
deviceId + localTaskIdand excludesworkspacePath. IM-originated turns do not echo another notification to the same IM, while the legacyruntime.tasks.updatedreceiver remains only for older executors. - Backend stores Redis delivery receipts by user, device task, turn and terminal status, and recipient IM session. Successfully delivered notifications are suppressed when events replay after desktop restarts or device reconnects. Concurrent delivery uses a short-lived lock; successful receipts do not expire. Failures release the lock, and partial failures retry only recipients without a successful receipt. Legacy watcher updates use
updatedAt; events without a turn ID or update timestamp cannot be reliably deduplicated because distinct turns may return identical content.
URLβ
Wework runtime task URLs use:
/runtime-tasks?deviceId=<device>&localTaskId=<local-task>
The URL does not contain workspacePath. On refresh or shared links, the frontend opens the task from deviceId + localTaskId and then restores its workspace context from the latest runtime work list.
New conversation and no-project entry points use the root path or regular conversation path, not placeholder parameters such as projectId=0. Project selection state is restored from the runtime workspace reference and the current conversation context.
New-task send state and diagnosticsβ
After sending, the frontend displays the user message and loads history while the executor may still be preparing a worktree, before registering the LocalTask. When no task link, provider session, or known execution exists, an empty transcript must omit running to represent unknown state. Returning running=false would prematurely settle the frontend send. Known running and completed states still use explicit booleans.
Explicit sends update the shared RuntimeTaskLifecycleStore directly. Switching tabs or hiding the initiating view must not block sendRequested, sendAccepted, or send-failure updates. Background list and transcript synchronization remain subject to view ownership.
Hidden workbenches kept mounted must not subscribe to global project creation, workspace binding, or cloud device settings events. Only the routeActive workbench handles these interactions, preventing duplicate portal dialogs after switching tabs. When a known task fails before creating its provider session, its transcript must still return running=false so clients can settle the pending state.
Electron startup readiness must be idempotent: after startup completes, notifications from navigation or remounting must not show and focus the main window again, stealing focus from a popout. Concurrent notifications share one completion operation; a failed operation can be attempted again.
The popout chat window uses one current conversation to drive messages, its title, and native window dimensions, without restoring the main workbench's split layout. Its modes are composer, menu, and conversation. Opening a menu preserves the composer's bottom position; sending switches to conversation dimensions. The toolbar remains available in compact composer mode.
The popout handles unmodified Esc at the native keyboard event layer, hiding the window while retaining its conversation; IME composition is excluded. Invoking the popout shortcut must not restore the main window. Explicit Dock, tray, or βOpen in main windowβ actions may open it; ordinary application focus activation does not restore it.
When a page does not recover after reload, inspect Electron's [renderer-load] events. Correlate loading, DOM readiness, completion, failure, and prevented unload events by webContentsId. These events omit page URLs to avoid exposing query parameters.
When restoring a local task's project-space context, use the project ID in its binding to read the item without scanning the project list again. The project-space-context-resolved/failed diagnostics record lookup duration and whether the current view applied the result, distinguishing lookup timeouts from stale responses.
To investigate a blank interval after sending, correlate deviceId + taskId between runtime-launch.log in the Electron log directory and executor logs. The former records transcript receipt, lifecycle transitions, waiting-indicator state, and web_contents_id; executor runtime worktree stage entries measure lock waits, preflight, Git worktree creation, and persistence. A backend running=true response does not prove that the frontend rendered a waiting indicator: compare both timelines. Message bodies are not required for these diagnostics. Changes to Electron log capture or the executor require restarting the corresponding process; frontend hot reload does not update either process.
Compatibilityβ
Wegent-native Task/Subtask flows remain available for existing chat, shared task, and historical task URL paths. Wework sidebar, mobile drawer, project task display, and new task creation use the runtime work API instead of the DB task list or Backend projects table.