Skip to content
Open
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
46 changes: 21 additions & 25 deletions docs/architecture/managed-cloud-collaboration.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,12 +23,13 @@ a link. The recipient sees that session in **Shared directly with me** without
copying a URL. Link generation remains an explicit action and always exposes a
Copy control.

An imported shared session or local Codex/Claude/Cursor history is immutable
at its source. The user may inspect and comment where cloud authorization
exists. On the first attempt to continue the conversation, ORGII asks for a
local repository/workspace with the same Git remote plus the local account and
model, then creates a writable ORGII-owned fork and sends the message there.
Cancelling the picker preserves the unsent message.
An imported shared session or local provider history remains immutable at its
source. Continuing it does not create a product-level fork or flatten history
into a prompt. ORGII rebuilds the canonical role/tool transcript in the chosen
Codex, Claude Code, or native Agent runtime, automatically reuses a valid local
workspace, and sends through the ordinary durable message queue. Compatible
runtime/account/workspace bindings retain their native UUID, so switching back
synchronizes the missing suffix instead of starting over.

## Ownership and authorization

Expand Down Expand Up @@ -64,27 +65,22 @@ deletion may remain recoverable, so the sync worker uses a distinct purge path.
Deleting a Project also deletes its child Work Items; children must never be
silently converted into standalone items by an FK default.

### Comments and owner-local agent follow-up
### Conversation, comments, and local execution

Session comments are durable cloud rows. Replies retain their thread root,
edits and deletes converge live, and status is a typed tri-state value. The
literal `@agent ` prefix is stored verbatim and rendered as a pill. It starts
work only when submitted on the original cloud session by that session's
owner; on another member's import, a read-only replay, or a writable fork it
is ordinary comment text with no suggestion, assignment, toast, or agent side
effect.

There is no cloud task/lease/claim plane. An owner submission enters the same
local queue/send path as an ordinary message and therefore uses the owner's
locally authenticated account and selected model. The backend returns a
viewer-derived ownership capability, the UI and runner both fail closed on
it, and only the owner may stamp the resulting `agent_report`. The Address
Comments action operates on an explicit selection and links agent output back
to the originating comment using the exact dispatched turn generation.
Top-level comments have exactly one scope: no event anchor means a session
note applying to the session as a whole; an event anchor means a round comment.
Address Comments groups both scopes, selects both by default, permits
scope-level selection, and carries the scope into the agent briefing.
edits and deletes converge live, and delivery is pending/sent/failed on the
same visible message. Human Team Chat comments also project into the canonical
conversation as user-role events with structured sender identity. Mentions
select a notification audience; they do not create a separate transcript.

Provider execution remains local and uses the sender's explicitly selected
local account/model. Cloud stores the multi-writer conversation events but has
no provider key, execution host, task lease, or single-run claim. The ordinary
durable queue owns local ordering and restart recovery; Cloud append
idempotency owns duplicate suppression across retries. Agent reports remain
system cards. Top-level comments have exactly one scope: no event anchor means
a session note applying to the session as a whole; an event anchor means a
round comment.

### Background upload policy

Expand Down
166 changes: 73 additions & 93 deletions docs/conversation-events-plane-design-2026-08-21.md
Original file line number Diff line number Diff line change
@@ -1,102 +1,82 @@
# Conversation Events Plane — the real fix for "it's just one session"
# Canonical conversation continuation

2026-08-21. User directive: chatting in a conversation must NOT be a fork —
forks exist only behind the explicit Fork button. This design removes the
fork machinery from implicit continuation entirely by giving conversations
their own **multi-writer event plane** on the cloud, mirroring the proven
session-comments wire.
This document records the current continuation contract. A conversation is a
single canonical event history that can be resumed by any supported native
runtime. Switching runtime is not a fork and does not flatten history into a
prompt.

## Model
## Authority and projection

- A **conversation** is keyed by `(org_id, root_session_id)` — the family
root's bare session id. It OUTLIVES the root session row (retention
expiry of the oldest segment must never mute the conversation — observed
live 2026-08-21 with ORG2_RETENTION_EXPIRED).
- The owner's own session transcript stays the base timeline (owner-only
push unchanged) — AND every owner turn is ALSO published to the plane
(user row at dispatch, agent tail at terminal, one turnId) under the
local event ids, so the plane carries every turn of the conversation and
its seq is the one total order. Clients fold plane rows onto their local
twins (owner transcript, imported replay copies) by turn-intent id for
user rows and by source event id for the rest; pre-plane history keeps
the timestamp merge.
- Any other member's turn runs on THEIR machine (sender-runs/sender-pays)
in a **local runner session** that is: created empty (external-history
fork pattern — context injected, never copied), per-session sync OFF
(never pushed as a session row), invisible in every session list.
- On turn completion the runner's new events are pushed to
`cloud_conversation_events` with the author's identity; every client
merges `owner transcript + conversation plane + discussion` into ONE
stream (the merge/attribution/rendering pipeline from the fork-stitching
work is reused verbatim — turn-plane events are normalized SessionEvents
with a `conversationSender` stamp).
- Context continuity: EVERY send (owner included) prefixes the agent
content with a rendered delta of conversation events the executing
session has not yet seen (per-runner cursor). Display text stays the
user's words; the delta rides agentContent (the projection contract from
the external-history fork path).
- `SessionEvent[]` is the provider-neutral authority for roles, completed tool
call/result pairs, images, compaction summaries, delivery state and sender
provenance.
- Codex and Claude Code histories are projections of that authority into each
provider's native role/tool transcript format.
- A target provider receives the complete verified canonical prefix as native
messages. The new user turn is delivered once through the provider's normal
send path.
- Provider-private reasoning and policy are not portable. Interrupted turns
retain the accepted user event, completed assistant output and closed tool
pairs; unresolved tool calls are not projected into another provider.
- Round-trip parsing must reproduce the same portable semantic items before a
materialization can be used.

## Cloud (migration 0024_conversation_events.sql)
## Identity and runtime switching

- Table `cloud_conversation_events(id, org_id, root_session_id,
author_user_id, turn_id, seq, event jsonb, created_at)`.
- `seq` server-assigned per conversation under
`pg_advisory_xact_lock(hash(org_id, root_session_id))` (0015 pattern).
- Event cap 64KB each, ≤200 events per push call; oversized payloads are
truncated client-side before push with a marker.
- No FK to cloud_sessions: the plane outlives the root row.
- Counters table `cloud_conversations(org_id, root_session_id, event_count,
prompt_count, last_event_at)` maintained under the same lock — feeds
listing badges without count(\*) scans.
- RPCs (definer, RPC-only posture, org-membership asserted; visibility
honors the root session's access ladder WHILE the row exists, falls back
to org-wide once it ages out; read-time retention on event created_at —
soft, Slack model):
- `cloud_push_conversation_events(p_org_id, p_root_session_id, p_turn_id,
p_events jsonb[])` → `{firstSeq, lastSeq}`; batch-append so live
streaming of a running turn is a client cadence choice, not a schema
change.
- `cloud_list_conversation_events(p_org_id, p_root_session_id,
p_after_seq, p_limit)` → ordered rows + authors.
- Signal: new kind `conversationEvents` via `nudge_org_signal` (dedicated
trigger fn, 0015 precedent) + client presence-channel broadcast
(comments-bus pattern) for sub-second delivery.
- `cloud_list_org_sessions`: additive per-row `conversationEventCount` /
`conversationPromptCount` (joined from the counters table by
root_session_id == sourceSessionId).
- `get_cloud_capabilities()` gains `conversationEvents: true` — the client
feature gate; pre-plane backends keep the fork-wire fallback.
- GDPR: export includes authored events; account deletion removes them
(cloud_session_comments precedent for personal content). Both functions
recreated from their LATEST bodies (delete: 0016, export: 0003) with
additive blocks.
- A canonical root identifies the conversation independently of any execution
episode.
- Each compatible runtime/account/workspace binding may keep its own native
UUID. Switching `Codex -> Claude Code -> Codex` synchronizes only the missing
canonical suffix and reuses the earlier Codex UUID when it is still valid.
- The normal New Session runtime/model selectors choose the next target. No
continuation-only workspace dialog or model registry exists.
- Native transcripts and the provider application catalog are published as one
lifecycle. A native-format JSONL file alone is not advertised as visible in
Codex or Claude Desktop.

## Client (ORGII)
## Delivery and concurrency

1. Protocol: `org2CloudConversationEventsClient` + per-conversation atom
(after_seq cursor, LWW merge), realtime bump on the `conversationEvents`
signal kind + broadcast bus.
2. Read: ConversationStreamProvider merges plane events (author-stamped)
after the base segments; dedup by turn against optimistic local copies.
3. Write: `conversation runner` — registry `rootSessionId → runner session`
(per device); created via the continuation setup flow (setup memory
applies, so no dialog after the first time anywhere in the org repo
scope); per-session sync forced OFF; hidden from session lists.
Turn watch = event-marker based (never bare terminal status — the
stale-reply race), then push the turn's events.
4. Send routing (capability-gated): implicit sends in any conversation
surface go to the runner+plane; the fork-before-send and tip-follow
paths remain ONLY as the fallback for pre-plane backends. The explicit
Fork button keeps real forking (a deliberate branch = a new
conversation).
5. Unread: family badge adds conversationPromptCount to the aggregate;
seen watermark unchanged (counts ride the same ratchet).
- `messageQueueAtom` is the only durable client dispatcher for ordinary sends,
imported histories, My Sessions and Team Sessions.
- A queued row owns one stable `turnIntentId` across optimistic display,
provider acceptance, restart recovery and Cloud publication.
- The existing queue FSM owns queued/preparing/accepted state, retry deadlines,
Stop/Send Now behavior and follow-up ordering. Continuation code does not add
a second wake counter, queue, footer FSM or scroll/follow implementation.
- A Web Lock only prevents two webviews on the same app instance from mutating
one canonical root concurrently. It is not a Cloud lease and does not prevent
different devices from appending independent turns to a Team Session.
- Failed outgoing messages remain visible with their original body, images and
mentions and can be retried or edited. Pre-send validation failures leave the
composer unchanged.

## Explicitly deferred
## Team Sessions and Team Chat

- Live streaming of in-flight turns to OTHER clients (plane supports it;
client pushes at turn completion in v1). The sender's own surface overlays
the runner's live events and scopes the working indicator to the runner.
- Migrating Team chat (comments) onto the same plane.
- Backfilling legacy fork families into planes (they keep the stitched
read path indefinitely).
- Cloud stores the shared canonical event plane and assigns a monotonic
per-conversation sequence under the existing advisory lock.
- Push idempotency is `(org, root, turnIntentId, event.id)`. The Cloud never
receives a user's provider key and does not execute a native runtime.
- Human Team Chat comments are canonical user-role events with structured
sender provenance. `@member` and `@all` determine human notification audience;
they do not create a second transcript.
- Agent reports remain non-portable system cards.
- The event plane is deliberately multi-writer. It does not introduce a global
single-provider-turn lease across devices.

## Context exhaustion

- Compaction is triggered only after the provider reports context exhaustion.
- If the accepted attempt has no replay-unsafe tool or assistant side effects,
the provider may use its native compact/rollover capability.
- Otherwise ORG2 creates a fresh native episode from the structured canonical
role/tool list and retries the accepted user turn once. It never works around
exhaustion by embedding the transcript in one user prompt.
- The new native UUID remains attached to the same canonical root.

## Surface adapters

- My Session, imported history and Team Session surfaces provide only root
identity, event loading/publication and target selection.
- Work Item comments may trigger this mechanism in the future, but Work Item
code must remain a thin adapter and cannot own continuation, queue or provider
materialization semantics.
13 changes: 13 additions & 0 deletions scripts/tauri/open-instance.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

const { spawn, spawnSync } = require("child_process");
const fs = require("fs");
const os = require("os");
const path = require("path");

const { createInstanceProfile } = require("./instance-profile.cjs");
Expand All @@ -27,17 +28,27 @@ const appPath = path.resolve(
);
const dataHome = path.resolve(optionValue("--data-home") ?? profile.dataHome);
const externalHistoryHome = path.join(dataHome, "external-history-home");
// Keep discovery isolated between ORG2 identities, but publish newly-created
// provider-native conversations to the profile read by the real Codex/Claude
// apps. Tests remain isolated because their launchers do not set this override.
const nativeTranscriptHome = path.resolve(
optionValue("--native-transcript-home") ??
process.env.ORGII_NATIVE_TRANSCRIPT_HOME ??
os.homedir()
);

if (!fs.existsSync(appPath)) {
console.error(`Instance app not found: ${appPath}`);
process.exit(1);
}
fs.mkdirSync(dataHome, { recursive: true });
fs.mkdirSync(externalHistoryHome, { recursive: true });
fs.mkdirSync(nativeTranscriptHome, { recursive: true });

const instanceEnv = {
ORGII_HOME: dataHome,
ORGII_EXTERNAL_HISTORY_HOME: externalHistoryHome,
ORGII_NATIVE_TRANSCRIPT_HOME: nativeTranscriptHome,
ORGII_IDE_SERVER_PORT: String(profile.ideServerPort),
ORGII_CLI_PROXY_PORT: String(profile.cliProxyPort),
ORGII_DEEP_LINK_SCHEME: profile.authDeepLinkScheme,
Expand All @@ -56,6 +67,7 @@ if (process.platform === "win32") {
`[instance ${profile.id}] started ${appPath}\n` +
` ORGII_HOME=${dataHome}\n` +
` External history home=${externalHistoryHome}\n` +
` Native transcript home=${nativeTranscriptHome}\n` +
` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}`
);
process.exit(0);
Expand All @@ -73,5 +85,6 @@ console.log(
`[instance ${profile.id}] opened ${appPath}\n` +
` ORGII_HOME=${dataHome}\n` +
` External history home=${externalHistoryHome}\n` +
` Native transcript home=${nativeTranscriptHome}\n` +
` IDE server=${profile.ideServerPort}, CLI proxy=${profile.cliProxyPort}`
);
1 change: 1 addition & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ tauri-plugin-single-instance = { version = "2.0.0", features = ["deep-link"] }
windows = { version = "0.61", features = [
"Win32_Foundation",
"Win32_Graphics_Dwm",
"Win32_Storage_FileSystem",
] }

[target.'cfg(target_os = "macos")'.dependencies]
Expand Down
56 changes: 54 additions & 2 deletions src-tauri/crates/agent-cli/src/session_provenance/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -260,10 +260,20 @@ fn update_platform(
_ => {}
}
let path = platform.config_path();
update_json_platform_at_path(platform, &path, enabled, live_status, executable)
}

fn update_json_platform_at_path(
platform: SessionProvenanceHookPlatform,
path: &Path,
enabled: bool,
live_status: bool,
executable: &Path,
) -> Result<(), String> {
if !enabled && !path.exists() {
return Ok(());
}
let mut config = read_config(&path)?;
let mut config = read_config(path)?;
let original_config = config.clone();
let (unix_command, windows_command) = hook_commands(executable, platform.source_arg());
match platform {
Expand Down Expand Up @@ -358,7 +368,49 @@ fn update_platform(
if config == original_config {
Ok(())
} else {
write_config(&path, &config)
write_config(path, &config)
}
}

/// Materialize only ORGII-managed provenance hooks inside an isolated CLI
/// profile. Managed Cursor/Claude/Codex sessions override their normal config
/// roots; without this projection the globally enabled hooks are invisible to
/// the child.
///
/// `config_path` is the provider's config JSON. Existing user settings are
/// preserved by the same merge functions used by the global installer.
pub fn materialize_hooks_for_isolated_profile(
platform: SessionProvenanceHookPlatform,
config_path: &Path,
) -> Result<(), String> {
let _guard = operation_guard()?;
let preferences = read_preferences().unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"[SessionProvenance] Unreadable preferences while projecting isolated profile; using defaults"
);
HookPreferences::default()
});
let enabled = preferences.effective_enabled(platform);
let executable = std::env::current_exe()
.map_err(|err| format!("Failed to locate ORG2 executable: {err}"))?;
match platform {
SessionProvenanceHookPlatform::ClaudeCode
| SessionProvenanceHookPlatform::Codex
| SessionProvenanceHookPlatform::Cursor
| SessionProvenanceHookPlatform::QwenCode
| SessionProvenanceHookPlatform::FactoryDroid
| SessionProvenanceHookPlatform::Trae
| SessionProvenanceHookPlatform::Windsurf => update_json_platform_at_path(
platform,
config_path,
enabled,
preferences.live_status_enabled,
&executable,
),
_ => Err(format!(
"{platform:?} isolated-profile projection is not a supported JSON target"
)),
}
}

Expand Down
Loading
Loading