u-foo 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/LICENSE +35 -0
  2. package/README.md +163 -0
  3. package/README.zh-CN.md +163 -0
  4. package/bin/uclaude +65 -0
  5. package/bin/ucodex +65 -0
  6. package/bin/ufoo +93 -0
  7. package/bin/ufoo.js +35 -0
  8. package/modules/AGENTS.template.md +87 -0
  9. package/modules/bus/README.md +132 -0
  10. package/modules/bus/SKILLS/ubus/SKILL.md +209 -0
  11. package/modules/bus/scripts/bus-alert.sh +185 -0
  12. package/modules/bus/scripts/bus-listen.sh +117 -0
  13. package/modules/context/ASSUMPTIONS.md +7 -0
  14. package/modules/context/CONSTRAINTS.md +7 -0
  15. package/modules/context/CONTEXT-STRUCTURE.md +49 -0
  16. package/modules/context/DECISION-PROTOCOL.md +62 -0
  17. package/modules/context/HANDOFF.md +33 -0
  18. package/modules/context/README.md +82 -0
  19. package/modules/context/RULES.md +15 -0
  20. package/modules/context/SKILLS/README.md +14 -0
  21. package/modules/context/SKILLS/uctx/SKILL.md +91 -0
  22. package/modules/context/SYSTEM.md +18 -0
  23. package/modules/context/TEMPLATES/assumptions.md +4 -0
  24. package/modules/context/TEMPLATES/constraints.md +4 -0
  25. package/modules/context/TEMPLATES/decision.md +16 -0
  26. package/modules/context/TEMPLATES/project-context-readme.md +6 -0
  27. package/modules/context/TEMPLATES/system.md +3 -0
  28. package/modules/context/TEMPLATES/terminology.md +4 -0
  29. package/modules/context/TERMINOLOGY.md +10 -0
  30. package/modules/resources/ICONS/README.md +12 -0
  31. package/modules/resources/ICONS/libraries/README.md +17 -0
  32. package/modules/resources/ICONS/libraries/heroicons/LICENSE +22 -0
  33. package/modules/resources/ICONS/libraries/heroicons/README.md +15 -0
  34. package/modules/resources/ICONS/libraries/heroicons/arrow-right.svg +4 -0
  35. package/modules/resources/ICONS/libraries/heroicons/check.svg +4 -0
  36. package/modules/resources/ICONS/libraries/heroicons/chevron-down.svg +4 -0
  37. package/modules/resources/ICONS/libraries/heroicons/cog-6-tooth.svg +5 -0
  38. package/modules/resources/ICONS/libraries/heroicons/magnifying-glass.svg +4 -0
  39. package/modules/resources/ICONS/libraries/heroicons/x-mark.svg +4 -0
  40. package/modules/resources/ICONS/libraries/lucide/LICENSE +40 -0
  41. package/modules/resources/ICONS/libraries/lucide/README.md +15 -0
  42. package/modules/resources/ICONS/libraries/lucide/arrow-right.svg +15 -0
  43. package/modules/resources/ICONS/libraries/lucide/check.svg +14 -0
  44. package/modules/resources/ICONS/libraries/lucide/chevron-down.svg +14 -0
  45. package/modules/resources/ICONS/libraries/lucide/search.svg +15 -0
  46. package/modules/resources/ICONS/libraries/lucide/settings.svg +15 -0
  47. package/modules/resources/ICONS/libraries/lucide/x.svg +15 -0
  48. package/modules/resources/ICONS/rules.md +7 -0
  49. package/modules/resources/README.md +9 -0
  50. package/modules/resources/UI/ANTI-PATTERNS.md +6 -0
  51. package/modules/resources/UI/TONE.md +6 -0
  52. package/package.json +40 -0
  53. package/scripts/banner.sh +89 -0
  54. package/scripts/bus-alert.sh +6 -0
  55. package/scripts/bus-autotrigger.sh +6 -0
  56. package/scripts/bus-daemon.sh +231 -0
  57. package/scripts/bus-inject.sh +144 -0
  58. package/scripts/bus-listen.sh +6 -0
  59. package/scripts/bus.sh +984 -0
  60. package/scripts/context-decisions.sh +167 -0
  61. package/scripts/context-doctor.sh +72 -0
  62. package/scripts/context-lint.sh +110 -0
  63. package/scripts/doctor.sh +22 -0
  64. package/scripts/init.sh +247 -0
  65. package/scripts/skills.sh +113 -0
  66. package/scripts/status.sh +125 -0
  67. package/src/agent/cliRunner.js +190 -0
  68. package/src/agent/internalRunner.js +212 -0
  69. package/src/agent/normalizeOutput.js +41 -0
  70. package/src/agent/ufooAgent.js +222 -0
  71. package/src/chat/index.js +1603 -0
  72. package/src/cli.js +349 -0
  73. package/src/config.js +37 -0
  74. package/src/daemon/index.js +501 -0
  75. package/src/daemon/ops.js +120 -0
  76. package/src/daemon/run.js +41 -0
  77. package/src/daemon/status.js +78 -0
package/scripts/bus.sh ADDED
@@ -0,0 +1,984 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ # bus: Project-level Agent event bus
5
+ # Independent module, stored in .ufoo/bus/
6
+ #
7
+ # Usage: bus <command> [options]
8
+ #
9
+ # Commands:
10
+ # init Initialize event bus
11
+ # join [session-id] Join bus (register current instance)
12
+ # send <target> <message> Send targeted message
13
+ # broadcast <message> Broadcast message
14
+ # check Check pending events
15
+ # status View bus status
16
+ # consume Consume events
17
+ # leave Leave bus
18
+
19
+ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
20
+ BUS_DIR=".ufoo/bus"
21
+ DATE_FORMAT="%Y-%m-%dT%H:%M:%S.000Z"
22
+
23
+ # Colors
24
+ RED='\033[0;31m'
25
+ GREEN='\033[0;32m'
26
+ YELLOW='\033[0;33m'
27
+ BLUE='\033[0;34m'
28
+ CYAN='\033[0;36m'
29
+ NC='\033[0m'
30
+
31
+ log_info() { echo -e "${BLUE}[bus]${NC} $*"; }
32
+ log_ok() { echo -e "${GREEN}[bus]${NC} $*"; }
33
+ log_warn() { echo -e "${YELLOW}[bus]${NC} $*"; }
34
+ log_error() { echo -e "${RED}[bus]${NC} $*" >&2; }
35
+
36
+ # ============================================================================
37
+ # Utility functions
38
+ # ============================================================================
39
+
40
+ get_timestamp() {
41
+ date -u +"$DATE_FORMAT"
42
+ }
43
+
44
+ get_date() {
45
+ date -u +"%Y-%m-%d"
46
+ }
47
+
48
+ generate_instance_id() {
49
+ echo "$(date +%s%N | shasum | head -c 8)"
50
+ }
51
+
52
+ ensure_bus() {
53
+ if [[ ! -d "$BUS_DIR" ]]; then
54
+ log_error "Event bus not initialized. Please run: bus init or /uinit"
55
+ exit 1
56
+ fi
57
+ }
58
+
59
+ subscriber_to_safe_name() {
60
+ echo "${1//:/_}"
61
+ }
62
+
63
+ get_next_seq() {
64
+ local today_file="$BUS_DIR/events/$(get_date).jsonl"
65
+ if [[ -f "$today_file" ]]; then
66
+ local last_seq
67
+ last_seq=$(tail -1 "$today_file" 2>/dev/null | jq -r '.seq // 0' 2>/dev/null || echo "0")
68
+ echo $((last_seq + 1))
69
+ else
70
+ local max_seq=0
71
+ shopt -s nullglob
72
+ for f in "$BUS_DIR/events"/*.jsonl; do
73
+ if [[ -f "$f" ]]; then
74
+ local file_max
75
+ file_max=$(tail -1 "$f" 2>/dev/null | jq -r '.seq // 0' 2>/dev/null || echo "0")
76
+ if [[ $file_max -gt $max_seq ]]; then
77
+ max_seq=$file_max
78
+ fi
79
+ fi
80
+ done
81
+ echo $((max_seq + 1))
82
+ fi
83
+ }
84
+
85
+ # Best-effort check for currently running process.
86
+ is_pid_alive() {
87
+ local pid="${1:-0}"
88
+ if [[ -z "$pid" || "$pid" == "0" ]]; then
89
+ return 1
90
+ fi
91
+ if kill -0 "$pid" 2>/dev/null; then
92
+ return 0
93
+ fi
94
+ return 1
95
+ }
96
+
97
+ # Resolve nickname to subscriber ID
98
+ resolve_nickname() {
99
+ local nickname="$1"
100
+ ensure_bus
101
+ jq -r --arg nick "$nickname" \
102
+ '.subscribers | to_entries[] | select(.value.nickname == $nick) | .key' \
103
+ "$BUS_DIR/bus.json" | head -1
104
+ }
105
+
106
+ # Check if target matches subscriber
107
+ target_matches() {
108
+ local target="$1"
109
+ local subscriber="$2"
110
+
111
+ # Priority 1: Wildcard/empty
112
+ [[ -z "$target" || "$target" == "*" ]] && return 0
113
+
114
+ # Priority 2: Exact subscriber ID match
115
+ [[ "$target" == "$subscriber" ]] && return 0
116
+
117
+ # Priority 3: Nickname match (resolve nickname to real ID)
118
+ if [[ ! "$target" =~ : ]]; then
119
+ local resolved
120
+ resolved=$(resolve_nickname "$target")
121
+ if [[ -n "$resolved" ]]; then
122
+ [[ "$resolved" == "$subscriber" ]] && return 0
123
+ fi
124
+ fi
125
+
126
+ # Priority 4: Agent type match
127
+ local target_type="${target%%:*}"
128
+ local target_instance="${target#*:}"
129
+ local sub_type="${subscriber%%:*}"
130
+
131
+ [[ "$target" == "$target_type" && "$target_type" == "$sub_type" ]] && return 0
132
+ [[ "$target_instance" == "*" && "$target_type" == "$sub_type" ]] && return 0
133
+
134
+ return 1
135
+ }
136
+
137
+ # ============================================================================
138
+ # Command: init
139
+ # ============================================================================
140
+
141
+ cmd_init() {
142
+ if [[ -d "$BUS_DIR" ]]; then
143
+ log_warn "Event bus already exists"
144
+ return 0
145
+ fi
146
+
147
+ log_info "Initializing event bus..."
148
+
149
+ mkdir -p "$BUS_DIR"/{events,offsets,queues}
150
+
151
+ local project_name
152
+ project_name=$(basename "$(pwd)")
153
+
154
+ cat > "$BUS_DIR/bus.json" << EOF
155
+ {
156
+ "bus_id": "${project_name}-bus",
157
+ "created_at": "$(get_timestamp)",
158
+ "subscribers": {},
159
+ "agent_types": {},
160
+ "config": {
161
+ "poll_interval_ms": 3000,
162
+ "heartbeat_timeout_ms": 30000
163
+ }
164
+ }
165
+ EOF
166
+
167
+ # Record initialization event
168
+ local today_file="$BUS_DIR/events/$(get_date).jsonl"
169
+ echo "{\"seq\":1,\"ts\":\"$(get_timestamp)\",\"type\":\"system\",\"event\":\"bus_created\",\"publisher\":\"system\",\"data\":{}}" >> "$today_file"
170
+
171
+ log_ok "Event bus initialized: $BUS_DIR"
172
+ }
173
+
174
+ # ============================================================================
175
+ # Command: join (join bus)
176
+ # ============================================================================
177
+
178
+ cmd_join() {
179
+ local session_id="${1:-}"
180
+ local agent_type="${2:-}"
181
+ local nickname="${3:-}"
182
+
183
+ ensure_bus
184
+
185
+ # If no session_id provided, try to get from env or generate
186
+ if [[ -z "$session_id" ]]; then
187
+ session_id="${CLAUDE_SESSION_ID:-${CODEX_SESSION_ID:-$(generate_instance_id)}}"
188
+ fi
189
+
190
+ # If no agent_type provided, auto-detect from env
191
+ if [[ -z "$agent_type" ]]; then
192
+ if [[ -n "${CODEX_SESSION_ID:-}" ]]; then
193
+ agent_type="codex"
194
+ else
195
+ agent_type="claude-code"
196
+ fi
197
+ fi
198
+
199
+ local subscriber="${agent_type}:${session_id}"
200
+ local safe_name
201
+ safe_name=$(subscriber_to_safe_name "$subscriber")
202
+
203
+ # Check if subscriber already exists (rejoin scenario)
204
+ local existing_nickname
205
+ existing_nickname=$(jq -r --arg id "$subscriber" '.subscribers[$id].nickname // ""' "$BUS_DIR/bus.json" 2>/dev/null)
206
+
207
+ # Handle nickname: reuse existing, auto-generate, or use provided
208
+ if [[ -n "$existing_nickname" ]]; then
209
+ # Subscriber already exists
210
+ if [[ -z "$nickname" ]]; then
211
+ # No nickname provided: reuse existing
212
+ nickname="$existing_nickname"
213
+ elif [[ "$nickname" != "$existing_nickname" ]]; then
214
+ # Different nickname provided: error (use cmd_rename instead)
215
+ log_error "Subscriber $subscriber already exists with nickname '$existing_nickname'"
216
+ log_error "To change nickname, use: bus rename $subscriber '$nickname'"
217
+ exit 1
218
+ fi
219
+ # else: same nickname provided, continue with rejoin
220
+ else
221
+ # New subscriber: auto-generate or validate provided nickname
222
+ if [[ -z "$nickname" ]]; then
223
+ local count
224
+ count=$(jq -r --arg type "$agent_type" \
225
+ '.subscribers | to_entries[] |
226
+ select(.value.agent_type == $type) | .key' \
227
+ "$BUS_DIR/bus.json" 2>/dev/null | wc -l | tr -d ' ')
228
+ nickname="${agent_type}-$((count + 1))"
229
+ fi
230
+
231
+ # Check nickname uniqueness for new subscribers
232
+ local existing
233
+ existing=$(resolve_nickname "$nickname" 2>/dev/null || echo "")
234
+ if [[ -n "$existing" && "$existing" != "$subscriber" ]]; then
235
+ log_error "Nickname '$nickname' already in use by $existing"
236
+ exit 1
237
+ fi
238
+ fi
239
+
240
+ log_info "Joining event bus: $subscriber (nickname: $nickname)"
241
+
242
+ # Set terminal window title with session id and nickname
243
+ if [[ -n "$nickname" ]]; then
244
+ local title="[bus:${session_id}] ${nickname}"
245
+ else
246
+ local title="[bus:${session_id}]"
247
+ fi
248
+
249
+ # Optional: set terminal title (for human identification; disabled by default to avoid stdout pollution)
250
+ if [[ "${AI_BUS_SET_TITLE:-0}" == "1" ]]; then
251
+ echo -ne "\\033]0;${title}\\007"
252
+ fi
253
+
254
+ # Update bus.json
255
+ local tmp_file
256
+ tmp_file=$(mktemp)
257
+
258
+ local tmux_pane="${TMUX_PANE:-}"
259
+ local tmux_session="${TMUX_SESSION:-}"
260
+ local tty_path=""
261
+ tty_path="$(tty 2>/dev/null || true)"
262
+ if [[ "$tty_path" == "not a tty" ]]; then
263
+ tty_path=""
264
+ fi
265
+
266
+ # Clean up old subscribers on same tty (avoid duplicate subscriptions)
267
+ if [[ -n "$tty_path" ]]; then
268
+ for old_queue in "$BUS_DIR/queues"/*/tty; do
269
+ if [[ -f "$old_queue" ]]; then
270
+ old_tty=$(cat "$old_queue")
271
+ if [[ "$old_tty" == "$tty_path" ]]; then
272
+ old_dir=$(dirname "$old_queue")
273
+ old_safe_name=$(basename "$old_dir")
274
+ old_subscriber="${old_safe_name/_/:}"
275
+ if [[ "$old_subscriber" != "$subscriber" ]]; then
276
+ log_info "Cleaning up old subscription on same tty: $old_subscriber"
277
+ rm -rf "$old_dir"
278
+ rm -f "$BUS_DIR/offsets/${old_safe_name}.offset"
279
+ # Remove from bus.json
280
+ jq --arg name "$old_subscriber" 'del(.subscribers[$name])' "$BUS_DIR/bus.json" > "$tmp_file" && mv "$tmp_file" "$BUS_DIR/bus.json"
281
+ tmp_file=$(mktemp)
282
+ fi
283
+ fi
284
+ fi
285
+ done
286
+ fi
287
+
288
+ jq --arg name "$subscriber" \
289
+ --arg agent_type "$agent_type" \
290
+ --arg instance_id "$session_id" \
291
+ --arg nickname "$nickname" \
292
+ --arg tmux_pane "$tmux_pane" \
293
+ --arg tmux_session "$tmux_session" \
294
+ --arg tty "$tty_path" \
295
+ --arg ts "$(get_timestamp)" \
296
+ --arg pid "${UFOO_PARENT_PID:-$PPID}" \
297
+ --arg cwd "$(pwd)" \
298
+ '
299
+ .subscribers[$name] = {
300
+ "agent_type": $agent_type,
301
+ "instance_id": $instance_id,
302
+ "nickname": $nickname,
303
+ "tmux_pane": (if $tmux_pane != "" then $tmux_pane else null end),
304
+ "tmux_session": (if $tmux_session != "" then $tmux_session else null end),
305
+ "tty": (if $tty != "" then $tty else null end),
306
+ "pid": ($pid | tonumber),
307
+ "joined_at": $ts,
308
+ "status": "active",
309
+ "last_heartbeat": $ts
310
+ }
311
+ |
312
+ .agent_types[$agent_type].instances = (
313
+ (.agent_types[$agent_type].instances // []) + [$instance_id] | unique
314
+ )
315
+ |
316
+ .agent_types[$agent_type].active_count = (
317
+ .subscribers | to_entries | map(select(.value.agent_type == $agent_type and .value.status == "active")) | length
318
+ )
319
+ ' "$BUS_DIR/bus.json" > "$tmp_file"
320
+
321
+ mv "$tmp_file" "$BUS_DIR/bus.json"
322
+
323
+ # Create offset file
324
+ cat > "$BUS_DIR/offsets/${safe_name}.offset" << EOF
325
+ {
326
+ "subscriber": "$subscriber",
327
+ "current_seq": 0,
328
+ "last_consumed_at": "$(get_timestamp)"
329
+ }
330
+ EOF
331
+
332
+ # Create queue directory
333
+ mkdir -p "$BUS_DIR/queues/${safe_name}"
334
+ # Best-effort: persist tty for other scripts (e.g. injection) without parsing bus.json
335
+ if [[ -n "$tty_path" ]]; then
336
+ echo "$tty_path" > "$BUS_DIR/queues/${safe_name}/tty"
337
+ fi
338
+
339
+ # Record tty device (for later inject targeting)
340
+ local current_tty
341
+ current_tty=$(tty 2>/dev/null || echo "")
342
+ if [[ -n "$current_tty" && "$current_tty" != "not a tty" ]]; then
343
+ echo "$current_tty" > "$BUS_DIR/queues/${safe_name}/tty"
344
+ fi
345
+
346
+ # Publish join event
347
+ local seq
348
+ seq=$(get_next_seq)
349
+ local today_file="$BUS_DIR/events/$(get_date).jsonl"
350
+ echo "{\"seq\":$seq,\"ts\":\"$(get_timestamp)\",\"type\":\"system\",\"event\":\"agent_joined\",\"publisher\":\"$subscriber\",\"data\":{\"agent_type\":\"$agent_type\",\"instance_id\":\"$session_id\"}}" >> "$today_file"
351
+
352
+ log_ok "Joined event bus"
353
+ echo ""
354
+ echo -e "${CYAN}My identity: $subscriber${NC}"
355
+ echo ""
356
+
357
+ # Check pending events
358
+ cmd_check "$subscriber"
359
+
360
+ # Output subscriber ID
361
+ echo "$subscriber"
362
+ }
363
+
364
+ # ============================================================================
365
+ # Command: check (check pending events)
366
+ # ============================================================================
367
+
368
+ cmd_check() {
369
+ local subscriber="${1:-}"
370
+ local auto_ack="${2:-}"
371
+
372
+ if [[ -z "$subscriber" ]]; then
373
+ log_error "Usage: bus check <subscriber-id> [--ack]"
374
+ exit 1
375
+ fi
376
+
377
+ ensure_bus
378
+
379
+ local safe_name
380
+ safe_name=$(subscriber_to_safe_name "$subscriber")
381
+ local queue_file="$BUS_DIR/queues/${safe_name}/pending.jsonl"
382
+
383
+ if [[ -f "$queue_file" && -s "$queue_file" ]]; then
384
+ local count
385
+ count=$(wc -l < "$queue_file" | tr -d ' ')
386
+ log_warn "You have $count pending event(s):"
387
+ echo ""
388
+ while IFS= read -r event; do
389
+ local publisher type event_name data
390
+ publisher=$(echo "$event" | jq -r '.publisher')
391
+ type=$(echo "$event" | jq -r '.type')
392
+ event_name=$(echo "$event" | jq -r '.event')
393
+ data=$(echo "$event" | jq -c '.data')
394
+ echo -e " ${YELLOW}@you${NC} from ${CYAN}$publisher${NC}"
395
+ echo -e " Type: $type/$event_name"
396
+ echo -e " Content: $data"
397
+ echo ""
398
+ done < "$queue_file"
399
+
400
+ # Auto-ack if requested, or show hint
401
+ if [[ "$auto_ack" == "--ack" ]]; then
402
+ : > "$queue_file"
403
+ log_ok "Messages acknowledged and cleared"
404
+ else
405
+ echo -e "${CYAN}After handling, run: ufoo bus ack $subscriber${NC}"
406
+ fi
407
+ else
408
+ log_ok "No pending events"
409
+ fi
410
+ }
411
+
412
+ # ============================================================================
413
+ # Command: ack (acknowledge/clear pending messages)
414
+ # ============================================================================
415
+
416
+ cmd_ack() {
417
+ local subscriber="${1:-}"
418
+
419
+ if [[ -z "$subscriber" ]]; then
420
+ log_error "Usage: bus ack <subscriber-id>"
421
+ exit 1
422
+ fi
423
+
424
+ ensure_bus
425
+
426
+ local safe_name
427
+ safe_name=$(subscriber_to_safe_name "$subscriber")
428
+ local queue_file="$BUS_DIR/queues/${safe_name}/pending.jsonl"
429
+
430
+ if [[ -f "$queue_file" && -s "$queue_file" ]]; then
431
+ local count
432
+ count=$(wc -l < "$queue_file" | tr -d ' ')
433
+ # Clear the queue
434
+ : > "$queue_file"
435
+ log_ok "Acknowledged and cleared $count message(s)"
436
+ else
437
+ log_ok "No pending messages to acknowledge"
438
+ fi
439
+ }
440
+
441
+ # ============================================================================
442
+ # Command: send (send targeted message)
443
+ # ============================================================================
444
+
445
+ cmd_send() {
446
+ local target="${1:-}"
447
+ local message="${2:-}"
448
+
449
+ # Auto-detect publisher: prefer env var, otherwise build from session ID
450
+ local publisher="${AI_BUS_PUBLISHER:-}"
451
+ if [[ -z "$publisher" ]]; then
452
+ if [[ -n "${CODEX_SESSION_ID:-}" ]]; then
453
+ publisher="codex:${CODEX_SESSION_ID}"
454
+ elif [[ -n "${CLAUDE_SESSION_ID:-}" ]]; then
455
+ publisher="claude-code:${CLAUDE_SESSION_ID}"
456
+ else
457
+ publisher="unknown"
458
+ fi
459
+ fi
460
+
461
+ if [[ -z "$target" || -z "$message" ]]; then
462
+ log_error "Usage: context-bus send <target> <message>"
463
+ log_error "Example: context-bus send claude-code:abc123 'Please help me review'"
464
+ exit 1
465
+ fi
466
+
467
+ ensure_bus
468
+
469
+ local seq
470
+ seq=$(get_next_seq)
471
+ local today_file="$BUS_DIR/events/$(get_date).jsonl"
472
+
473
+ # Build event
474
+ local event_json
475
+ event_json=$(jq -cn \
476
+ --argjson seq "$seq" \
477
+ --arg ts "$(get_timestamp)" \
478
+ --arg publisher "$publisher" \
479
+ --arg target "$target" \
480
+ --arg message "$message" \
481
+ '{
482
+ seq: $seq,
483
+ ts: $ts,
484
+ type: "message",
485
+ event: "targeted",
486
+ publisher: $publisher,
487
+ target: $target,
488
+ data: { message: $message }
489
+ }')
490
+
491
+ echo "$event_json" >> "$today_file"
492
+
493
+ # Write to target queue
494
+ local matching_subscribers
495
+ matching_subscribers=$(jq -r '.subscribers | keys[]' "$BUS_DIR/bus.json")
496
+
497
+ for sub in $matching_subscribers; do
498
+ if target_matches "$target" "$sub"; then
499
+ local safe_name
500
+ safe_name=$(subscriber_to_safe_name "$sub")
501
+ mkdir -p "$BUS_DIR/queues/${safe_name}"
502
+ echo "$event_json" >> "$BUS_DIR/queues/${safe_name}/pending.jsonl"
503
+ fi
504
+ done
505
+
506
+ log_ok "Message sent: seq=$seq -> $target"
507
+ }
508
+
509
+ # ============================================================================
510
+ # Command: broadcast (broadcast message)
511
+ # ============================================================================
512
+
513
+ cmd_broadcast() {
514
+ local message="${1:-}"
515
+ local publisher="${AI_BUS_PUBLISHER:-unknown}"
516
+
517
+ if [[ -z "$message" ]]; then
518
+ log_error "Usage: context-bus broadcast <message>"
519
+ exit 1
520
+ fi
521
+
522
+ ensure_bus
523
+
524
+ local seq
525
+ seq=$(get_next_seq)
526
+ local today_file="$BUS_DIR/events/$(get_date).jsonl"
527
+
528
+ local event_json
529
+ event_json=$(jq -cn \
530
+ --argjson seq "$seq" \
531
+ --arg ts "$(get_timestamp)" \
532
+ --arg publisher "$publisher" \
533
+ --arg message "$message" \
534
+ '{
535
+ seq: $seq,
536
+ ts: $ts,
537
+ type: "message",
538
+ event: "broadcast",
539
+ publisher: $publisher,
540
+ data: { message: $message }
541
+ }')
542
+
543
+ echo "$event_json" >> "$today_file"
544
+
545
+ # Fan out broadcast to all subscriber queues
546
+ local matching_subscribers
547
+ matching_subscribers=$(jq -r '.subscribers | keys[]' "$BUS_DIR/bus.json")
548
+
549
+ for sub in $matching_subscribers; do
550
+ local safe_name
551
+ safe_name=$(subscriber_to_safe_name "$sub")
552
+ mkdir -p "$BUS_DIR/queues/${safe_name}"
553
+ echo "$event_json" >> "$BUS_DIR/queues/${safe_name}/pending.jsonl"
554
+ done
555
+
556
+ log_ok "Broadcast sent: seq=$seq"
557
+ }
558
+
559
+ # ============================================================================
560
+ # Command: status
561
+ # ============================================================================
562
+
563
+ cmd_status() {
564
+ ensure_bus
565
+
566
+ echo ""
567
+ echo -e "${CYAN}=== Event Bus Status ===${NC}"
568
+ echo ""
569
+
570
+ local bus_id
571
+ bus_id=$(jq -r '.bus_id' "$BUS_DIR/bus.json")
572
+ echo "Bus ID: $bus_id"
573
+ echo ""
574
+
575
+ echo -e "${CYAN}Online subscribers:${NC}"
576
+ local online=()
577
+ while IFS=$'\t' read -r sub_id sub_pid sub_nick; do
578
+ [[ -z "$sub_id" ]] && continue
579
+ if is_pid_alive "$sub_pid"; then
580
+ if [[ -n "$sub_nick" && "$sub_nick" != "null" ]]; then
581
+ online+=("$sub_id ($sub_nick)")
582
+ else
583
+ online+=("$sub_id")
584
+ fi
585
+ fi
586
+ done < <(jq -r '.subscribers | to_entries[] | select(.value.status == "active") | "\(.key)\t\(.value.pid // 0)\t\(.value.nickname // "")"' "$BUS_DIR/bus.json")
587
+ if [[ ${#online[@]} -eq 0 ]]; then
588
+ echo " (none)"
589
+ else
590
+ printf " %s\n" "${online[@]}"
591
+ fi
592
+ echo ""
593
+
594
+ echo -e "${CYAN}Event statistics:${NC}"
595
+ local total=0
596
+ shopt -s nullglob
597
+ for f in "$BUS_DIR/events"/*.jsonl; do
598
+ if [[ -f "$f" ]]; then
599
+ local count
600
+ count=$(wc -l < "$f" | tr -d ' ')
601
+ total=$((total + count))
602
+ echo " $(basename "$f"): $count events"
603
+ fi
604
+ done
605
+ echo " Total: $total events"
606
+ }
607
+
608
+ # ============================================================================
609
+ # Command: consume
610
+ # ============================================================================
611
+
612
+ cmd_consume() {
613
+ local subscriber="${1:-}"
614
+ local limit="${2:-10}"
615
+
616
+ if [[ -z "$subscriber" ]]; then
617
+ log_error "Usage: context-bus consume <subscriber-id> [limit]"
618
+ exit 1
619
+ fi
620
+
621
+ ensure_bus
622
+
623
+ local safe_name
624
+ safe_name=$(subscriber_to_safe_name "$subscriber")
625
+ local offset_file="$BUS_DIR/offsets/${safe_name}.offset"
626
+
627
+ if [[ ! -f "$offset_file" ]]; then
628
+ log_error "Subscriber not registered: $subscriber"
629
+ exit 1
630
+ fi
631
+
632
+ local current_seq
633
+ current_seq=$(jq -r '.current_seq' "$offset_file")
634
+
635
+ local events=()
636
+ local max_seq=$current_seq
637
+
638
+ shopt -s nullglob
639
+ for event_file in "$BUS_DIR/events"/*.jsonl; do
640
+ while IFS= read -r line; do
641
+ local seq target
642
+ seq=$(echo "$line" | jq -r '.seq')
643
+ target=$(echo "$line" | jq -r '.target // ""')
644
+
645
+ if [[ $seq -gt $current_seq ]]; then
646
+ if target_matches "$target" "$subscriber"; then
647
+ events+=("$line")
648
+ [[ $seq -gt $max_seq ]] && max_seq=$seq
649
+ fi
650
+ fi
651
+ done < "$event_file"
652
+ done
653
+
654
+ local count=0
655
+ for event in "${events[@]}"; do
656
+ [[ $count -ge $limit ]] && break
657
+ echo "$event"
658
+ ((count++))
659
+ done
660
+
661
+ if [[ $max_seq -gt $current_seq ]]; then
662
+ jq --argjson seq "$max_seq" --arg ts "$(get_timestamp)" \
663
+ '.current_seq = $seq | .last_consumed_at = $ts' \
664
+ "$offset_file" > "${offset_file}.tmp"
665
+ mv "${offset_file}.tmp" "$offset_file"
666
+ fi
667
+ }
668
+
669
+ # ============================================================================
670
+ # Command: resolve (smart routing - find target agent)
671
+ # ============================================================================
672
+
673
+ cmd_resolve() {
674
+ local my_id="${1:-}"
675
+ local target_type="${2:-}"
676
+
677
+ if [[ -z "$my_id" || -z "$target_type" ]]; then
678
+ log_error "Usage: bus resolve <my-subscriber-id> <target-type>"
679
+ log_error "Example: bus resolve claude-code:abc123 codex"
680
+ exit 1
681
+ fi
682
+
683
+ ensure_bus
684
+
685
+ echo ""
686
+ echo -e "${CYAN}=== Smart Routing: Finding $target_type ===${NC}"
687
+ echo ""
688
+
689
+ # Get all active subscribers of target type (excluding myself) that are currently online.
690
+ local candidates=()
691
+ while IFS=$'\t' read -r candidate_id candidate_pid; do
692
+ [[ -z "$candidate_id" ]] && continue
693
+ if is_pid_alive "$candidate_pid"; then
694
+ candidates+=("$candidate_id")
695
+ fi
696
+ done < <(jq -r --arg type "$target_type" --arg me "$my_id" '
697
+ .subscribers | to_entries[] |
698
+ select(.value.agent_type == $type and .key != $me and .value.status == "active") |
699
+ "\(.key)\t\(.value.pid // 0)"
700
+ ' "$BUS_DIR/bus.json")
701
+
702
+ if [[ ${#candidates[@]} -eq 0 ]]; then
703
+ log_warn "No online $target_type agents found"
704
+ echo ""
705
+ echo "RESULT: none"
706
+ return 0
707
+ fi
708
+
709
+ # Count candidates
710
+ local count
711
+ count=${#candidates[@]}
712
+
713
+ if [[ "$count" -eq 1 ]]; then
714
+ echo -e "${GREEN}Only one $target_type found:${NC} ${candidates[0]}"
715
+ echo ""
716
+ echo "RESULT: ${candidates[0]}"
717
+ return 0
718
+ fi
719
+
720
+ # Multiple candidates - show each with message history
721
+ echo -e "${YELLOW}Multiple $target_type agents found ($count):${NC}"
722
+ echo ""
723
+
724
+ for candidate in "${candidates[@]}"; do
725
+ local nickname
726
+ nickname=$(jq -r --arg id "$candidate" '.subscribers[$id].nickname // ""' "$BUS_DIR/bus.json")
727
+ local joined_at
728
+ joined_at=$(jq -r --arg id "$candidate" '.subscribers[$id].joined_at // ""' "$BUS_DIR/bus.json")
729
+
730
+ echo -e "${CYAN}[$candidate]${NC}"
731
+ if [[ -n "$nickname" && "$nickname" != "null" ]]; then
732
+ echo " Nickname: $nickname"
733
+ fi
734
+ echo " Joined: $joined_at"
735
+
736
+ # Find recent message history with this candidate
737
+ echo " Recent messages:"
738
+ local msg_count=0
739
+ shopt -s nullglob
740
+ for event_file in "$BUS_DIR/events"/*.jsonl; do
741
+ while IFS= read -r line; do
742
+ local publisher target msg_preview
743
+ publisher=$(echo "$line" | jq -r '.publisher // ""')
744
+ target=$(echo "$line" | jq -r '.target // ""')
745
+
746
+ # Check if this message involves both my_id and candidate
747
+ if [[ ("$publisher" == "$my_id" && "$target" == "$candidate") || \
748
+ ("$publisher" == "$candidate" && "$target" == "$my_id") ]]; then
749
+ msg_preview=$(echo "$line" | jq -r '.data.message // "" | .[0:80]')
750
+ local direction
751
+ if [[ "$publisher" == "$my_id" ]]; then
752
+ direction="→ sent"
753
+ else
754
+ direction="← recv"
755
+ fi
756
+ echo " $direction: $msg_preview..."
757
+ ((msg_count++))
758
+ if [[ $msg_count -ge 3 ]]; then
759
+ break 2
760
+ fi
761
+ fi
762
+ done < "$event_file"
763
+ done
764
+
765
+ if [[ $msg_count -eq 0 ]]; then
766
+ echo " (no message history)"
767
+ fi
768
+ echo ""
769
+ done
770
+
771
+ echo "---"
772
+ echo "CANDIDATES: ${candidates[*]}"
773
+ echo ""
774
+ echo ""
775
+ echo -e "${CYAN}Hint: Use message history and context to choose the right target.${NC}"
776
+ echo "If unsure, you can broadcast to all: ufoo bus send \"$target_type\" \"message\""
777
+ }
778
+
779
+ # ============================================================================
780
+ # Command: rename (set/change nickname)
781
+ # ============================================================================
782
+
783
+ cmd_rename() {
784
+ local subscriber="${1:-}"
785
+ local new_nickname="${2:-}"
786
+
787
+ if [[ -z "$subscriber" || -z "$new_nickname" ]]; then
788
+ log_error "Usage: bus rename <subscriber-id> <new-nickname>"
789
+ log_error "Example: bus rename claude-code:abc123 'architect'"
790
+ exit 1
791
+ fi
792
+
793
+ ensure_bus
794
+
795
+ # Check subscriber exists
796
+ local exists
797
+ exists=$(jq -r --arg id "$subscriber" '.subscribers[$id] // empty' "$BUS_DIR/bus.json")
798
+ if [[ -z "$exists" ]]; then
799
+ log_error "Subscriber not found: $subscriber"
800
+ exit 1
801
+ fi
802
+
803
+ # Check nickname uniqueness
804
+ local existing
805
+ existing=$(resolve_nickname "$new_nickname" 2>/dev/null || echo "")
806
+ if [[ -n "$existing" && "$existing" != "$subscriber" ]]; then
807
+ log_error "Nickname '$new_nickname' already in use by $existing"
808
+ exit 1
809
+ fi
810
+
811
+ # Get old nickname
812
+ local old_nickname
813
+ old_nickname=$(jq -r --arg id "$subscriber" '.subscribers[$id].nickname // ""' "$BUS_DIR/bus.json")
814
+
815
+ # Update nickname
816
+ local tmp_file
817
+ tmp_file=$(mktemp)
818
+
819
+ jq --arg id "$subscriber" \
820
+ --arg nick "$new_nickname" \
821
+ '.subscribers[$id].nickname = $nick' \
822
+ "$BUS_DIR/bus.json" > "$tmp_file"
823
+
824
+ mv "$tmp_file" "$BUS_DIR/bus.json"
825
+
826
+ # Publish rename event
827
+ local seq
828
+ seq=$(get_next_seq)
829
+ local today_file="$BUS_DIR/events/$(get_date).jsonl"
830
+
831
+ local event_json
832
+ event_json=$(jq -cn \
833
+ --argjson seq "$seq" \
834
+ --arg ts "$(get_timestamp)" \
835
+ --arg subscriber "$subscriber" \
836
+ --arg old_nick "$old_nickname" \
837
+ --arg new_nick "$new_nickname" \
838
+ '{
839
+ seq: $seq,
840
+ ts: $ts,
841
+ type: "system",
842
+ event: "agent_renamed",
843
+ publisher: $subscriber,
844
+ data: {
845
+ subscriber: $subscriber,
846
+ old_nickname: $old_nick,
847
+ new_nickname: $new_nick
848
+ }
849
+ }')
850
+
851
+ echo "$event_json" >> "$today_file"
852
+
853
+ if [[ -n "$old_nickname" ]]; then
854
+ log_ok "Renamed $subscriber: '$old_nickname' -> '$new_nickname'"
855
+ else
856
+ log_ok "Set nickname for $subscriber: '$new_nickname'"
857
+ fi
858
+ }
859
+
860
+ # ============================================================================
861
+ # Command: leave
862
+ # ============================================================================
863
+
864
+ cmd_leave() {
865
+ local subscriber="${1:-}"
866
+
867
+ if [[ -z "$subscriber" ]]; then
868
+ log_error "Usage: context-bus leave <subscriber-id>"
869
+ exit 1
870
+ fi
871
+
872
+ ensure_bus
873
+
874
+ log_info "Leaving event bus: $subscriber"
875
+
876
+ # Update status to offline
877
+ local tmp_file
878
+ tmp_file=$(mktemp)
879
+
880
+ jq --arg name "$subscriber" \
881
+ '.subscribers[$name].status = "offline"' \
882
+ "$BUS_DIR/bus.json" > "$tmp_file"
883
+
884
+ mv "$tmp_file" "$BUS_DIR/bus.json"
885
+
886
+ log_ok "Left event bus"
887
+ }
888
+
889
+ # ============================================================================
890
+ # Command: alert/listen/autotrigger (helpers)
891
+ # ============================================================================
892
+
893
+ cmd_alert() {
894
+ local subscriber="${1:-}"
895
+ if [[ -z "$subscriber" ]]; then
896
+ log_error "Usage: bus alert <subscriber-id> [interval] [--notify|--daemon|--stop|...]"
897
+ exit 1
898
+ fi
899
+ if [[ ! -x "$SCRIPT_DIR/bus-alert.sh" ]]; then
900
+ log_error "Missing script: $SCRIPT_DIR/bus-alert.sh"
901
+ exit 1
902
+ fi
903
+ exec bash "$SCRIPT_DIR/bus-alert.sh" "$@"
904
+ }
905
+
906
+ cmd_listen() {
907
+ local subscriber="${1:-}"
908
+ if [[ -z "$subscriber" ]]; then
909
+ log_error "Usage: bus listen <subscriber-id> [--from-beginning|--reset|...]"
910
+ exit 1
911
+ fi
912
+ if [[ ! -x "$SCRIPT_DIR/bus-listen.sh" ]]; then
913
+ log_error "Missing script: $SCRIPT_DIR/bus-listen.sh"
914
+ exit 1
915
+ fi
916
+ exec bash "$SCRIPT_DIR/bus-listen.sh" "$@"
917
+ }
918
+
919
+ cmd_autotrigger() {
920
+ if [[ ! -x "$SCRIPT_DIR/bus-autotrigger.sh" ]]; then
921
+ log_error "Missing script: $SCRIPT_DIR/bus-autotrigger.sh"
922
+ exit 1
923
+ fi
924
+ exec bash "$SCRIPT_DIR/bus-autotrigger.sh" "$@"
925
+ }
926
+
927
+ # ============================================================================
928
+ # Main entry
929
+ # ============================================================================
930
+
931
+ main() {
932
+ local cmd="${1:-help}"
933
+ shift || true
934
+
935
+ case "$cmd" in
936
+ init) cmd_init "$@" ;;
937
+ join) cmd_join "$@" ;;
938
+ check) cmd_check "$@" ;;
939
+ ack) cmd_ack "$@" ;;
940
+ send) cmd_send "$@" ;;
941
+ broadcast) cmd_broadcast "$@" ;;
942
+ status) cmd_status "$@" ;;
943
+ consume) cmd_consume "$@" ;;
944
+ resolve) cmd_resolve "$@" ;;
945
+ rename|nick) cmd_rename "$@" ;;
946
+ leave) cmd_leave "$@" ;;
947
+ alert) cmd_alert "$@" ;;
948
+ listen) cmd_listen "$@" ;;
949
+ autotrigger) cmd_autotrigger "$@" ;;
950
+ help|--help|-h)
951
+ echo "bus - Project-level Agent event bus"
952
+ echo ""
953
+ echo "Usage: bus <command> [options]"
954
+ echo ""
955
+ echo "Commands:"
956
+ echo " init Initialize event bus"
957
+ echo " join [session-id] [type] [nick] Join bus (auto-generates nickname if omitted)"
958
+ echo " check <subscriber> Check pending events"
959
+ echo " ack <subscriber> Acknowledge and clear pending messages"
960
+ echo " resolve <my-id> <target-type> Smart routing: find target agent"
961
+ echo " rename <subscriber> <nickname> Set/change agent nickname"
962
+ echo " send <target> <message> Send targeted message (supports nickname)"
963
+ echo " broadcast <message> Broadcast message"
964
+ echo " status View bus status"
965
+ echo " consume <subscriber> Consume events"
966
+ echo " leave <subscriber> Leave bus"
967
+ echo " alert <subscriber> Background alerts (no auto-execute)"
968
+ echo " listen <subscriber> Foreground listener, print new messages"
969
+ echo " autotrigger start|stop|status Unattended auto-execute (tmux)"
970
+ echo ""
971
+ echo "Examples:"
972
+ echo " bus join abc123 claude-code \"architect\""
973
+ echo " bus rename claude-code:abc123 \"dev-lead\""
974
+ echo " bus send architect \"Please help me review\""
975
+ echo " bus send claude-code:abc123 \"Please help me review\""
976
+ ;;
977
+ *)
978
+ log_error "Unknown command: $cmd"
979
+ exit 1
980
+ ;;
981
+ esac
982
+ }
983
+
984
+ main "$@"