Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@
does. Late in a minute, `check_limits` could say a limit resets "14:00
(in 1h 59m)" above a line that says "14:00 (in 2h)", and `statusline`
could say "out in 59m" while `status` said "13:00 (in 1h)".
- **The app** says it's reading your agents' history until the first read
is done, also when it connects while that read runs. It stopped saying so
once the read had begun.

## 0.1.4

Expand Down
4 changes: 2 additions & 2 deletions contract/error.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,10 @@
"code": -32001,
"data": {
"supported": [
1
2
]
},
"message": "this turnscope speaks protocol 1, not 2"
"message": "this turnscope speaks protocol 2, not 3"
},
"id": 1,
"jsonrpc": "2.0"
Expand Down
2 changes: 1 addition & 1 deletion contract/hello.request.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,6 @@
"jsonrpc": "2.0",
"method": "hello",
"params": {
"protocol": 1
"protocol": 2
}
}
2 changes: 1 addition & 1 deletion contract/hello.result.json
Original file line number Diff line number Diff line change
Expand Up @@ -436,7 +436,7 @@
}
],
"at": 1791460800000,
"historyReadAt": 1791460740000,
"historyRead": true,
"settings": {
"notify": {
"reset": true,
Expand Down
2 changes: 1 addition & 1 deletion contract/status.notification.json
Original file line number Diff line number Diff line change
Expand Up @@ -369,7 +369,7 @@
}
],
"at": 1791460800000,
"historyReadAt": 1791460740000,
"historyRead": true,
"settings": {
"notify": {
"reset": true,
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ directory. Times are UTC milliseconds and money is dollars. The schema is
| `sign_in` | What each agent folder held for each provider, from when: the account, and an API key's fingerprint |
| `alert` | Each alert raised, what it says, and whether a client acknowledged it |
| `price` | models.dev's prices for each model, each from when it was read |
| `state` | One row: the reader version the history was read with, when history, limits and prices were last read, and the settings |
| `state` | One row: the reader version the history was read with, when its first read finished, when limits and prices were last read, and the settings |

`db::open` creates or migrates the database. Each revision is one script: a
shipped script is never edited, and a change is a new script at the end.
Expand Down
12 changes: 6 additions & 6 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ Try it by hand, with the app running or after `turnscope serve &`:

```sh
nc -U "$(turnscope serve --socket)"
{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":1}}
{"jsonrpc":"2.0","id":1,"method":"hello","params":{"protocol":2}}
```

## Lifecycle
Expand Down Expand Up @@ -78,7 +78,7 @@ sync. Until `hello` succeeds, any other request but `shutdown` gets
`-32002`.

```json
{"id":1,"jsonrpc":"2.0","method":"hello","params":{"protocol":1}}
{"id":1,"jsonrpc":"2.0","method":"hello","params":{"protocol":2}}
```

```json
Expand All @@ -88,7 +88,7 @@ sync. Until `hello` succeeds, any other request but `shutdown` gets
A protocol the server doesn't speak gets `-32001` (`error.json`):

```json
{"error":{"code":-32001,"data":{"supported":[1]},"message":"this turnscope speaks protocol 1, not 2"},"id":1,"jsonrpc":"2.0"}
{"error":{"code":-32001,"data":{"supported":[2]},"message":"this turnscope speaks protocol 2, not 3"},"id":1,"jsonrpc":"2.0"}
```

## Requests
Expand Down Expand Up @@ -165,7 +165,7 @@ take a message within 2 seconds is dropped.
| `-32601` | No such method | `no method <method>` |
| `-32602` | Parameters that don't fit, or an unknown agent | `parameters that don't fit: <why>`, `no agent is <id>` |
| `-32000` | The engine couldn't do it | Why, such as `no limit <key> of <account>` |
| `-32001` | Unsupported protocol. `data.supported` lists those spoken | `this turnscope speaks protocol 1, not <n>` |
| `-32001` | Unsupported protocol. `data.supported` lists those spoken | `this turnscope speaks protocol 2, not <n>` |
| `-32002` | A request before `hello` | `send hello first` |

## Conventions
Expand All @@ -192,7 +192,7 @@ and MCP server use those.
| Field | Type | Holds |
|---|---|---|
| `at` | ms | The minute it was worked out for |
| `historyReadAt` | ms or null | When the agents' history was last read. Null until the first read finishes |
| `historyRead` | bool | Whether the first read of the agents' history finished |
| `accounts` | [Account] | Every account, hidden ones too, in the order to list them |
| `agents` | [Agent] | Every agent Turnscope reads, installed or not |
| `settings` | Settings | The user's settings |
Expand Down Expand Up @@ -447,7 +447,7 @@ sent, not when it was raised.

## Versioning

`protocol` is an integer, now `1`: `PROTOCOL` in `src/serve.rs` and
`protocol` is an integer, now `2`: `PROTOCOL` in `src/serve.rs` and
`protocolVersion` in `Contract.swift`. Within a version, fields may be
added, and clients ignore fields they don't know. Anything else needs a new
version: a new enum value, a field removed or renamed, or a field with a
Expand Down
8 changes: 4 additions & 4 deletions macos/TurnscopeKit/Sources/TurnscopeKit/Contract.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,16 @@
import Foundation

/// The protocol version this app speaks.
public let protocolVersion = 1
public let protocolVersion = 2

// MARK: Status

/// Everything the app shows, as of `at`.
public struct Status: Decodable, Equatable, Sendable {
public var at: Date
/// When the agents' history was last read through. Nil while it's being
/// read for the first time.
public var historyReadAt: Date?
/// Whether the agents' history has been read through once. False while
/// it's being read for the first time.
public var historyRead: Bool
/// Every account, in the order to list them: accounts in use first,
/// most urgent first.
public var accounts: [AccountStatus]
Expand Down
2 changes: 1 addition & 1 deletion macos/TurnscopeKit/Sources/TurnscopeKit/Store.swift
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ public final class Store {
/// The names of every agent Turnscope reads, installed or not.
public var agentNames: [String] { status?.agents.map(\.name) ?? [] }
/// Whether the agents' history is still being read for the first time.
public var reading: Bool { status != nil && status?.historyReadAt == nil }
public var reading: Bool { status?.historyRead == false }
/// Which notifications are turned on.
public var notify: Notify? { status?.settings.notify }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ func theRealEngineIsStartedSaysHelloAndTakesChanges() async throws {
client.start()
// Nothing is listening, so the client starts serve, which reads the
// history first.
await until { store.status?.historyReadAt != nil }
await until { store.status?.historyRead == true }
#expect(client.phase == .connected)
#expect(store.status?.agents.isEmpty == false)
// A change goes through serve, which then pushes a status that has it.
Expand Down
2 changes: 1 addition & 1 deletion src/alerts.rs
Original file line number Diff line number Diff line change
Expand Up @@ -294,7 +294,7 @@ mod tests {
};
Status {
at,
history_read_at: None,
history_read: false,
accounts: vec![Account {
id: "anthropic:a".to_owned(),
provider: "anthropic".to_owned(),
Expand Down
30 changes: 27 additions & 3 deletions src/ingest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,10 @@ fn due(db: &Connection, home: &Path) -> Result<Vec<Due>> {
pub fn ingest(db: &mut Connection, home: &Path) -> Result<bool> {
let _lock = lock(db)?;

let readers: i64 = db.query_row("SELECT readers FROM state", [], |row| row.get(0))?;
let (readers, first): (i64, bool) =
db.query_row("SELECT readers, history_at IS NULL FROM state", [], |row| {
Ok((row.get(0)?, row.get(1)?))
})?;
// An older Turnscope still running, as an agent's MCP server can be,
// would read it all again its way, and this one back again.
if readers > READERS {
Expand All @@ -210,8 +213,8 @@ pub fn ingest(db: &mut Connection, home: &Path) -> Result<bool> {
}

let due = due(db, home)?;
db.execute("UPDATE state SET history_at = ?1", [crate::now()])?;
if due.is_empty() {
// The first read ends by saying so, even with nothing to read.
if due.is_empty() && !first {
return Ok(false);
}

Expand Down Expand Up @@ -258,6 +261,12 @@ pub fn ingest(db: &mut Connection, home: &Path) -> Result<bool> {
})?;
// Statistics for the query planner, where tables changed enough.
db.execute_batch("PRAGMA optimize")?;
// `history_at` is when the first read finished. Only whether it has is
// shown, so it's written once: a catch-up after it writes nothing to say
// it ran.
if first {
db.execute("UPDATE state SET history_at = ?1", [crate::now()])?;
}
Ok(wrote)
}

Expand Down Expand Up @@ -697,6 +706,21 @@ mod tests {
assert!(!ingest(&mut db, home.path()).unwrap());
}

#[test]
fn a_catch_up_after_the_first_read_that_reads_nothing_writes_nothing() {
let (dir, home, mut db, folder) = store();
std::fs::write(folder.join("s1.jsonl"), line("Fix the parser.")).unwrap();
let unread = "SELECT history_at IS NULL FROM state";
assert_eq!(count(&db, unread), 1);
assert!(ingest(&mut db, home.path()).unwrap());
assert_eq!(count(&db, unread), 0);
// It moves when another connection writes.
let other = crate::db::open(dir.path()).unwrap();
let before = count(&other, "PRAGMA data_version");
assert!(!ingest(&mut db, home.path()).unwrap());
assert_eq!(count(&other, "PRAGMA data_version"), before);
}

#[test]
fn a_gone_files_inode_under_another_agent_isnt_taken_as_moved() {
let (_dir, home, mut db, folder) = store();
Expand Down
62 changes: 55 additions & 7 deletions src/serve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ use serde_json::{Value, json};
use crate::{Error, Result, agents, alerts, status, usage};

/// The protocol spoken, raised for any change a client can't ignore.
pub const PROTOCOL: u64 = 1;
pub const PROTOCOL: u64 = 2;

pub fn socket_path(data: &Path) -> PathBuf {
let path = data.join("serve.sock");
Expand Down Expand Up @@ -298,12 +298,6 @@ impl Server {
let mut shown = serde_json::to_value(&app).unwrap_or_default();
if let Some(fields) = shown.as_object_mut() {
fields.remove("at");
// Set at every catch-up; what's shown is only whether the first
// read is done.
fields.insert(
"historyReadAt".to_owned(),
json!(app.status.history_read_at.is_some()),
);
}
if self.sent.as_ref().is_some_and(|(_, sent)| *sent == shown) {
return;
Expand Down Expand Up @@ -591,4 +585,58 @@ mod tests {
again::<crate::alerts::Alert>(each);
}
}

/// A status is pushed when what it shows changed, and only then: not
/// after a catch-up that read history but changed nothing shown.
#[test]
fn a_status_is_pushed_only_when_what_it_shows_changed() {
use std::io::BufRead as _;
let (data, home) = (tempfile::tempdir().unwrap(), tempfile::tempdir().unwrap());
// Claude Code is installed from the start, with no history yet.
let folder = home.path().join(".claude/projects/-work-a");
std::fs::create_dir_all(&folder).unwrap();
let (stream, app) = super::UnixStream::pair().unwrap();
app.set_nonblocking(true).unwrap();
let mut app = std::io::BufReader::new(app);
let mut server = super::Server {
db: crate::db::open(data.path()).unwrap(),
home: home.path().to_path_buf(),
clients: [(
1,
super::Client {
stream,
greeted: true,
},
)]
.into(),
sent: None,
sender: std::sync::mpsc::channel().0,
};
// Push, and say whether the first read had finished in each status
// the app was sent.
let mut pushed = |server: &mut super::Server| -> Vec<Value> {
server.push();
(&mut app)
.lines()
.map_while(Result::ok)
.map(|line| serde_json::from_str::<Value>(&line).unwrap())
.map(|message| message["params"]["historyRead"].clone())
.collect()
};

assert_eq!(pushed(&mut server), [false]);
// The first read finishes, though there's nothing to read.
crate::ingest::ingest(&mut server.db, home.path()).unwrap();
assert_eq!(pushed(&mut server), [true]);
// A session that drew on no account: read, and nothing shown changed.
let line = serde_json::json!({"type": "user", "sessionId": "s1",
"timestamp": "2026-09-30T09:00:00Z", "message": {"role": "user", "content": "Hi."}});
std::fs::write(folder.join("s1.jsonl"), format!("{line}\n")).unwrap();
assert!(crate::ingest::ingest(&mut server.db, home.path()).unwrap());
assert!(pushed(&mut server).is_empty());
let mut settings = crate::alerts::Settings::default();
settings.notify.reset = false;
crate::alerts::save_settings(&server.db, &settings).unwrap();
assert_eq!(pushed(&mut server), [true]);
}
}
8 changes: 5 additions & 3 deletions src/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ const STALE: i64 = 30 * MINUTE;
pub struct Status {
/// The minute it's as of.
pub at: i64,
/// When the agents' history was last read.
pub history_read_at: Option<i64>,
/// Whether the first read of the agents' history finished.
pub history_read: bool,
/// In the order to list them: in use first, the most urgent first.
pub accounts: Vec<Account>,
pub settings: crate::alerts::Settings,
Expand Down Expand Up @@ -492,7 +492,9 @@ pub fn status(db: &rusqlite::Connection, now: i64) -> Result<Status> {
rank(&mut accounts);
Ok(Status {
at: now,
history_read_at: db.query_row("SELECT history_at FROM state", [], |row| row.get(0))?,
history_read: db.query_row("SELECT history_at IS NOT NULL FROM state", [], |row| {
row.get(0)
})?,
accounts,
settings: crate::alerts::settings(db)?,
})
Expand Down
4 changes: 2 additions & 2 deletions tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -608,9 +608,9 @@ fn serve_says_hello_with_the_status_and_answers_in_turn() {
};
let early = ask(br#"{"jsonrpc":"2.0","id":1,"method":"alerts.ack","params":{"ids":[]}}"#);
assert_eq!(early["error"]["code"], -32002);
let other = ask(br#"{"jsonrpc":"2.0","id":2,"method":"hello","params":{"protocol":2}}"#);
let other = ask(br#"{"jsonrpc":"2.0","id":2,"method":"hello","params":{"protocol":1}}"#);
assert_eq!(other["error"]["code"], -32001);
let hello = ask(br#"{"jsonrpc":"2.0","id":3,"method":"hello","params":{"protocol":1}}"#);
let hello = ask(br#"{"jsonrpc":"2.0","id":3,"method":"hello","params":{"protocol":2}}"#);
assert_eq!(
hello["result"]["server"],
format!("turnscope {}", env!("CARGO_PKG_VERSION"))
Expand Down
Loading