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.
- kanibako/__init__.py +3 -0
- kanibako/__main__.py +6 -0
- kanibako/auth_browser.py +296 -0
- kanibako/auth_parser.py +51 -0
- kanibako/browser_sidecar.py +183 -0
- kanibako/browser_state.py +103 -0
- kanibako/bun_sea.py +144 -0
- kanibako/cli.py +344 -0
- kanibako/commands/__init__.py +0 -0
- kanibako/commands/archive.py +228 -0
- kanibako/commands/box/__init__.py +22 -0
- kanibako/commands/box/_duplicate.py +395 -0
- kanibako/commands/box/_migrate.py +574 -0
- kanibako/commands/box/_parser.py +1178 -0
- kanibako/commands/clean.py +166 -0
- kanibako/commands/crab_cmd.py +480 -0
- kanibako/commands/diagnose.py +239 -0
- kanibako/commands/fork_cmd.py +51 -0
- kanibako/commands/helper_cmd.py +669 -0
- kanibako/commands/image.py +1300 -0
- kanibako/commands/install.py +152 -0
- kanibako/commands/refresh_credentials.py +67 -0
- kanibako/commands/restore.py +298 -0
- kanibako/commands/setup_cmd.py +89 -0
- kanibako/commands/start.py +1600 -0
- kanibako/commands/stop.py +116 -0
- kanibako/commands/system_cmd.py +224 -0
- kanibako/commands/upgrade.py +161 -0
- kanibako/commands/vault_cmd.py +199 -0
- kanibako/commands/workset_cmd.py +552 -0
- kanibako/config.py +514 -0
- kanibako/config_interface.py +573 -0
- kanibako/config_io.py +36 -0
- kanibako/container.py +607 -0
- kanibako/containerfiles.py +58 -0
- kanibako/containers/Containerfile.kanibako +99 -0
- kanibako/containers/Containerfile.template-android +55 -0
- kanibako/containers/Containerfile.template-dotnet +29 -0
- kanibako/containers/Containerfile.template-js +43 -0
- kanibako/containers/Containerfile.template-jvm +27 -0
- kanibako/containers/Containerfile.template-systems +46 -0
- kanibako/containers/__init__.py +0 -0
- kanibako/crabs.py +89 -0
- kanibako/errors.py +33 -0
- kanibako/freshness.py +67 -0
- kanibako/git.py +114 -0
- kanibako/helper_client.py +132 -0
- kanibako/helper_listener.py +538 -0
- kanibako/helpers.py +339 -0
- kanibako/hygiene.py +296 -0
- kanibako/image_sharing.py +133 -0
- kanibako/instructions.py +160 -0
- kanibako/log.py +31 -0
- kanibako/names.py +248 -0
- kanibako/paths.py +1483 -0
- kanibako/plugins/__init__.py +10 -0
- kanibako/registry.py +71 -0
- kanibako/rig_bundle.py +121 -0
- kanibako/rig_meta.py +92 -0
- kanibako/rig_registry.py +132 -0
- kanibako/rig_resolve.py +182 -0
- kanibako/rig_source.py +245 -0
- kanibako/scripts/__init__.py +0 -0
- kanibako/scripts/helper-init.sh +45 -0
- kanibako/scripts/kanibako-entry +12 -0
- kanibako/settings_resolve.py +312 -0
- kanibako/settings_seeds.py +154 -0
- kanibako/settings_shares.py +154 -0
- kanibako/shellenv.py +75 -0
- kanibako/snapshots.py +281 -0
- kanibako/targets/__init__.py +173 -0
- kanibako/targets/base.py +243 -0
- kanibako/targets/no_agent.py +58 -0
- kanibako/templates.py +60 -0
- kanibako/templates_image.py +224 -0
- kanibako/tweakcc.py +140 -0
- kanibako/tweakcc_cache.py +171 -0
- kanibako/utils.py +136 -0
- kanibako/workset.py +347 -0
- kanibako_cli-1.5.0.dev14.dist-info/METADATA +15 -0
- kanibako_cli-1.5.0.dev14.dist-info/RECORD +85 -0
- kanibako_cli-1.5.0.dev14.dist-info/WHEEL +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/entry_points.txt +5 -0
- kanibako_cli-1.5.0.dev14.dist-info/licenses/LICENSE.md +594 -0
- 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
|