lonca-client 0.2.2

Client of the Lonca daemon socket: protocol types, framing, daemon checks and the human session file
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
//! Wire protocol: one JSON object per line over a Unix socket
//! (`docs/protocol.md`). The parameters of `repo.prepare_init` are the
//! daemon's (`lonca_daemon::protocol::PrepareInit`): they name a policy
//! profile, and this crate knows no policy.

use std::io::{BufRead, Write};

use lonca_core::ops::{Claim, Decision, HumanSignature, LeaseItem, NegotiationMsg, Op, Scope};
use lonca_core::{ChangeId, FileEntry, FileMode, GitId};
use serde::{Deserialize, Serialize};
use serde_json::Value;

/// Largest accepted request line (base64 file contents included).
pub const MAX_LINE: usize = 96 * 1024 * 1024;

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Request {
    pub id: u64,
    pub method: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub repo: Option<String>,
    /// Agent session token or human session token. Never logged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub token: Option<String>,
    #[serde(default)]
    pub params: Value,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ErrorBody {
    pub code: String,
    pub message: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Response {
    pub id: u64,
    pub ok: bool,
    #[serde(default, skip_serializing_if = "Value::is_null")]
    pub result: Value,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub error: Option<ErrorBody>,
}

impl Response {
    pub fn ok(id: u64, result: Value) -> Self {
        Self {
            id,
            ok: true,
            result,
            error: None,
        }
    }

    pub fn err(id: u64, code: &str, message: impl Into<String>) -> Self {
        Self {
            id,
            ok: false,
            result: Value::Null,
            error: Some(ErrorBody {
                code: code.into(),
                message: message.into(),
            }),
        }
    }
}

/// The outcome of a logged operation.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Logged {
    pub seq: u64,
    pub decision: Decision,
}

/// One file in a proposed change, relative to the base commit.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FileChange {
    pub path: String,
    /// `None` deletes the file.
    #[serde(default)]
    pub content_b64: Option<String>,
    #[serde(default)]
    pub mode: Option<FileMode>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HumanSubmit {
    pub op: Op,
    pub sig: HumanSignature,
    /// `session.isolate`: the workspace, which the proxy attributes
    /// connections to. Not logged.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub workspace: Option<String>,
}

/// `human.prepare_isolate`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrepareIsolate {
    pub session: String,
    /// The commit the workspace says it stands on.
    pub base: GitId,
}

/// `human.login`: opens a human session. The signature is over a
/// [`lonca_core::ops::LoginIntent`]; `repo` on the request names the
/// repository whose enrolment verifies it, or is left out to try every
/// repository the daemon serves.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HumanLogin {
    pub sig: HumanSignature,
    /// Session length; the daemon has a default and a ceiling.
    #[serde(default)]
    pub ttl_ms: Option<i64>,
}

/// `repo.restore`: a new repository from a backup bundle, a bare store or
/// a remote Lonca pushed to.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Restore {
    pub repo: String,
    pub from: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrepareChange {
    pub base: GitId,
    pub files: Vec<FileChange>,
    #[serde(default)]
    pub change: Option<ChangeId>,
    pub author: String,
    #[serde(default)]
    pub intent: String,
    #[serde(default)]
    pub revert_of: Option<ChangeId>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PreparePolicy {
    pub config: String,
    pub policy: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct PrepareRemote {
    pub remote: String,
    #[serde(default)]
    pub branch: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LeaseAcquire {
    #[serde(default)]
    pub set: Option<String>,
    pub items: Vec<LeaseItem>,
    pub intent: String,
    pub ttl_ms: i64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct SetRef {
    pub set: String,
    #[serde(default)]
    pub ttl_ms: Option<i64>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ProposeChange {
    #[serde(default)]
    pub change: Option<ChangeId>,
    pub base: GitId,
    pub files: Vec<FileChange>,
    pub claim: Claim,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChangeRef {
    pub change: ChangeId,
}

/// `security.events`: what the security log holds since `since_ms`
/// (default: the last day).
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SecurityEvents {
    #[serde(default)]
    pub since_ms: Option<i64>,
}

/// `trunk.bundle`: a Git bundle of trunk for a checkout, thin against
/// the commits the checkout already has.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrunkBundle {
    #[serde(default)]
    pub have: Vec<GitId>,
}

/// `trunk.distance`: how far a commit that once was trunk is behind.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TrunkDistance {
    pub base: GitId,
}

/// `change.resolve`: the whole id of the one change whose id starts with
/// `prefix` (at least 4 letters).
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChangeResolve {
    pub prefix: String,
}

/// `change.diff`: the unified diff of a change, computed by the daemon
/// in the store, with redaction applied and a size cap.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct ChangeDiff {
    pub change: ChangeId,
    /// Only `unified` today.
    #[serde(default)]
    pub format: Option<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Negotiate {
    pub thread: String,
    pub to_session: String,
    pub message: NegotiationMsg,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Attenuate {
    pub agent: String,
    pub scope: Scope,
    pub expires_ms: i64,
    /// Lease sets handed to the child until it ends.
    #[serde(default)]
    pub leases: Vec<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LeaseInterest {
    pub items: Vec<LeaseItem>,
    #[serde(default)]
    pub intent: String,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Events {
    #[serde(default)]
    pub from: u64,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct FactsPreview {
    pub base: GitId,
    pub files: Vec<FileChange>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TreeQuery {
    pub commit: GitId,
    #[serde(default)]
    pub with_content: bool,
    #[serde(default)]
    pub paths: Option<Vec<String>>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct TreeFile {
    pub path: String,
    pub mode: FileMode,
    pub blob: GitId,
    /// Blob size in bytes, so a client can page its fetches.
    #[serde(default)]
    pub size: u64,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub content_b64: Option<String>,
}

/// `blobs`: the contents of some files of one commit, one page. The
/// client sizes a page from `tree`'s sizes to stay under the line cap.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct BlobsQuery {
    pub commit: GitId,
    pub paths: Vec<String>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LogQuery {
    #[serde(default)]
    pub from: u64,
    #[serde(default)]
    pub limit: Option<u64>,
    /// With nothing past `from`, hold the request this long (the daemon caps
    /// it at 30 seconds) for an entry to arrive.
    #[serde(default)]
    pub wait_ms: Option<i64>,
}

/// A harness hook event, reduced to what Lonca uses. `hook.input` makes one
/// from what a harness sent; tool names stay the harness's own.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct HookEvent {
    pub hook_event_name: String,
    #[serde(default)]
    pub tool_name: Option<String>,
    #[serde(default)]
    pub tool_input: Value,
    #[serde(default)]
    pub cwd: Option<String>,
    /// Absolute path of the workspace root the hook resolved.
    pub workspace: String,
    /// The harness profile to read it with; none means `claude-code`.
    #[serde(default)]
    pub harness: Option<String>,
}

/// `harness.setup`: which harness's setup to read from the policy.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HarnessSetup {
    pub harness: String,
}

/// What a hook received from its harness, untouched: the daemon reads it with
/// the harness's profile from the policy.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HookInput {
    pub harness: String,
    pub input: Value,
    /// Absolute path of the workspace root the hook resolved.
    pub workspace: String,
}

/// One file a workspace differs in from its base, as it would be in a tree.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LocalFile {
    pub path: String,
    /// `None`: the file is deleted.
    #[serde(default)]
    pub entry: Option<FileEntry>,
}

/// `hook.stop`: the agent is about to finish its turn, with these changes.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HookStop {
    pub harness: String,
    pub files: Vec<LocalFile>,
}

/// `mcp.call`: `lonca mcp` is about to run one of its tools, by the name it
/// has in `tools/list`.
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct McpCall {
    pub tool: String,
}

/// `session.end`: what the workspace held, when the client knows.
#[derive(Clone, Debug, Default, Serialize, Deserialize)]
pub struct SessionEnd {
    #[serde(default)]
    pub files: Option<Vec<LocalFile>>,
}

#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct HookBind {
    pub harness: String,
    pub harness_session: String,
    pub sudo_without_password: bool,
    /// Workspace root; lets the network proxy attribute connections.
    #[serde(default)]
    pub workspace: Option<String>,
}

#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct HookVerdict {
    /// `allow`, `deny` or `ask`.
    pub decision: String,
    #[serde(default)]
    pub reason: String,
}

/// Reads one line of at most `max` bytes. `Ok(None)` at end of stream.
pub fn read_line_limited(r: &mut impl BufRead, max: usize) -> std::io::Result<Option<String>> {
    let mut buf = Vec::new();
    loop {
        let chunk = r.fill_buf()?;
        if chunk.is_empty() {
            return Ok(if buf.is_empty() {
                None
            } else {
                Some(String::from_utf8_lossy(&buf).into_owned())
            });
        }
        match chunk.iter().position(|&b| b == b'\n') {
            Some(i) => {
                buf.extend_from_slice(&chunk[..i]);
                r.consume(i + 1);
                return Ok(Some(String::from_utf8_lossy(&buf).into_owned()));
            }
            None => {
                let n = chunk.len();
                buf.extend_from_slice(chunk);
                r.consume(n);
                if buf.len() > max {
                    return Err(std::io::Error::new(
                        std::io::ErrorKind::InvalidData,
                        "request too large",
                    ));
                }
            }
        }
    }
}

pub fn write_line<T: Serialize>(w: &mut impl Write, v: &T) -> std::io::Result<()> {
    let mut bytes = serde_json::to_vec(v)?;
    bytes.push(b'\n');
    w.write_all(&bytes)?;
    w.flush()
}