kanibako-cli 1.5.0.dev14__py3-none-any.whl

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 (85) hide show
  1. kanibako/__init__.py +3 -0
  2. kanibako/__main__.py +6 -0
  3. kanibako/auth_browser.py +296 -0
  4. kanibako/auth_parser.py +51 -0
  5. kanibako/browser_sidecar.py +183 -0
  6. kanibako/browser_state.py +103 -0
  7. kanibako/bun_sea.py +144 -0
  8. kanibako/cli.py +344 -0
  9. kanibako/commands/__init__.py +0 -0
  10. kanibako/commands/archive.py +228 -0
  11. kanibako/commands/box/__init__.py +22 -0
  12. kanibako/commands/box/_duplicate.py +395 -0
  13. kanibako/commands/box/_migrate.py +574 -0
  14. kanibako/commands/box/_parser.py +1178 -0
  15. kanibako/commands/clean.py +166 -0
  16. kanibako/commands/crab_cmd.py +480 -0
  17. kanibako/commands/diagnose.py +239 -0
  18. kanibako/commands/fork_cmd.py +51 -0
  19. kanibako/commands/helper_cmd.py +669 -0
  20. kanibako/commands/image.py +1300 -0
  21. kanibako/commands/install.py +152 -0
  22. kanibako/commands/refresh_credentials.py +67 -0
  23. kanibako/commands/restore.py +298 -0
  24. kanibako/commands/setup_cmd.py +89 -0
  25. kanibako/commands/start.py +1600 -0
  26. kanibako/commands/stop.py +116 -0
  27. kanibako/commands/system_cmd.py +224 -0
  28. kanibako/commands/upgrade.py +161 -0
  29. kanibako/commands/vault_cmd.py +199 -0
  30. kanibako/commands/workset_cmd.py +552 -0
  31. kanibako/config.py +514 -0
  32. kanibako/config_interface.py +573 -0
  33. kanibako/config_io.py +36 -0
  34. kanibako/container.py +607 -0
  35. kanibako/containerfiles.py +58 -0
  36. kanibako/containers/Containerfile.kanibako +99 -0
  37. kanibako/containers/Containerfile.template-android +55 -0
  38. kanibako/containers/Containerfile.template-dotnet +29 -0
  39. kanibako/containers/Containerfile.template-js +43 -0
  40. kanibako/containers/Containerfile.template-jvm +27 -0
  41. kanibako/containers/Containerfile.template-systems +46 -0
  42. kanibako/containers/__init__.py +0 -0
  43. kanibako/crabs.py +89 -0
  44. kanibako/errors.py +33 -0
  45. kanibako/freshness.py +67 -0
  46. kanibako/git.py +114 -0
  47. kanibako/helper_client.py +132 -0
  48. kanibako/helper_listener.py +538 -0
  49. kanibako/helpers.py +339 -0
  50. kanibako/hygiene.py +296 -0
  51. kanibako/image_sharing.py +133 -0
  52. kanibako/instructions.py +160 -0
  53. kanibako/log.py +31 -0
  54. kanibako/names.py +248 -0
  55. kanibako/paths.py +1483 -0
  56. kanibako/plugins/__init__.py +10 -0
  57. kanibako/registry.py +71 -0
  58. kanibako/rig_bundle.py +121 -0
  59. kanibako/rig_meta.py +92 -0
  60. kanibako/rig_registry.py +132 -0
  61. kanibako/rig_resolve.py +182 -0
  62. kanibako/rig_source.py +245 -0
  63. kanibako/scripts/__init__.py +0 -0
  64. kanibako/scripts/helper-init.sh +45 -0
  65. kanibako/scripts/kanibako-entry +12 -0
  66. kanibako/settings_resolve.py +312 -0
  67. kanibako/settings_seeds.py +154 -0
  68. kanibako/settings_shares.py +154 -0
  69. kanibako/shellenv.py +75 -0
  70. kanibako/snapshots.py +281 -0
  71. kanibako/targets/__init__.py +173 -0
  72. kanibako/targets/base.py +243 -0
  73. kanibako/targets/no_agent.py +58 -0
  74. kanibako/templates.py +60 -0
  75. kanibako/templates_image.py +224 -0
  76. kanibako/tweakcc.py +140 -0
  77. kanibako/tweakcc_cache.py +171 -0
  78. kanibako/utils.py +136 -0
  79. kanibako/workset.py +347 -0
  80. kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
  81. kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
  82. kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
  83. kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
  84. kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
  85. kanibako_cli-1.5.0.dev14.dist-info/top_level.txt +1 -0
@@ -0,0 +1,669 @@
1
+ """kanibako helper: spawn and manage child kanibako instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import json
7
+ import shutil
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ from kanibako.config import config_file_path
12
+ from kanibako.helpers import (
13
+ SpawnBudget,
14
+ check_spawn_allowed,
15
+ child_budget,
16
+ create_broadcast_dirs,
17
+ create_helper_dirs,
18
+ create_peer_channels,
19
+ link_broadcast,
20
+ read_spawn_config,
21
+ remove_helper_dirs,
22
+ resolve_init_script,
23
+ resolve_spawn_budget,
24
+ write_spawn_config,
25
+ )
26
+ from kanibako.paths import xdg
27
+
28
+
29
+ def add_helper_subparsers(p: argparse.ArgumentParser) -> None:
30
+ """Register helper subcommands on the given parser.
31
+
32
+ Called by crab_cmd.py to nest helpers under ``kanibako crab helper``.
33
+ """
34
+ ss = p.add_subparsers(dest="helper_command", metavar="COMMAND")
35
+
36
+ # helper spawn
37
+ spawn_p = ss.add_parser(
38
+ "spawn",
39
+ help="Spawn a new helper instance",
40
+ description="Create and launch a new child kanibako instance.",
41
+ )
42
+ spawn_p.add_argument(
43
+ "--depth", type=int, default=None,
44
+ help="Spawn depth limit for the child (only if no config override)",
45
+ )
46
+ spawn_p.add_argument(
47
+ "--breadth", type=int, default=None,
48
+ help="Spawn breadth limit for the child (only if no config override)",
49
+ )
50
+ spawn_p.add_argument(
51
+ "--model", default=None, metavar="VARIANT",
52
+ help="Override model variant for the child (e.g. sonnet)",
53
+ )
54
+ spawn_p.set_defaults(func=run_spawn)
55
+
56
+ # helper list
57
+ list_p = ss.add_parser(
58
+ "list",
59
+ aliases=["ls"],
60
+ help="List active helpers",
61
+ description="Show all helper instances and their status.",
62
+ )
63
+ list_p.add_argument(
64
+ "-q", "--quiet", action="store_true",
65
+ help="Print only helper numbers, one per line",
66
+ )
67
+ list_p.set_defaults(func=run_list)
68
+
69
+ # helper stop <N>
70
+ stop_p = ss.add_parser(
71
+ "stop",
72
+ help="Stop a helper instance",
73
+ description="Stop a running helper container.",
74
+ )
75
+ stop_p.add_argument("number", type=int, help="Helper number to stop")
76
+ stop_p.set_defaults(func=run_stop)
77
+
78
+ # helper cleanup <N>
79
+ cleanup_p = ss.add_parser(
80
+ "cleanup",
81
+ help="Stop and remove a helper",
82
+ description="Stop a helper and remove its directory structure and peer channels.",
83
+ )
84
+ cleanup_p.add_argument("number", type=int, help="Helper number to clean up")
85
+ cleanup_p.add_argument(
86
+ "--cascade", action="store_true",
87
+ help="Also remove all descendant helpers recursively",
88
+ )
89
+ cleanup_p.set_defaults(func=run_cleanup)
90
+
91
+ # helper respawn <N>
92
+ respawn_p = ss.add_parser(
93
+ "respawn",
94
+ help="Relaunch a stopped helper",
95
+ description="Relaunch a previously stopped helper (same number, same directories).",
96
+ )
97
+ respawn_p.add_argument("number", type=int, help="Helper number to respawn")
98
+ respawn_p.set_defaults(func=run_respawn)
99
+
100
+ # helper send <N> <message>
101
+ send_p = ss.add_parser(
102
+ "send",
103
+ help="Send a message to a helper",
104
+ description="Send a message to a specific helper by number.",
105
+ )
106
+ send_p.add_argument("number", type=int, help="Helper number to send to")
107
+ send_p.add_argument("message", help="Message text to send")
108
+ send_p.set_defaults(func=run_send)
109
+
110
+ # helper broadcast <message>
111
+ bcast_p = ss.add_parser(
112
+ "broadcast",
113
+ help="Broadcast a message to all helpers",
114
+ description="Send a message to all connected helpers.",
115
+ )
116
+ bcast_p.add_argument("message", help="Message text to broadcast")
117
+ bcast_p.set_defaults(func=run_broadcast)
118
+
119
+ # helper log
120
+ log_p = ss.add_parser(
121
+ "log",
122
+ help="View inter-agent message log",
123
+ description="Display the JSONL message log in human-readable format.",
124
+ )
125
+ log_p.add_argument(
126
+ "--follow", "-f", action="store_true",
127
+ help="Follow log output (like tail -f)",
128
+ )
129
+ log_p.add_argument(
130
+ "--from", type=int, default=None, dest="from_helper",
131
+ help="Filter messages from a specific helper number",
132
+ )
133
+ log_p.add_argument(
134
+ "--last", type=int, default=None,
135
+ help="Show only the last N entries",
136
+ )
137
+ log_p.set_defaults(func=run_log)
138
+
139
+ # helper register <N> (used by helper-init.sh, hidden from help)
140
+ register_p = ss.add_parser(
141
+ "register",
142
+ help=argparse.SUPPRESS,
143
+ description="Register this helper with the hub (used by helper-init.sh).",
144
+ )
145
+ register_p.add_argument("number", type=int, help="Helper number to register")
146
+ register_p.set_defaults(func=run_register)
147
+
148
+ p.set_defaults(func=run_list)
149
+
150
+
151
+ def _helpers_dir() -> Path:
152
+ """Return the helpers directory for the current session."""
153
+ return Path.home() / "helpers"
154
+
155
+
156
+ def _socket_path() -> Path:
157
+ """Return the path to the helper hub socket."""
158
+ return Path.home() / ".local" / "state" / "kanibako" / "helper.sock"
159
+
160
+
161
+ def _check_helpers_enabled() -> bool:
162
+ """Check if the helper socket exists (helpers are enabled)."""
163
+ return _socket_path().exists()
164
+
165
+
166
+ def _ro_spawn_config_path(helpers_dir: Path, helper_num: int) -> Path:
167
+ """Return the path to a helper's RO spawn config."""
168
+ return helpers_dir / str(helper_num) / "spawn.yaml"
169
+
170
+
171
+ def _state_path(helpers_dir: Path, helper_num: int) -> Path:
172
+ """Return the path to a helper's state file."""
173
+ return helpers_dir / str(helper_num) / "state.json"
174
+
175
+
176
+ def _read_state(helpers_dir: Path, helper_num: int) -> dict:
177
+ """Read a helper's state file. Returns empty dict if absent."""
178
+ path = _state_path(helpers_dir, helper_num)
179
+ if not path.is_file():
180
+ return {}
181
+ with open(path) as f:
182
+ return json.load(f)
183
+
184
+
185
+ def _write_state(helpers_dir: Path, helper_num: int, state: dict) -> None:
186
+ """Write a helper's state file."""
187
+ path = _state_path(helpers_dir, helper_num)
188
+ path.parent.mkdir(parents=True, exist_ok=True)
189
+ with open(path, "w") as f:
190
+ json.dump(state, f, indent=2)
191
+ f.write("\n")
192
+
193
+
194
+ def _get_existing_helpers(helpers_dir: Path) -> list[int]:
195
+ """Scan helpers/ for existing helper directories (numeric names)."""
196
+ if not helpers_dir.is_dir():
197
+ return []
198
+ result = []
199
+ for child in helpers_dir.iterdir():
200
+ if child.is_dir() and child.name.isdigit():
201
+ result.append(int(child.name))
202
+ return sorted(result)
203
+
204
+
205
+ def _next_helper_number(existing: list[int], budget: SpawnBudget) -> int:
206
+ """Determine the next helper number (first unused slot)."""
207
+ used = set(existing)
208
+ # Sequentially find the next unused number starting from 1
209
+ # (0 is reserved for the director)
210
+ n = 1
211
+ while n in used:
212
+ n += 1
213
+ return n
214
+
215
+
216
+ def run_spawn(args: argparse.Namespace) -> int:
217
+ """Spawn a new helper instance."""
218
+ helpers_dir = _helpers_dir()
219
+
220
+ # Resolve own spawn budget
221
+ config_file = config_file_path(xdg("XDG_CONFIG_HOME", ".config"))
222
+ host_budget = None
223
+ ro_budget = None
224
+
225
+ # Check for RO spawn config (set by parent, if we are a helper)
226
+ own_ro_config = Path.home() / "spawn.yaml"
227
+ if own_ro_config.is_file():
228
+ ro_budget = read_spawn_config(own_ro_config)
229
+
230
+ # Check host config
231
+ if config_file.is_file():
232
+ host_budget = read_spawn_config(config_file)
233
+
234
+ budget = resolve_spawn_budget(
235
+ ro_budget, host_budget, args.depth, args.breadth,
236
+ )
237
+
238
+ # Check if spawning is allowed
239
+ existing = _get_existing_helpers(helpers_dir)
240
+ error = check_spawn_allowed(budget, len(existing))
241
+ if error:
242
+ print(f"Cannot spawn: {error}", file=sys.stderr)
243
+ return 1
244
+
245
+ # Determine helper number
246
+ helper_num = _next_helper_number(existing, budget)
247
+
248
+ # Create directory structure
249
+ create_helper_dirs(helpers_dir, helper_num)
250
+ create_broadcast_dirs(helpers_dir)
251
+ create_peer_channels(helpers_dir, helper_num, existing)
252
+ link_broadcast(helpers_dir, helper_num)
253
+
254
+ # Write RO spawn config for the child
255
+ child_cfg = child_budget(budget)
256
+ write_spawn_config(
257
+ _ro_spawn_config_path(helpers_dir, helper_num),
258
+ child_cfg,
259
+ )
260
+
261
+ # Copy init script into helper's scripts/
262
+ init_script = resolve_init_script(
263
+ Path.home() / "playbook" / "scripts",
264
+ )
265
+ dest_scripts = helpers_dir / str(helper_num) / "playbook" / "scripts"
266
+ dest_init = dest_scripts / "helper-init.sh"
267
+ if not dest_init.exists():
268
+ shutil.copy2(init_script, dest_init)
269
+
270
+ # Write helper state
271
+ state = {
272
+ "status": "spawned",
273
+ "model": args.model,
274
+ "depth": child_cfg.depth,
275
+ "breadth": child_cfg.breadth,
276
+ "peers": existing,
277
+ }
278
+ _write_state(helpers_dir, helper_num, state)
279
+
280
+ # Launch container via socket if helpers are enabled
281
+ container_name = None
282
+ if _check_helpers_enabled():
283
+ from kanibako.helper_client import send_request
284
+ try:
285
+ resp = send_request(_socket_path(), {
286
+ "action": "spawn",
287
+ "helper_num": helper_num,
288
+ "model": args.model,
289
+ "helpers_dir": str(helpers_dir),
290
+ })
291
+ if resp.get("status") == "ok":
292
+ container_name = resp.get("container_name")
293
+ state["status"] = "running"
294
+ state["container_name"] = container_name
295
+ else:
296
+ state["status"] = "failed"
297
+ state["error"] = resp.get("message", "unknown error")
298
+ print(
299
+ f"Warning: container launch failed: {resp.get('message')}",
300
+ file=sys.stderr,
301
+ )
302
+ except Exception as e:
303
+ state["status"] = "failed"
304
+ state["error"] = str(e)
305
+ print(f"Warning: container launch failed: {e}", file=sys.stderr)
306
+ else:
307
+ state["status"] = "spawned"
308
+
309
+ _write_state(helpers_dir, helper_num, state)
310
+
311
+ print(f"Spawned helper {helper_num}")
312
+ if args.model:
313
+ print(f" model: {args.model}")
314
+ print(f" depth: {child_cfg.depth}, breadth: {child_cfg.breadth}")
315
+ print(f" peers: {existing}")
316
+ if container_name:
317
+ print(f" container: {container_name}")
318
+ return 0
319
+
320
+
321
+ def run_list(args: argparse.Namespace) -> int:
322
+ """List active helpers."""
323
+ helpers_dir = _helpers_dir()
324
+ existing = _get_existing_helpers(helpers_dir)
325
+
326
+ if not existing:
327
+ print("No helpers.")
328
+ return 0
329
+
330
+ print(f"{'NUM':<6} {'STATUS':<10} {'MODEL':<10} {'DEPTH':<6} {'PEERS'}")
331
+ for num in existing:
332
+ state = _read_state(helpers_dir, num)
333
+ status = state.get("status", "unknown")
334
+ model = state.get("model") or "-"
335
+ depth = state.get("depth", "?")
336
+ # Count peer symlinks
337
+ peers_dir = helpers_dir / str(num) / "peers"
338
+ peer_count = 0
339
+ if peers_dir.is_dir():
340
+ peer_count = sum(1 for p in peers_dir.iterdir() if p.is_symlink())
341
+ print(f"{num:<6} {status:<10} {model:<10} {depth!s:<6} {peer_count} ch")
342
+ return 0
343
+
344
+
345
+ def run_stop(args: argparse.Namespace) -> int:
346
+ """Stop a helper instance."""
347
+ helpers_dir = _helpers_dir()
348
+ helper_num = args.number
349
+ helper_root = helpers_dir / str(helper_num)
350
+
351
+ if not helper_root.is_dir():
352
+ print(f"Helper {helper_num} does not exist.", file=sys.stderr)
353
+ return 1
354
+
355
+ state = _read_state(helpers_dir, helper_num)
356
+ if state.get("status") == "stopped":
357
+ print(f"Helper {helper_num} is already stopped.")
358
+ return 0
359
+
360
+ # Stop container via socket if running
361
+ container_name = state.get("container_name")
362
+ if container_name and _check_helpers_enabled():
363
+ from kanibako.helper_client import send_request
364
+ try:
365
+ send_request(_socket_path(), {
366
+ "action": "stop",
367
+ "container_name": container_name,
368
+ })
369
+ except Exception:
370
+ pass # Best-effort stop
371
+
372
+ state["status"] = "stopped"
373
+ _write_state(helpers_dir, helper_num, state)
374
+ print(f"Stopped helper {helper_num}.")
375
+ return 0
376
+
377
+
378
+ def run_cleanup(args: argparse.Namespace) -> int:
379
+ """Stop and remove a helper."""
380
+ helpers_dir = _helpers_dir()
381
+ helper_num = args.number
382
+ helper_root = helpers_dir / str(helper_num)
383
+
384
+ if not helper_root.is_dir():
385
+ print(f"Helper {helper_num} does not exist.", file=sys.stderr)
386
+ return 1
387
+
388
+ # Stop container if running
389
+ state = _read_state(helpers_dir, helper_num)
390
+ container_name = state.get("container_name")
391
+ if container_name and _check_helpers_enabled():
392
+ from kanibako.helper_client import send_request
393
+ try:
394
+ send_request(_socket_path(), {
395
+ "action": "stop",
396
+ "container_name": container_name,
397
+ })
398
+ except Exception:
399
+ pass
400
+
401
+ cascade = getattr(args, "cascade", False)
402
+ if cascade:
403
+ removed = _cascade_cleanup(helpers_dir, helper_num)
404
+ print(f"Cleaned up helper {helper_num} and {len(removed) - 1} descendant(s).")
405
+ else:
406
+ existing = _get_existing_helpers(helpers_dir)
407
+ siblings = [n for n in existing if n != helper_num]
408
+ remove_helper_dirs(helpers_dir, helper_num, siblings)
409
+ print(f"Cleaned up helper {helper_num}.")
410
+ return 0
411
+
412
+
413
+ def _cascade_cleanup(helpers_dir: Path, helper_num: int) -> list[int]:
414
+ """Recursively clean up a helper and all its descendants.
415
+
416
+ Returns the list of all helper numbers that were removed.
417
+ """
418
+ removed = []
419
+ # Check if this helper has its own helpers/ subtree (children)
420
+ child_helpers_dir = helpers_dir / str(helper_num) / "helpers"
421
+ if child_helpers_dir.is_dir():
422
+ children = _get_existing_helpers(child_helpers_dir)
423
+ for child in children:
424
+ removed.extend(_cascade_cleanup(child_helpers_dir, child))
425
+
426
+ # Now clean up this helper itself
427
+ existing = _get_existing_helpers(helpers_dir)
428
+ siblings = [n for n in existing if n != helper_num]
429
+ remove_helper_dirs(helpers_dir, helper_num, siblings)
430
+ removed.append(helper_num)
431
+ return removed
432
+
433
+
434
+ def run_respawn(args: argparse.Namespace) -> int:
435
+ """Relaunch a stopped helper."""
436
+ helpers_dir = _helpers_dir()
437
+ helper_num = args.number
438
+ helper_root = helpers_dir / str(helper_num)
439
+
440
+ if not helper_root.is_dir():
441
+ print(f"Helper {helper_num} does not exist.", file=sys.stderr)
442
+ return 1
443
+
444
+ state = _read_state(helpers_dir, helper_num)
445
+ if state.get("status") != "stopped":
446
+ status = state.get("status", "unknown")
447
+ print(
448
+ f"Helper {helper_num} is {status}, not stopped. "
449
+ f"Only stopped helpers can be respawned.",
450
+ file=sys.stderr,
451
+ )
452
+ return 1
453
+
454
+ # Relaunch container via socket if helpers are enabled
455
+ if _check_helpers_enabled():
456
+ from kanibako.helper_client import send_request
457
+ try:
458
+ resp = send_request(_socket_path(), {
459
+ "action": "spawn",
460
+ "helper_num": helper_num,
461
+ "model": state.get("model"),
462
+ "helpers_dir": str(helpers_dir),
463
+ })
464
+ if resp.get("status") == "ok":
465
+ state["status"] = "running"
466
+ state["container_name"] = resp.get("container_name")
467
+ else:
468
+ state["status"] = "failed"
469
+ print(
470
+ f"Warning: container relaunch failed: {resp.get('message')}",
471
+ file=sys.stderr,
472
+ )
473
+ except Exception as e:
474
+ state["status"] = "failed"
475
+ print(f"Warning: container relaunch failed: {e}", file=sys.stderr)
476
+ else:
477
+ state["status"] = "respawned"
478
+
479
+ _write_state(helpers_dir, helper_num, state)
480
+ print(f"Respawned helper {helper_num}.")
481
+ return 0
482
+
483
+
484
+ def run_send(args: argparse.Namespace) -> int:
485
+ """Send a message to a specific helper."""
486
+ if not _check_helpers_enabled():
487
+ print("Helpers not enabled (no socket found).", file=sys.stderr)
488
+ return 1
489
+
490
+ from kanibako.helper_client import send_request
491
+ try:
492
+ resp = send_request(_socket_path(), {
493
+ "action": "send",
494
+ "to": args.number,
495
+ "payload": {"text": args.message},
496
+ })
497
+ if resp.get("status") != "ok":
498
+ print(f"Send failed: {resp.get('message')}", file=sys.stderr)
499
+ return 1
500
+ except Exception as e:
501
+ print(f"Send failed: {e}", file=sys.stderr)
502
+ return 1
503
+
504
+ print(f"Message sent to helper {args.number}.")
505
+ return 0
506
+
507
+
508
+ def run_broadcast(args: argparse.Namespace) -> int:
509
+ """Broadcast a message to all helpers."""
510
+ if not _check_helpers_enabled():
511
+ print("Helpers not enabled (no socket found).", file=sys.stderr)
512
+ return 1
513
+
514
+ from kanibako.helper_client import send_request
515
+ try:
516
+ resp = send_request(_socket_path(), {
517
+ "action": "broadcast",
518
+ "payload": {"text": args.message},
519
+ })
520
+ if resp.get("status") != "ok":
521
+ print(f"Broadcast failed: {resp.get('message')}", file=sys.stderr)
522
+ return 1
523
+ except Exception as e:
524
+ print(f"Broadcast failed: {e}", file=sys.stderr)
525
+ return 1
526
+
527
+ print("Message broadcast to all helpers.")
528
+ return 0
529
+
530
+
531
+ def run_register(args: argparse.Namespace) -> int:
532
+ """Register this helper with the hub (one-shot)."""
533
+ if not _check_helpers_enabled():
534
+ return 1
535
+
536
+ from kanibako.helper_client import send_request
537
+ try:
538
+ resp = send_request(_socket_path(), {
539
+ "action": "register",
540
+ "helper_num": args.number,
541
+ })
542
+ if resp.get("status") != "ok":
543
+ return 1
544
+ except Exception:
545
+ return 1
546
+ return 0
547
+
548
+
549
+ def _log_path() -> Path:
550
+ """Return the path to the helper message log file."""
551
+ return Path.home() / ".local" / "state" / "kanibako" / "helper-messages.jsonl"
552
+
553
+
554
+ def run_log(args: argparse.Namespace) -> int:
555
+ """Display the inter-agent message log."""
556
+ log_file = _log_path()
557
+
558
+ if not log_file.is_file():
559
+ print("No helper message log found.", file=sys.stderr)
560
+ return 1
561
+
562
+ follow = getattr(args, "follow", False)
563
+ from_helper = getattr(args, "from_helper", None)
564
+ last_n = getattr(args, "last", None)
565
+
566
+ if follow:
567
+ return _follow_log(log_file, from_helper)
568
+
569
+ entries = _read_log_entries(log_file)
570
+
571
+ # Filter by helper
572
+ if from_helper is not None:
573
+ entries = [
574
+ e for e in entries
575
+ if e.get("from") == from_helper or e.get("helper") == from_helper
576
+ ]
577
+
578
+ # Last N entries
579
+ if last_n is not None and last_n > 0:
580
+ entries = entries[-last_n:]
581
+
582
+ if not entries:
583
+ print("No log entries.")
584
+ return 0
585
+
586
+ for entry in entries:
587
+ print(_format_log_entry(entry))
588
+ return 0
589
+
590
+
591
+ def _read_log_entries(log_file: Path) -> list[dict]:
592
+ """Read all JSONL entries from the log file."""
593
+ entries = []
594
+ with open(log_file) as f:
595
+ for line in f:
596
+ line = line.strip()
597
+ if line:
598
+ try:
599
+ entries.append(json.loads(line))
600
+ except json.JSONDecodeError:
601
+ continue
602
+ return entries
603
+
604
+
605
+ def _format_log_entry(entry: dict) -> str:
606
+ """Format a single log entry for display."""
607
+ ts = entry.get("ts", "")
608
+ # Extract time portion (HH:MM:SS)
609
+ if "T" in ts:
610
+ time_part = ts.split("T")[1].split(".")[0].split("+")[0]
611
+ else:
612
+ time_part = ts[:8] if len(ts) >= 8 else ts
613
+
614
+ entry_type = entry.get("type", "")
615
+
616
+ if entry_type == "message":
617
+ sender = entry.get("from", "?")
618
+ recipient = entry.get("to", "?")
619
+ to_str = "*" if recipient == "all" else str(recipient)
620
+ text = entry.get("payload", {}).get("text", "")
621
+ return f"{time_part} [{sender} → {to_str}] {text}"
622
+ elif entry_type == "control":
623
+ event = entry.get("event", "?")
624
+ helper = entry.get("helper", "")
625
+ extra = ""
626
+ if "model" in entry and entry["model"]:
627
+ extra = f" (model={entry['model']})"
628
+ return f"{time_part} [{event}] helper {helper}{extra}"
629
+ else:
630
+ return f"{time_part} {json.dumps(entry)}"
631
+
632
+
633
+ def _follow_log(log_file: Path, from_helper: int | None) -> int:
634
+ """Follow the log file, printing new entries as they appear."""
635
+ import time
636
+
637
+ # Print existing entries first
638
+ entries = _read_log_entries(log_file)
639
+ if from_helper is not None:
640
+ entries = [
641
+ e for e in entries
642
+ if e.get("from") == from_helper or e.get("helper") == from_helper
643
+ ]
644
+ for entry in entries:
645
+ print(_format_log_entry(entry))
646
+
647
+ # Then tail the file
648
+ with open(log_file) as f:
649
+ f.seek(0, 2) # seek to end
650
+ try:
651
+ while True:
652
+ line = f.readline()
653
+ if not line:
654
+ time.sleep(0.2)
655
+ continue
656
+ line = line.strip()
657
+ if not line:
658
+ continue
659
+ try:
660
+ entry = json.loads(line)
661
+ except json.JSONDecodeError:
662
+ continue
663
+ if from_helper is not None:
664
+ if entry.get("from") != from_helper and entry.get("helper") != from_helper:
665
+ continue
666
+ print(_format_log_entry(entry))
667
+ except KeyboardInterrupt:
668
+ pass
669
+ return 0