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
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ It has three modes:
- **Pomodoro**: a focus timer, 25 minutes of focus and 5 of break. K0 starts, pauses and resumes, and a long press gives up; K1 switches between On duty and Pomodoro, and a long press opens the box's menu (volume, mute, status); K2 still takes you to the agent's window, and a long press mutes. Each phase ends with a chime and a spoken line, and the next one waits for you to press K0. Today's finished sessions and focus time are kept on the box, reset daily, and survive a restart.
- **Leisure**: after On duty has been idle long enough, the buddy goes off to play. After five minutes it starts little skits (patrolling, kicking a ball, reading, counting stars, hide-and-seek, startling itself awake, talking in its sleep); after half an hour it gets sleepy and dims; at night, after 90 minutes of sleep, it turns the backlight off. Any agent activity or button press brings it straight back on duty.

Some boxes have a touchscreen. On one, tap a row of the box's menu to pick it, tap a key hint at the bottom of the menu to press that key or outside the menu to close it, and tap the buddy to pet it. Everything else stays on the keys, which work the same on every box.
Some boxes have a touchscreen. On one, tap a task card to go to that agent's window (K2 goes to the most pressing one), tap a row of the box's menu to pick it, tap a key hint at the bottom of the menu to press that key or outside the menu to close it, and tap the buddy to pet it. Everything else stays on the keys, which work the same on every box.

Pomodoro runs entirely on the box: its timer and today's tally keep going without the Mac. Leisure's state also lives in the firmware, but it needs a live link to the daemon: if the box hears nothing from the Mac for 15 seconds it counts the link as lost, and Leisure returns to On duty until the link is back. Design notes: [`docs/pomodoro.md`](docs/pomodoro.md) and [`docs/leisure.md`](docs/leisure.md).

Expand Down
2 changes: 1 addition & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ VibeBuddy 是给 AI 编程 Agent 配的桌面宠物。它住在键盘旁一个
- **番茄钟**:给你计时,专注 25 分钟、休息 5 分钟。K0 开始、暂停、继续,长按放弃;K1 在值班与番茄钟之间切换,长按打开盒子上的菜单(音量、静音、状态);K2 照旧带你回到 Agent 所在的窗口,长按静音。阶段结束响铃并播报,下一阶段等你按 K0 再开始。今天完成了几次、专注了多久记在盒子上,按日清零,重启不丢。
- **休闲**:值班空闲够久之后它自己去玩。五分钟后开始演小剧目(巡逻、踢球、看书、数星星、躲猫猫、被自己吓醒、梦话),半小时后困了转暗睡觉,夜里睡够 90 分钟关背光。Agent 一有动静或按任何键,立刻回来值班。

部分盒子带触摸屏。在这样的盒子上,点盒子菜单里的一行就选中它,点菜单底部的按键提示就等于按那个键,点菜单外面就关掉菜单,点小助手就是摸摸它。其余操作仍然用按键,每台盒子都一样。
部分盒子带触摸屏。在这样的盒子上,点一张任务卡就回到那个 Agent 的窗口(K2 去的是最要紧的那个),点盒子菜单里的一行就选中它,点菜单底部的按键提示就等于按那个键,点菜单外面就关掉菜单,点小助手就是摸摸它。其余操作仍然用按键,每台盒子都一样。

番茄钟完全在盒子上运行,计时和当日次数不依赖 Mac 端。休闲的状态同样在固件里,但它需要与 daemon 的链路在线:盒子 15 秒收不到 Mac 的消息就判定链路断开,休闲随即回到值班,链路恢复前不会再进入。设计见 [`docs/pomodoro.md`](docs/pomodoro.md) 与 [`docs/leisure.md`](docs/leisure.md)。

Expand Down
39 changes: 39 additions & 0 deletions daemon/src/activity.rs
Original file line number Diff line number Diff line change
Expand Up @@ -707,6 +707,7 @@ impl ActivityTracker {
.take(MAX_VISIBLE_TASKS)
.map(|(key, activity)| {
let mut task = json!({
"id": card_id(key),
"title": activity.title,
"status": match activity.status {
ActivityStatus::Working => "working",
Expand Down Expand Up @@ -776,6 +777,12 @@ impl ActivityTracker {
})
}

/// Where a tapped card leads (ADR-0019): the source of the activity whose `card_id` the box named. None when
/// that activity has ended since, or has no source to open.
pub fn card_source(&self, card: &str) -> Option<ActivitySource> {
self.activities.iter().find(|(key, _)| card_id(key) == card).and_then(|(_, activity)| activity.source.clone())
}

/// Stamp the fields that only mean something at send time: card timers and today's stats.
///
/// They can't go into `activity_snapshot`, because that snapshot takes part in dedup. Both fields
Expand Down Expand Up @@ -1006,6 +1013,13 @@ fn format_duration(seconds: u64) -> String {
}
}

/// A task card's id: short, so the box can send it back on a tap, and the same for an activity as long as it lasts.
/// FNV-1a of the activity's key; a clash between the few cards on screen is unlikely, and would only open the other one.
fn card_id(key: &str) -> String {
let hash = key.bytes().fold(0x811c_9dc5_u32, |hash, byte| (hash ^ u32::from(byte)).wrapping_mul(0x0100_0193));
format!("{hash:08x}")
}

fn event(name: &str, session_id: &str, title: &str) -> Event {
Event {
version: VERSION,
Expand Down Expand Up @@ -1251,6 +1265,31 @@ mod tests {
assert_eq!(ended[RECENTLY_ENDED - 1].title, "TASK 5");
}

#[test]
fn a_tapped_card_leads_to_its_own_activity() {
let mut tracker = ActivityTracker::default();
let older = id("older", "older:1");
tracker.observe(&older, "CC:OLDER", ActivityStatus::Working);
let codex = ActivitySource::Codex {
thread_id: "older".to_owned(),
surface: Surface::default(),
};
tracker.associate_source(&older, codex.clone());
let newer = id("newer", "newer:1");
tracker.require_input(&newer, "CX:NEWER");

let snapshot = tracker.activity_snapshot().expect("there should be a visible activity");
let tasks = snapshot.extra["tasks"].as_array().expect("cards");
let older_card = tasks[1]["id"].as_str().expect("every card has an id");
assert_eq!(tasks[1]["title"], "CC:OLDER");
assert_eq!(older_card, card_id("older:1"));
assert_eq!(older_card.len(), 8);
// Not K2's choice, which would be the one waiting for input: the card that was tapped.
assert_eq!(tracker.card_source(older_card), Some(codex));
assert_eq!(tracker.card_source(tasks[0]["id"].as_str().unwrap()), None, "no source to open");
assert_eq!(tracker.card_source("00000000"), None, "gone, or never there");
}

#[test]
fn source_survives_activity_refreshes() {
let mut tracker = ActivityTracker::default();
Expand Down
4 changes: 3 additions & 1 deletion daemon/src/ci.rs
Original file line number Diff line number Diff line change
Expand Up @@ -403,7 +403,9 @@ mod tests {
&mut tracker,
vec![run(11, "in_progress", ""), run(10, "completed", "failure")],
);
assert_eq!(names(&events), ["task.error"]);
// Run 11 moves to the top first. Both cards read CI:VIBE-BUDDY, but their ids swap places, and a box
// with a touchscreen needs them in their new order (ADR-0019).
assert_eq!(names(&events), ["task.start", "task.error"]);

let events = poll(
&mut watcher,
Expand Down
28 changes: 28 additions & 0 deletions daemon/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -412,6 +412,8 @@ async fn publish_device_message(state: &AppState, message: DeviceMessage) {
};
if is_k2_press(&event) {
open_k2_source(state).await;
} else if let Some(card) = tapped_card(&event) {
open_card_source(state, card).await;
} else if !event.event.starts_with("voice.") && event.event != "echo" {
info!(event = %event.event, "ignoring unbound device event");
}
Expand Down Expand Up @@ -515,6 +517,24 @@ async fn open_k2_source(state: &AppState) {
}
}

/// A tap on a task card on a box with a touchscreen (ADR-0019): the card's id.
fn tapped_card(event: &Event) -> Option<&str> {
(event.event == "focus").then(|| event.extra.get("task").and_then(|value| value.as_str())).flatten()
}

/// Opens the agent behind a tapped card. If it has ended since, or has nothing to open, nothing happens: opening
/// something other than what was tapped would be more confusing than nothing.
async fn open_card_source(state: &AppState, card: &str) {
let Some(source) = state.activities.lock().await.card_source(card) else {
info!(%card, "a tapped card's activity is gone or has no source");
return;
};
match source_opener::open(source).await {
Ok(link) => info!(%link, "opened a tapped card's source"),
Err(error) => warn!(%error, "failed to open a tapped card's source"),
}
}

fn is_k2_press(event: &Event) -> bool {
event.event == "button"
&& event.extra.get("button").and_then(|value| value.as_str()) == Some("K2")
Expand Down Expand Up @@ -2220,6 +2240,14 @@ mod tests {
assert_eq!(local_day(&now), 20260915);
}

#[test]
fn a_focus_event_names_the_tapped_card() {
let event: Event = serde_json::from_str(r#"{"version":1,"event":"focus","task":"0d4e77c3"}"#).expect("focus event should parse");
assert_eq!(tapped_card(&event), Some("0d4e77c3"));
let button: Event = serde_json::from_str(r#"{"version":1,"event":"button","button":"K2","action":"press"}"#).expect("button event should parse");
assert_eq!(tapped_card(&button), None);
}

#[test]
fn only_a_k2_press_requests_source_opening() {
let event: Event = serde_json::from_str(
Expand Down
3 changes: 2 additions & 1 deletion docs/device-menu.md
Original file line number Diff line number Diff line change
Expand Up @@ -93,9 +93,10 @@ Some boxes have a touchscreen (ADR-0019). The firmware looks for one at boot and
- **A row** is selected and acted on at once, as K1 to the row and then K0 would. VOLUME steps, MUTE toggles, COMPUTERS opens; FORGET ALL and NEW ID still ask first.
- **A key hint** along the bottom is that key: tap CLOSE to close, BACK to go back.
- **The dimmed screen around the panel** closes the menu, as K2 does.
- **A task card** on duty takes you to that card's agent: the box sends the card's id in a `focus` event and the daemon opens it, the way K2 opens the most pressing one ([`protocol.md`](protocol.md)).
- **The buddy** on duty, outside the menu, is petted: it hops with a heart for a moment, and nothing else changes.

The tapped row, hint or buddy is outlined for a moment, so a tap that landed is seen to. A tap in Leisure only calls the buddy back, like any key, and wakes a screen dimmed for the night. The menu still opens on K1 held, and the Pomodoro screen has nothing to tap: a box on a desk gets brushed, and the timer stays on K0. Every tap prints `TAP <x> <y>` as a diagnostic line.
The tapped row, hint, card or buddy is outlined for a moment, so a tap that landed is seen to. A tap in Leisure only calls the buddy back, like any key, and wakes a screen dimmed for the night. The menu still opens on K1 held, and the Pomodoro screen has nothing to tap: a box on a desk gets brushed, and the timer stays on K0. Every tap prints `TAP <x> <y>` as a diagnostic line.

![A tapped row](images/touch-row.png) ![The buddy petted](images/touch-pet.png)

Expand Down
10 changes: 9 additions & 1 deletion docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ What the current firmware recognizes:
{"version":1,"event":"task.start","title":"GAMMA","tasks":[{"title":"GAMMA","status":"working","elapsed_s":75},{"title":"BETA","status":"input_required","elapsed_s":900}],"stats":["7 DONE","4 ASKS","1H23 BUSY"]}
```

Each item contains `title`, `status` and `elapsed_s`, with optional `project`; the current status values are `working`, `input_required`, `done` and `failed`. `title` is the card's first line: the name the agent gave the session itself (the Claude app's session title, Codex's thread name) or the branch name, falling back to the project name only when neither exists. `project` is the project name, which the device draws on the second line unless it duplicates the title. Older firmware ignores `tasks` and `project` under the v1 rules. Future events may include `task.progress`, `task.cancelled`, `agent.waiting`, `message`, `system` and `device.status`.
Each item contains `id`, `title`, `status` and `elapsed_s`, with optional `project`; `id` is eight hex digits that stay the same for an activity as long as it lasts, which a box with a touchscreen sends back when its card is tapped (see `focus` below); the current status values are `working`, `input_required`, `done` and `failed`. `title` is the card's first line: the name the agent gave the session itself (the Claude app's session title, Codex's thread name) or the branch name, falling back to the project name only when neither exists. `project` is the project name, which the device draws on the second line unless it duplicates the title. Older firmware ignores `tasks`, `project` and `id` under the v1 rules. Future events may include `task.progress`, `task.cancelled`, `agent.waiting`, `message`, `system` and `device.status`.

`elapsed_s` is the number of seconds since the activity entered its **current state**, not since the previous event: a working card answers "how long has this turn been running", and a card waiting for input answers "how long has it been waiting". The device keeps counting on its own after receiving it, because `vibebuddyd` deduplicates and sends nothing while the visible state is unchanged, yet the number on screen has to keep ticking. And precisely because of deduplication, `elapsed_s` and `stats` are stamped onto the event only after deduplication; putting them in the snapshot would turn every tool event into a redraw, constantly knocking the working animation back to its first frame.

Expand Down Expand Up @@ -88,6 +88,14 @@ Device-to-Mac events use NDJSON as well, for example:

Currently only a K2 short press is reported, in every mode. K0 (Pomodoro), K1 (switch mode; long-press for the device menu, see [`device-menu.md`](device-menu.md)), a K2 long press (mute) and every key while the menu is open are consumed by the firmware itself and not reported. A short press counts only on release, because only on release do you know it wasn't the start of a long press, so reporting is deferred from press to release. See [`pomodoro.md`](pomodoro.md) for modes and Pomodoro, and [`leisure.md`](leisure.md) for Leisure. On receiving it, `vibebuddyd` picks a destination in this order: first, a task waiting for someone to answer (it's what the screen's main state shows, and it's blocking a person); next, the task most recently announced as finished (it has left the card stack and can no longer be seen on screen, whereas a task still running stays on screen and never needs K2 to find it); only then the newest work item. The destination is replaced by the next announcement, with no time window: the whole point of this device is that you're away from the computer, and expiring the destination on a wall clock would assume the user is always sitting nearby. Once an activity is chosen, where it runs decides what happens: only if it runs in the agent's own desktop app is a deeplink used (a top-level Codex activity opens its own thread, a sub-agent activity opens the parent thread that owns it, and Claude Code uses the desktop session id reported by the hook via `claude://code/continue?session=<desktop session id>`); if it runs in another app (a terminal, an editor's integrated terminal), that app is brought to the front, using a bundle id reported by the hook, so the daemon doesn't need to know the app; a session with no host (SSH, a background process) is skipped outright and the next candidate is tried. GitHub Actions opens the corresponding run. When there's no current activity, it falls back to the most recent locatable source; that destination is written to a local state file, so it survives a daemon restart. Candidates are tried one by one in this order: a Codex thread is first checked against the local thread table, and skipped if it isn't there, so a blank session doesn't get opened. K0, K1 and long-press/release are not yet bound.

On a box with a touchscreen (ADR-0019), a tap on a task card names that card's activity instead of leaving the choice to the daemon:

```json
{"version":1,"event":"focus","task":"0d4e77c3"}
```

`task` is the `id` the card came with. `vibebuddyd` opens that activity's source the same way K2 opens its choice, on macOS and Linux alike. If the activity has ended since, or has no source to open, nothing happens and the daemon logs it: opening something other than what was tapped would be more confusing than nothing. Cards from an older daemon have no `id`, and a tap on one sends nothing.

The `READY`, `EVENT`, `TITLE` and `ERROR` lines the firmware prints are diagnostic text for verifying the on-device link, not formal device-to-Mac JSON events. In the same category are `MODE DUTY` / `POMODORO` / `LEISURE` (mode switches); `POMODORO FOCUS START` / `FOCUS END` / `BREAK START` / `BREAK END` / `PAUSED` / `RESUMED` / `STOPPED` / `BREAK SKIPPED` (Pomodoro transitions); `LEISURE ALERT` / `BORED` / `SLEEPY`, `LEISURE SKIT <name>`, `LEISURE LIGHTS OUT` / `ON` (Leisure); and `CLOCK HOUR <n>` (the received hour changed), `TALLY LOADED <count> <seconds>S DAY <date>` (the daily record restored at boot), `MUTE ON` / `OFF` (K2 long press, or the menu), `MENU OPEN` / `MENU CLOSED` (the device menu), `TOUCH CHSC5432` / `TOUCH NONE` (whether the box has a touchscreen, at boot and in answer to `device.hello`; ADR-0019), `TAP <x> <y>` (a tap on the touchscreen, in screen pixels) and `AUDIO MUTED <which line>` (a voice line swallowed while muted). Pomodoro and Leisure state lives only in the firmware; the Mac just logs it.

## Device maintenance: blink to identify and voice pack writes
Expand Down
4 changes: 2 additions & 2 deletions firmware-rs/core/examples/look_preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ fn main() {
display.set_look(&mut screen, &scene!(), Some(look));

let cards = [
TaskInput { title: Some(b"CC:CHARACTERS"), state: State::Working, elapsed_s: 75, project: Some(b"VIBE-BUDDY") },
TaskInput { title: Some(b"CX:EROS-TRAINING"), state: State::Working, elapsed_s: 900, project: None },
TaskInput { title: Some(b"CC:CHARACTERS"), state: State::Working, elapsed_s: 75, project: Some(b"VIBE-BUDDY"), id: None },
TaskInput { title: Some(b"CX:EROS-TRAINING"), state: State::Working, elapsed_s: 900, project: None, id: None },
];
let states: [(&str, State, &[TaskInput], u32); 6] = [
("idle", State::Idle, &[], 120),
Expand Down
9 changes: 5 additions & 4 deletions firmware-rs/core/examples/preview.rs
Original file line number Diff line number Diff line change
Expand Up @@ -124,12 +124,13 @@ fn main() {
preview.snapshot("pomodoro_idle");

let tasks = [
TaskInput { title: Some(b"CC:POMODORO TIMER FEATURE"), state: State::Working, elapsed_s: 75, project: Some(b"AGENT-BEACON") },
TaskInput { title: Some(b"CC:POMODORO TIMER FEATURE"), state: State::Working, elapsed_s: 75, project: Some(b"AGENT-BEACON"), id: None },
TaskInput {
title: Some(b"CX:EROS-TRAINING-INFRA"),
state: State::InputRequired,
elapsed_s: 900,
project: Some(b"EROS-TRAINING-INFRA"),
id: None,
},
];
preview.show(State::InputRequired, Some(b"CC:AGENT-BEACON"), &tasks);
Expand Down Expand Up @@ -187,9 +188,9 @@ fn main() {
// Three cards: the state label keeps clear of the last one.
preview.display.set_muted(&mut preview.screen, &scene!(preview), false);
let three = [
TaskInput { title: Some(b"CC:UPDATE README"), state: State::Working, elapsed_s: 20, project: Some(b"VIBEBUDDY") },
TaskInput { title: Some(b"CX:BUILD FIRMWARE"), state: State::Working, elapsed_s: 60, project: Some(b"VIBEBUDDY") },
TaskInput { title: Some(b"CI:APP TESTS"), state: State::Working, elapsed_s: 120, project: Some(b"VIBEBUDDY") },
TaskInput { title: Some(b"CC:UPDATE README"), state: State::Working, elapsed_s: 20, project: Some(b"VIBEBUDDY"), id: None },
TaskInput { title: Some(b"CX:BUILD FIRMWARE"), state: State::Working, elapsed_s: 60, project: Some(b"VIBEBUDDY"), id: None },
TaskInput { title: Some(b"CI:APP TESTS"), state: State::Working, elapsed_s: 120, project: Some(b"VIBEBUDDY"), id: None },
];
preview.show(State::Working, None, &three);
preview.snapshot("pet_three_tasks");
Expand Down
Loading
Loading