Skip to content
Draft
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
63 changes: 62 additions & 1 deletion docs/remote-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,9 +333,11 @@ prompts; the WebSocket extension controls sessions already owned by the TUI.
| `_nori/session/subscribe` | Optional `session_id`, optional `cursors` map | `{ "subscribed": true }`, then envelope notifications |
| `_nori/session/prompt` | Optional `session_id`, ACP `prompt` content blocks, optional `meta` | `session_id`, admitted harness `request_id` |
| `_nori/session/respond` | Optional `session_id`, permission `request_id`, ACP permission `response` | `session_id`, `answered: true` |
| `_nori/session/delegate` | Optional parent `session_id`, `agent`, `task` | Delegation record |
| `_nori/session/delegation/resolve` | Optional parent `session_id`, delegation `request_id`, `approved` boolean | Updated delegation record |

A list row reports identity, agent, status, cwd, lineage, pending request IDs,
sequence bounds (`latest_seq`, `first_available_seq`), age in seconds, and a
`source`, delegation `depth`, the parent's `delegations`, sequence bounds (`latest_seq`, `first_available_seq`), age in seconds, and a
title derived from observed session activity. `replay_cursors` includes every
conversation known to the journal. Restarting a failed session with Ctrl+R keeps
its Nori conversation ID, list row, and sequence counter. Existing subscriptions
Expand Down Expand Up @@ -370,6 +372,59 @@ method rather than a generic deserialize/serialize round trip, which can choose
an empty response variant and discard fields. The transport serializes the
original manager event without that round trip.

### Delegation

Delegation is a Nori-owned manager operation behind `multi_session`, shared by
remote controllers, the local `/delegate <agent> <task>` command, and the
session-bound `delegate` tool on Nori's authenticated HTTP `nori-client` MCP
server. This puts approval, session capacity, and child lifetime under the
same authority even when no terminal is attached. The MCP tool accepts
`agent` and `task`; its parent identity is bound by Nori to the calling session.
Agents without HTTP MCP support retain user-driven `/delegate`, but have no
stdio agent-tool fallback.

A delegation record contains `request_id`, `parent_id`, `child_id`,
`parent_agent`, `agent`, `task`, `depth`, `status`, and optional `result`.
The child conversation ID is reserved before approval. The statuses are
`awaiting_approval`, `running`, `declined`, `completed`, `failed`, and
`cancelled`; these describe delegation state, separately from session status.
`DelegationChanged` Nori events carry the record through ordered manager
envelopes. Lists expose current records for recovering approval presentation.
All-session subscribers also receive the child's normal envelopes; a
parent-only subscription does not become a subscription to every descendant.

Same-kind children start immediately. A different-kind request returns
`awaiting_approval`; resolve it for the requesting parent and request ID with
`approved: true` (Start) or `approved: false` (Decline). Only the controller may
request or resolve delegation. Unknown, resolved, or wrongly attributed
requests fail rather than starting another child. Approval does not reserve a
process indefinitely: actual startup must pass the shared session cap. Depth
is measured in delegation edges, separately from forks. Children may delegate
one level further; a request deeper than two levels below the top session
returns an error to its requester. Branch-at-head transfers parent or child
ownership to the process's new conversation without increasing delegation
depth. The same delegation request ID remains valid, while `DelegationChanged`
publishes updated attribution; pending approvals and result delivery follow
the new parent identity.

A child's ACP permission request belongs to that child's conversation and
request ID. Answer it after selecting the child, with Alt+Down in the terminal
for next waiting session. Parent notices name the child and parent rather than
opening the child's permission popup in the parent. A completed child's result
is automatically queued as attributed input to the parent, including when the
parent is busy. The manager retains delivery until that parent's correlated
ACP prompt response, rather than treating queue admission as acknowledgment.
If the parent process crashes before acknowledgment, stable-ID resume submits
the result again. A delegated child with descendants waits for them and its
result-driven continuation before reporting its final result. The child process
then ends and frees its cap slot; its record and transcript remain inspectable.

Explicit parent close cancels descendants and outstanding approvals, even if
the parent's process has already failed. Parent crash preserves running
children and holds results until stable-ID resume (Ctrl+R). Child failure
returns a failure result without failing the parent. This state belongs to the
current manager lifetime; host restart does not restore it from transcripts.

### Reconnect and retention

The manager journals at most 4096 envelopes **total across sessions** in memory.
Expand Down Expand Up @@ -518,6 +573,12 @@ highlighted failed session with the same conversation ID. The status line shows
controlling, watching/read-only, reconnecting, or disconnected state. The
current session marker appears inside TITLE, leaving its short ID visible.
Session commands address the host; they do not launch local agents.
`/delegate <agent> <task>` is the delegation entry point; the list does not
start delegations. The configured `hotkeys.next_waiting_session` binding
(Alt+Down by default) selects the next waiting child or other session.
Delegated child rows prefix TITLE with the parent identity and hierarchy marker,
so long task titles cannot displace that identity; metadata columns remain fixed
in the attached list and `nori host ls`.

Socket loss or a receive-liveness deadline starts recovery. Periodic session
list requests keep idle connections observable; receiving JSON renews the
Expand Down
1 change: 1 addition & 0 deletions nori-rs/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions nori-rs/acp-host/tests/managed_host.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,8 @@ async fn watcher_cannot_mutate_and_takeover_notifies_both_without_disconnecting(
"_nori/session/close",
"_nori/session/cancel",
"_nori/session/resume",
"_nori/session/delegate",
"_nori/session/delegation/resolve",
] {
send(&mut second, 2, method, json!({})).await;
let response = receive(&mut second).await;
Expand Down
10 changes: 9 additions & 1 deletion nori-rs/cli/src/host/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,10 @@ host ls / host stop ---------------+ SessionManager
control characters and colors status only on a terminal. Terminal rows fit
the available display cells by truncating titles with an ellipsis at grapheme
boundaries; if metadata alone exceeds the width, that prefix is clipped.
Piped output retains full titles without color or a width limit.
Piped output retains full titles without color or a width limit. Delegated
child rows retain their own ID, agent, status, and age columns; the hierarchy
marker and parent identity prefix TITLE, so long task titles do not displace
parent attribution and indentation never shifts the metadata columns.
- Lifecycle requests prepare agents using the host's configuration. Resume
uses the stored agent and working directory while retaining the conversation
identity. Stop closes the server, shuts down the manager, removes discovery
Expand All @@ -66,6 +69,11 @@ host ls / host stop ---------------+ SessionManager
- The authenticated listener exposes only `/sessions`; it does not change the
legacy interactive remote-control listener. See the
[wire contract](../../../../docs/remote-control.md#11-background-host-and-attached-terminals).
- Delegation requests pass through `ManagedRemoteHost` to the same manager
used by local sessions. Child sessions consume the ordinary session cap;
completing a child releases its process slot but preserves its listed record
and transcript. Closing a parent cancels descendants, while a parent crash
preserves children and holds their results for stable-ID resume.
- The host has no persistent manager snapshot. Transcripts survive normally,
but in-memory registry state and reconnect replay belong to the current host
process. The token rotates when that process starts again.
Expand Down
40 changes: 40 additions & 0 deletions nori-rs/cli/src/host/table.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,18 @@ pub(super) fn format_table(listing: &Value, width: Option<usize>, color: bool) -
let agent = clean("agent");
let status = clean("status").replace('_', " ");
let title = clean("title");
let title = match row["parent"].as_str() {
Some(_) if row["source"] == "session" => {
let parent = clean("parent");
let parent = if width.is_some() {
parent.chars().take(8).collect::<String>()
} else {
parent
};
format!("↳ (parent {parent}) {title}")
}
_ => title,
};
let seconds = row["age_seconds"].as_i64().unwrap_or(0).max(0);
let age = if seconds < 60 {
format!("{seconds}s")
Expand Down Expand Up @@ -123,4 +135,32 @@ mod tests {
assert!(rendered.lines().nth(1).unwrap().ends_with(title));
assert!(!rendered.contains('\u{1b}'));
}

#[test]
fn delegated_child_identity_stays_inside_title_column() {
let parent = "11111111-1111-1111-1111-111111111111";
let child = "22222222-2222-2222-2222-222222222222";
let listing = json!({"sessions":[
{"session_id":parent,"agent":"mock-model","status":"ready","title":"Parent"},
{"session_id":child,"agent":"mock-model-alt","status":"waiting_on_you","title":"Child task","parent":parent,"source":"session","depth":1}
]});
let piped = format_table(&listing, None, false);
let rows = piped.lines().collect::<Vec<_>>();
assert_eq!(&rows[2][..36], child);
assert_eq!(&rows[2][38..54], "mock-model-alt ");
assert_eq!(&rows[2][56..72], "waiting on you ");
let title = &rows[2][82..];
assert!(title.contains("Child task"));
assert!(
title.contains(parent),
"child row must identify its parent: {title}"
);
let tty = format_table(&listing, Some(110), false);
assert!(tty.lines().all(|line| line.width() <= 110));
assert!(
tty.lines().nth(2).unwrap()[82..].contains("parent 11111111"),
"normal terminal width must retain parent identity"
);
assert_eq!(&tty.lines().nth(2).unwrap()[..82], &rows[2][..82]);
}
}
92 changes: 89 additions & 3 deletions nori-rs/harness/docs.md
Original file line number Diff line number Diff line change
Expand Up @@ -501,6 +501,16 @@ task. A frontend may remain open to show the terminal state.
Explicit `shutdown()` aborts the retained reducer and relay tasks during
teardown so those session-owned tasks cannot outlive the harness.

[`HarnessHandle`](src/runtime.rs) clones share a normal-teardown marker for
their process. It is set while handling shutdown or close, retained on success,
reset on failure, and also set when the relay observes `Shutdown` or `Closed`.
Command-channel and response-channel closure use a private typed error;
`is_expected_shutdown_error` requires both that type and the normal-teardown
marker. Frontends can therefore omit late command failures caused by ordinary
child cleanup without hiding an actual backend error, even one returned before
shutdown began. Unexpected process loss does not establish normal teardown,
and a replacement process receives a fresh marker.

#### Compaction, branching, and session swap

`compact()` has two paths, chosen in `harness/src/backend/submit_and_ops.rs`. If
Expand Down Expand Up @@ -664,7 +674,7 @@ processes.
| --- | --- |
| `Starting` | Before `SessionStarted` |
| `Working` | Phase is `Prompting`, `Loading`, or `Cancelling` |
| `WaitingOnYou` | An ACP permission request is pending |
| `WaitingOnYou` | An ACP permission request or child-start approval is pending |
| `Ready` | Idle |
| `Failed` | `SessionEnded` with `ConnectionLost`, `SpawnFailed`, or `TimedOut`, or the stream closed with no end event |
| `Closed` | `SessionEnded` with `Shutdown` or `Closed`, or superseded by a fork |
Expand Down Expand Up @@ -712,8 +722,10 @@ processes.
nor managed remote list responses expose it.
Resume validates failed status, agent identity, and a readable transcript,
then reserves capacity before launching the replacement.
- **Close and shutdown.** `close(id, child_grace)` shuts one session down,
bounded by `shutdown_timeout`; closing an ended session does nothing.
- **Close and shutdown.** `close(id, child_grace)` cancels delegated descendants and shuts the
target session down, bounded by `shutdown_timeout`. An ended target has no
process to stop, but its running descendants and pending approvals still
require cancellation.
Successful close waits for the manager's event pump to observe process end
and release the capacity slot, so a caller can immediately start a
replacement. Backend shutdown and that observation share one timeout. The
Expand Down Expand Up @@ -742,6 +754,80 @@ processes.
`SessionManagerConfig` defaults to eight sessions and a five-second shutdown
timeout.

#### Delegation between managed sessions

The [session manager](src/session_manager/mod.rs) owns delegation admission,
approval, parent/child links, result delivery, and descendant lifetime. Both terminal `/delegate` commands and the session-bound `delegate`
tool on the existing authenticated
[`nori-client` HTTP MCP server](src/backend/nori_client_mcp.rs) enter this same
manager path. The tool takes an agent and task; its authority comes from the
calling session, not a caller-supplied parent ID. Delegation requires the
parent's resolved `multi_session` flag. Direct single-session launch behavior
is unchanged.

```text
parent agent -- HTTP MCP delegate --+
local /delegate -------------------+--> SessionManager
remote /session/delegate ----------+ |
different agent? -> Start / Decline
|
child ACP process
|
correlated prompt outcome
|
attributed parent prompt queue
```

- Delegation records reserve a child conversation identity before approval.
Same-kind children start immediately; different-kind children stay
`awaiting_approval` until Start or Decline. Approval is scoped to the
requesting parent and request ID. Spawn admission uses the manager's shared
session cap, including children, and is checked when a child actually starts.
- Depth counts delegation edges, separately from fork ancestry. A top-level
session may delegate to a child, which may delegate to a grandchild;
deeper requests fail with an error returned to the requester. Branch-at-head
transfers delegation ownership to the conversation now carried by that
process without adding depth. Parent links, pending start approvals, and
result correlation follow the fork; the delegation request identity remains
stable. [State reduction](src/session_manager/delegation_state.rs) republishes
the changed attribution for observers.
- `DelegationChanged` carries request, parent, and child identities, agent
names, task, depth, status, and optional result. Manager envelopes make those
changes observable through ordinary subscriptions and reconnect replay,
including events published while a failed parent has no process. Child-start
approval contributes to the parent's `WaitingOnYou` status separately from
its ACP permission request IDs.
ACP permission requests remain owned by the child conversation; permission
responses must target that child, not its parent.
- A child's final output or failure becomes an attributed prompt queued into
its parent automatically, without a review step. A busy parent consumes it
through its normal prompt queue. The [result driver](src/session_manager/delegation_driver.rs)
retains that delivery until the parent's correlated ACP `PromptResponse`,
not merely queue admission. If the process crashes first, stable-ID resume
resubmits the unacknowledged result; the parent may therefore see it again.
A child that delegates waits for its descendants and resulting continuation
turns before returning its own final result. Terminal completion stops the
child process and releases its capacity slot while retaining the registry
record and transcript.
- Explicit parent close cancels descendants and invalidates pending delegation
approvals, including when the parent has already failed. A parent process
crash leaves children running; results wait for the parent's stable-ID
resume. These held results and delegation state belong to the live manager,
not a persistent host restart snapshot. A child crash produces a failure
result without failing its parent.
- Admission and explicit close share a mutex so a start cannot escape a parent
close. Result work is claimed under manager state, then prompts and process
shutdown run outside that lock. Prompt request IDs, outstanding descendants,
and in-flight result submissions determine completion; an idle phase alone
does not prove that a delegated task has finished. The MCP authority follows
its registered process through a weak manager reference, avoiding an
ownership cycle and rejecting calls from an ended process.

Agents without HTTP MCP support can still receive children started by the
user's `/delegate`; an agent-side stdio delegation tool is not provided.
The [managed remote contract](../../docs/remote-control.md#delegation) defines
controller authorization and request payloads.

#### Browser sessions and profile tiers

`backend/browser_session.rs` (`#[cfg(unix)]`) launches a headed Chrome browser
Expand Down
5 changes: 5 additions & 0 deletions nori-rs/harness/src/backend/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,10 @@ pub fn enhance_agent_error(
pub struct AcpBackendConfig {
/// Agent name used to look up agent in registry
pub agent: String,
/// Enable manager-owned delegation for this session.
pub multi_session: bool,
/// Session/process-bound authority for the internal delegation tool.
pub delegation_context: Option<crate::session_manager::DelegationContext>,
/// Working directory for the session
pub cwd: PathBuf,
/// Approval policy for command execution
Expand Down Expand Up @@ -282,6 +286,7 @@ pub struct AcpBackend {
goal_mcp_connected: Arc<AtomicBool>,
/// Loopback HTTP server exposing the backend-owned `nori-client` MCP tools.
goal_mcp_http_server: Arc<Mutex<Option<nori_client_mcp::NoriClientServer>>>,
delegation_context: Option<crate::session_manager::DelegationContext>,
/// Goal extension capability the agent advertised at initialize, parsed
/// once. `Some` enables driving goals via `_session/goal`.
goal_ext_capability: Option<goal_ext::GoalExtCapability>,
Expand Down
Loading
Loading