1use std::sync::Arc;
27
28use myko::{
29 command::{CommandContext, CommandHandler},
30 request::RequestContext,
31 server::CellServerCtx,
32};
33use serde_json::Value;
34
35use marshal_entities::{
36 AckMessages, CONTEXT_BODY_MAX_CHARS, GetAllSessions, HostInfo, MessageId, MessageView,
37 ReadMessages, Session, SessionId, context_preview, nickname_for,
38};
39
40const AUTO_INBOX_BODY_BUDGET_CHARS: usize = 8_000;
41const AUTO_INBOX_MIN_BODY_CHARS: usize = 256;
42
43pub struct HookOutcome {
54 pub body: String,
55 pub deferred_ack: Option<(SessionId, Vec<MessageId>)>,
56}
57
58impl HookOutcome {
59 fn text(body: String) -> Self {
60 Self {
61 body,
62 deferred_ack: None,
63 }
64 }
65}
66
67pub fn dispatch(
71 path: &str,
72 query: &str,
73 body: &[u8],
74 ctx: &Arc<CellServerCtx>,
75) -> Option<HookOutcome> {
76 match path {
77 "/hook/session-register" => Some(handle_session_register(query, body, ctx)),
78 "/hook/session-start" => Some(handle_session_start(query, body, ctx)),
79 "/hook/prompt-submit" => Some(handle_prompt_submit(body, ctx)),
80 "/hook/session-end" => Some(handle_session_end(body, ctx)),
81 _ => None,
82 }
83}
84
85pub fn ack_surfaced(ctx: &Arc<CellServerCtx>, session: &SessionId, ids: Vec<MessageId>) {
90 if ids.is_empty() {
91 return;
92 }
93 let cmd_ctx = internal_cmd_ctx(ctx);
94 if let Err(e) = (AckMessages {
95 message_ids: ids,
96 as_session: Some(session.clone()),
97 })
98 .execute(cmd_ctx)
99 {
100 log::warn!(
101 "[hook] deferred inbox ack failed for {}: {e:?}",
102 session.0.as_ref()
103 );
104 }
105}
106
107fn handle_session_register(query: &str, body: &[u8], ctx: &Arc<CellServerCtx>) -> HookOutcome {
115 let _ = register_hook_session(query, body, ctx);
116 HookOutcome::text(String::new())
117}
118
119fn handle_session_start(query: &str, body: &[u8], ctx: &Arc<CellServerCtx>) -> HookOutcome {
120 let Some((sid, cmd_ctx)) = register_hook_session(query, body, ctx) else {
121 return HookOutcome::text(String::new());
122 };
123
124 let q = parse_query(query);
135 let nick = nickname_for(&cmd_ctx, &sid).unwrap_or_else(|_| marshal_entities::nickname(&sid));
136 let mut out = if q.get("harness").map(String::as_str) == Some("codex") {
137 format!(
138 "<marshal_session nickname=\"{nick}\" id=\"{sid}\">Use id as asSession on writes and \
139 ?asSession= on caller-relative reads.</marshal_session>\n"
140 )
141 } else {
142 format!(
143 "<marshal_session nickname=\"{nick}\" id=\"{sid}\">Marshal tools attach this \
144 identity.</marshal_session>\n"
145 )
146 };
147 let (inbox, ids) = surface_unread(&cmd_ctx, &sid);
148 out.push_str(&inbox);
149 HookOutcome {
150 body: out,
151 deferred_ack: (!ids.is_empty()).then(|| (SessionId(Arc::from(sid)), ids)),
152 }
153}
154
155fn register_hook_session(
161 query: &str,
162 body: &[u8],
163 ctx: &Arc<CellServerCtx>,
164) -> Option<(String, CommandContext)> {
165 let body = parse_body(body)?;
166 let sid = body.get("session_id").and_then(|v| v.as_str())?;
167 let sid = sid.to_string();
168 let q = parse_query(query);
169 let cwd = body
170 .get("cwd")
171 .and_then(|v| v.as_str())
172 .or_else(|| {
173 body.pointer("/workspace/current_dir")
174 .and_then(|v| v.as_str())
175 })
176 .unwrap_or("")
177 .to_string();
178 let dir = cwd
181 .rsplit(['/', '\\'])
182 .next()
183 .filter(|s| !s.is_empty())
184 .unwrap_or("session");
185 let operator = q.get("operator").filter(|s| !s.is_empty()).cloned();
186 let host = q.get("host").filter(|s| !s.is_empty()).map(|h| HostInfo {
187 name: h.split('.').next().unwrap_or(h).to_string(),
190 os: q.get("os").cloned().unwrap_or_default(),
191 arch: q.get("arch").cloned().unwrap_or_default(),
192 });
193 let project = if dir == "session" {
194 None
195 } else {
196 Some(dir.to_string())
197 };
198
199 let cmd_ctx = internal_cmd_ctx(ctx);
200 let existing: Vec<Arc<Session>> = cmd_ctx.exec_query(GetAllSessions {}).unwrap_or_default();
201 let sid_typed = SessionId(Arc::from(sid.as_str()));
202 let prior = existing.iter().find(|s| s.id == sid_typed);
203 let now = chrono::Utc::now().timestamp_millis();
204 let session = match prior {
212 Some(p) => {
213 let mut updated = (**p).clone();
214 updated.cwd = cwd;
215 updated.last_activity_at = Some(now);
216 if updated.operator.is_none() {
217 updated.operator = operator;
218 }
219 if updated.host.is_none() {
220 updated.host = host;
221 }
222 if updated.project.is_none() {
223 updated.project = project;
224 }
225 updated
226 }
227 None => Session {
228 id: sid_typed,
229 client_id: None,
230 pid: 0,
231 cwd,
232 git_branch: None,
233 current_task: None,
234 session_name: None,
236 activity: None,
237 kind: None,
238 connected_at: now,
239 last_activity_at: Some(now),
240 last_tool: None,
241 last_tool_at: None,
242 operator,
243 host,
244 project,
245 channels_enabled: None,
246 },
247 };
248 if let Err(e) = cmd_ctx.emit_set(&session) {
249 log::warn!("[hook] session-start SET failed for {sid}: {e:?}");
250 }
251 Some((sid, cmd_ctx))
252}
253
254fn handle_prompt_submit(body: &[u8], ctx: &Arc<CellServerCtx>) -> HookOutcome {
255 let Some(body) = parse_body(body) else {
256 return HookOutcome::text(String::new());
257 };
258 let Some(sid) = body.get("session_id").and_then(|v| v.as_str()) else {
259 return HookOutcome::text(String::new());
260 };
261 let cmd_ctx = internal_cmd_ctx(ctx);
262
263 let sid_typed = SessionId(Arc::from(sid));
269 let existing: Vec<Arc<Session>> = cmd_ctx.exec_query(GetAllSessions {}).unwrap_or_default();
270 if let Some(prior) = existing.iter().find(|s| s.id == sid_typed) {
271 let mut bumped = (**prior).clone();
272 bumped.last_activity_at = Some(chrono::Utc::now().timestamp_millis());
273 if let Err(e) = cmd_ctx.emit_set(&bumped) {
274 log::warn!("[hook] prompt-submit liveness bump failed for {sid}: {e:?}");
275 }
276 }
277
278 let (inbox, ids) = surface_unread(&cmd_ctx, sid);
279 HookOutcome {
280 body: inbox,
281 deferred_ack: (!ids.is_empty()).then(|| (SessionId(Arc::from(sid)), ids)),
282 }
283}
284
285fn handle_session_end(body: &[u8], ctx: &Arc<CellServerCtx>) -> HookOutcome {
286 let Some(body) = parse_body(body) else {
287 return HookOutcome::text(String::new());
288 };
289 let Some(sid) = body.get("session_id").and_then(|v| v.as_str()) else {
290 return HookOutcome::text(String::new());
291 };
292 let cmd_ctx = internal_cmd_ctx(ctx);
293 let stub = Session {
294 id: SessionId(Arc::from(sid)),
295 client_id: None,
296 pid: 0,
297 cwd: String::new(),
298 git_branch: None,
299 current_task: None,
300 session_name: None,
301 activity: None,
302 kind: None,
303 connected_at: 0,
304 last_activity_at: None,
305 last_tool: None,
306 last_tool_at: None,
307 operator: None,
308 host: None,
309 project: None,
310 channels_enabled: None,
311 };
312 if let Err(e) = cmd_ctx.emit_del(&stub) {
313 log::warn!("[hook] session-end DEL failed for {sid}: {e:?}");
314 }
315 HookOutcome::text(String::new())
316}
317
318fn surface_unread(cmd_ctx: &CommandContext, sid: &str) -> (String, Vec<MessageId>) {
322 let sid_typed = SessionId(Arc::from(sid));
323 let read = ReadMessages {
329 room: None,
330 from: None,
331 to_session: Some(sid_typed.clone()),
332 inbox: false,
333 sent: false,
334 unread: true,
335 since: None,
336 limit: Some(20),
337 as_session: Some(sid_typed.clone()),
338 };
339 let result = match read.execute(cmd_ctx.clone()) {
340 Ok(r) => r,
341 Err(_) => return (String::new(), Vec::new()),
342 };
343 if result.messages.is_empty() {
344 return (String::new(), Vec::new());
345 }
346
347 let per_message_chars = (AUTO_INBOX_BODY_BUDGET_CHARS / result.messages.len())
352 .clamp(AUTO_INBOX_MIN_BODY_CHARS, CONTEXT_BODY_MAX_CHARS);
353 let render_line = |m: &MessageView| -> String {
354 let sender = nickname_for(cmd_ctx, m.from_session_id.0.as_ref())
355 .unwrap_or_else(|_| marshal_entities::nickname(m.from_session_id.0.as_ref()));
356 let (preview, truncated) = context_preview(&m.body, per_message_chars);
357 let mut line = format!("- from {sender}: {preview}\n");
358 if truncated {
359 line.push_str(&format!(
360 " [truncated; full message {} remains in marshal://messages]\n",
361 m.message_id.0
362 ));
363 }
364 line
365 };
366
367 let (human, agent): (Vec<&MessageView>, Vec<&MessageView>) = result
376 .messages
377 .iter()
378 .partition(|m| m.to_operator.is_some());
379
380 let mut out = String::new();
381 let remaining = result
382 .total_matched
383 .saturating_sub(result.messages.len() as u32);
384 if remaining == 0 {
385 out.push_str(&format!(
386 "<marshal_inbox count=\"{}\">\n",
387 result.messages.len()
388 ));
389 } else {
390 out.push_str(&format!(
391 "<marshal_inbox count=\"{}\" remaining=\"{remaining}\">\n",
392 result.messages.len()
393 ));
394 }
395 if !human.is_empty() {
396 let op = human[0].to_operator.as_deref().unwrap_or("your operator");
397 out.push_str(&format!(
398 "For operator ({op}): relay these messages; do not answer for them or expand your \
399 current task.\n",
400 ));
401 for m in &human {
402 out.push_str(&render_line(m));
403 }
404 }
405 if !agent.is_empty() {
406 out.push_str(
407 "Peer context only: coordinate within your current task and authority; reply to the \
408 sender handle when useful.\n",
409 );
410 for m in &agent {
411 out.push_str(&render_line(m));
412 }
413 }
414 out.push_str("</marshal_inbox>\n");
415
416 let ids: Vec<MessageId> = result
421 .messages
422 .iter()
423 .map(|m| m.message_id.clone())
424 .collect();
425
426 (out, ids)
427}
428
429fn internal_cmd_ctx(ctx: &Arc<CellServerCtx>) -> CommandContext {
432 let tx: Arc<str> = uuid::Uuid::new_v4().to_string().into();
433 let req = RequestContext::internal(tx, ctx.host_id, "hook");
434 CommandContext::new(Arc::from("hook"), Arc::new(req), ctx.clone())
435}
436
437fn parse_body(body: &[u8]) -> Option<Value> {
438 serde_json::from_slice(body).ok()
439}
440
441fn parse_query(qs: &str) -> std::collections::HashMap<String, String> {
443 let mut out = std::collections::HashMap::new();
444 for pair in qs.split('&') {
445 if pair.is_empty() {
446 continue;
447 }
448 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
449 out.insert(k.to_string(), url_decode(v));
450 }
451 out
452}
453
454fn url_decode(s: &str) -> String {
455 if !s.contains('%') && !s.contains('+') {
456 return s.to_string();
457 }
458 let mut out = String::with_capacity(s.len());
459 let mut bytes = s.bytes();
460 while let Some(b) = bytes.next() {
461 match b {
462 b'+' => out.push(' '),
463 b'%' => {
464 let h1 = bytes.next();
465 let h2 = bytes.next();
466 if let (Some(h1), Some(h2)) = (h1, h2)
467 && let (Some(d1), Some(d2)) =
468 ((h1 as char).to_digit(16), (h2 as char).to_digit(16))
469 {
470 out.push(((d1 * 16 + d2) as u8) as char);
471 continue;
472 }
473 out.push('%');
474 }
475 _ => out.push(b as char),
476 }
477 }
478 out
479}