subagent-cli 0.1.1__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.
subagent/cli.py ADDED
@@ -0,0 +1,1125 @@
1
+ """Top-level `subagent` CLI implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import click
10
+ import typer
11
+
12
+ from .config import SubagentConfig, load_config
13
+ from .controller_service import (
14
+ attach_controller,
15
+ init_controller,
16
+ read_env_handle,
17
+ recover_controllers,
18
+ release_controller,
19
+ resolve_controller_id,
20
+ shell_env_exports,
21
+ )
22
+ from .errors import SubagentError
23
+ from .handoff_service import continue_worker, create_handoff
24
+ from .input_contract import (
25
+ load_input_payload,
26
+ read_blocks,
27
+ read_bool,
28
+ read_string,
29
+ read_string_list,
30
+ reject_duplicates,
31
+ )
32
+ from .launcher_service import probe_launcher
33
+ from .output import emit_error_and_exit, emit_json, ok_envelope
34
+ from .paths import resolve_workspace_path
35
+ from .prompt_service import render_prompt
36
+ from .state import StateStore
37
+ from .turn_service import approve_request, cancel_turn, send_message, wait_for_event, watch_events
38
+ from .worker_service import inspect_worker, list_workers, show_worker, start_worker, stop_worker
39
+
40
+ app = typer.Typer(
41
+ help=(
42
+ "subagent: protocol-agnostic worker orchestration CLI\n"
43
+ "If you are a manager agent, start with: `subagent prompt render --target manager`"
44
+ )
45
+ )
46
+ launcher_app = typer.Typer(help="Manage launcher registry from config")
47
+ profile_app = typer.Typer(help="Manage profile registry from config")
48
+ pack_app = typer.Typer(help="Manage pack registry from config")
49
+ prompt_app = typer.Typer(help="Render manager/worker prompts")
50
+ controller_app = typer.Typer(help="Manage controller ownership")
51
+ worker_app = typer.Typer(help="Manage worker lifecycle")
52
+
53
+ app.add_typer(launcher_app, name="launcher")
54
+ app.add_typer(profile_app, name="profile")
55
+ app.add_typer(pack_app, name="pack")
56
+ app.add_typer(prompt_app, name="prompt")
57
+ app.add_typer(controller_app, name="controller")
58
+ app.add_typer(worker_app, name="worker")
59
+
60
+
61
+ def _load_config_or_exit(config_path: Path | None, *, json_output: bool) -> SubagentConfig:
62
+ try:
63
+ return load_config(config_path)
64
+ except SubagentError as error:
65
+ emit_error_and_exit(error, json_output=json_output)
66
+
67
+
68
+ def _store() -> StateStore:
69
+ store = StateStore()
70
+ store.bootstrap()
71
+ return store
72
+
73
+
74
+ def _is_param_default(ctx: typer.Context, name: str) -> bool:
75
+ source = ctx.get_parameter_source(name)
76
+ return source == click.core.ParameterSource.DEFAULT
77
+
78
+
79
+ def _require_value(value: Any, *, name: str, json_output: bool) -> Any:
80
+ if value is None:
81
+ emit_error_and_exit(
82
+ SubagentError(
83
+ code="INVALID_INPUT",
84
+ message=f"`{name}` is required",
85
+ ),
86
+ json_output=json_output,
87
+ )
88
+ return value
89
+
90
+
91
+ def _emit_simple_list(
92
+ *,
93
+ title: str,
94
+ items: list[dict[str, Any]],
95
+ json_output: bool,
96
+ event_type: str,
97
+ config: SubagentConfig,
98
+ ) -> None:
99
+ if json_output:
100
+ emit_json(
101
+ ok_envelope(
102
+ event_type,
103
+ {
104
+ "items": items,
105
+ "count": len(items),
106
+ "configPath": str(config.path),
107
+ "configLoaded": config.loaded,
108
+ },
109
+ )
110
+ )
111
+ return
112
+ if not items:
113
+ typer.echo(f"(no {title} configured)")
114
+ return
115
+ for item in items:
116
+ typer.echo(item["name"])
117
+
118
+
119
+ def _emit_simple_show(
120
+ *,
121
+ item: dict[str, Any],
122
+ item_type: str,
123
+ json_output: bool,
124
+ ) -> None:
125
+ if json_output:
126
+ emit_json(ok_envelope(f"{item_type}.shown", item))
127
+ return
128
+ for key, value in item.items():
129
+ typer.echo(f"{key}: {value}")
130
+
131
+
132
+ def _parse_blocks_json_or_exit(
133
+ blocks_json: str | None,
134
+ *,
135
+ json_output: bool,
136
+ ) -> list[dict[str, Any]] | None:
137
+ if blocks_json is None:
138
+ return None
139
+ try:
140
+ parsed = json.loads(blocks_json)
141
+ except json.JSONDecodeError as error:
142
+ emit_error_and_exit(
143
+ SubagentError(
144
+ code="INVALID_INPUT",
145
+ message="--blocks must be valid JSON",
146
+ details={"error": str(error)},
147
+ ),
148
+ json_output=json_output,
149
+ )
150
+ if not isinstance(parsed, list):
151
+ emit_error_and_exit(
152
+ SubagentError(
153
+ code="INVALID_INPUT",
154
+ message="--blocks JSON must be a list",
155
+ ),
156
+ json_output=json_output,
157
+ )
158
+ blocks: list[dict[str, Any]] = []
159
+ for idx, item in enumerate(parsed):
160
+ if not isinstance(item, dict):
161
+ emit_error_and_exit(
162
+ SubagentError(
163
+ code="INVALID_INPUT",
164
+ message=f"--blocks[{idx}] must be an object",
165
+ ),
166
+ json_output=json_output,
167
+ )
168
+ blocks.append(item)
169
+ return blocks
170
+
171
+
172
+ @launcher_app.command("list")
173
+ def launcher_list(
174
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
175
+ config_path: Path | None = typer.Option(
176
+ None,
177
+ "--config",
178
+ help="Override config path (default: ~/.config/subagent/config.yaml).",
179
+ ),
180
+ ) -> None:
181
+ config = _load_config_or_exit(config_path, json_output=json_output)
182
+ items = [
183
+ {
184
+ "name": launcher.name,
185
+ "backendKind": launcher.backend_kind,
186
+ "command": launcher.command,
187
+ }
188
+ for launcher in sorted(config.launchers.values(), key=lambda x: x.name)
189
+ ]
190
+ _emit_simple_list(
191
+ title="launchers",
192
+ items=items,
193
+ json_output=json_output,
194
+ event_type="launcher.listed",
195
+ config=config,
196
+ )
197
+
198
+
199
+ @launcher_app.command("show")
200
+ def launcher_show(
201
+ name: str = typer.Argument(..., help="Launcher name"),
202
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
203
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
204
+ ) -> None:
205
+ config = _load_config_or_exit(config_path, json_output=json_output)
206
+ launcher = config.launchers.get(name)
207
+ if launcher is None:
208
+ emit_error_and_exit(
209
+ SubagentError(
210
+ code="LAUNCHER_NOT_FOUND",
211
+ message=f"Launcher not found: {name}",
212
+ details={"name": name},
213
+ ),
214
+ json_output=json_output,
215
+ )
216
+ _emit_simple_show(item=launcher.to_dict(), item_type="launcher", json_output=json_output)
217
+
218
+
219
+ @launcher_app.command("probe")
220
+ def launcher_probe(
221
+ name: str = typer.Argument(..., help="Launcher name"),
222
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
223
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
224
+ ) -> None:
225
+ config = _load_config_or_exit(config_path, json_output=json_output)
226
+ try:
227
+ payload = probe_launcher(config, name)
228
+ except SubagentError as error:
229
+ emit_error_and_exit(error, json_output=json_output)
230
+ if json_output:
231
+ emit_json(ok_envelope("launcher.probed", payload))
232
+ else:
233
+ status = "available" if payload["available"] else "missing"
234
+ typer.echo(f"{payload['name']}: {status}")
235
+ typer.echo(f"command: {payload['command']}")
236
+ typer.echo(f"resolvedPath: {payload['resolvedCommandPath']}")
237
+
238
+
239
+ @profile_app.command("list")
240
+ def profile_list(
241
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
242
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
243
+ ) -> None:
244
+ config = _load_config_or_exit(config_path, json_output=json_output)
245
+ items = [
246
+ {
247
+ "name": profile.name,
248
+ "promptLanguage": profile.prompt_language,
249
+ "responseLanguage": profile.response_language,
250
+ }
251
+ for profile in sorted(config.profiles.values(), key=lambda x: x.name)
252
+ ]
253
+ _emit_simple_list(
254
+ title="profiles",
255
+ items=items,
256
+ json_output=json_output,
257
+ event_type="profile.listed",
258
+ config=config,
259
+ )
260
+
261
+
262
+ @profile_app.command("show")
263
+ def profile_show(
264
+ name: str = typer.Argument(..., help="Profile name"),
265
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
266
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
267
+ ) -> None:
268
+ config = _load_config_or_exit(config_path, json_output=json_output)
269
+ profile = config.profiles.get(name)
270
+ if profile is None:
271
+ emit_error_and_exit(
272
+ SubagentError(
273
+ code="PROFILE_NOT_FOUND",
274
+ message=f"Profile not found: {name}",
275
+ details={"name": name},
276
+ ),
277
+ json_output=json_output,
278
+ )
279
+ _emit_simple_show(item=profile.to_dict(), item_type="profile", json_output=json_output)
280
+
281
+
282
+ @pack_app.command("list")
283
+ def pack_list(
284
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
285
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
286
+ ) -> None:
287
+ config = _load_config_or_exit(config_path, json_output=json_output)
288
+ items = [
289
+ {
290
+ "name": pack.name,
291
+ "description": pack.description,
292
+ }
293
+ for pack in sorted(config.packs.values(), key=lambda x: x.name)
294
+ ]
295
+ _emit_simple_list(
296
+ title="packs",
297
+ items=items,
298
+ json_output=json_output,
299
+ event_type="pack.listed",
300
+ config=config,
301
+ )
302
+
303
+
304
+ @pack_app.command("show")
305
+ def pack_show(
306
+ name: str = typer.Argument(..., help="Pack name"),
307
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
308
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
309
+ ) -> None:
310
+ config = _load_config_or_exit(config_path, json_output=json_output)
311
+ pack = config.packs.get(name)
312
+ if pack is None:
313
+ emit_error_and_exit(
314
+ SubagentError(
315
+ code="PACK_NOT_FOUND",
316
+ message=f"Pack not found: {name}",
317
+ details={"name": name},
318
+ ),
319
+ json_output=json_output,
320
+ )
321
+ _emit_simple_show(item=pack.to_dict(), item_type="pack", json_output=json_output)
322
+
323
+
324
+ @prompt_app.command("render")
325
+ def prompt_render(
326
+ target: str = typer.Option(..., "--target", help="Prompt target: manager|worker"),
327
+ profile: str | None = typer.Option(None, "--profile", help="Profile name for worker target"),
328
+ packs: list[str] = typer.Option([], "--pack", help="Pack names (repeatable)"),
329
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
330
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
331
+ ) -> None:
332
+ config = _load_config_or_exit(config_path, json_output=json_output)
333
+ try:
334
+ payload = render_prompt(
335
+ config,
336
+ target=target,
337
+ profile_name=profile,
338
+ pack_names=packs,
339
+ )
340
+ except SubagentError as error:
341
+ emit_error_and_exit(error, json_output=json_output)
342
+ if json_output:
343
+ emit_json(ok_envelope("prompt.rendered", payload))
344
+ else:
345
+ typer.echo(payload["prompt"])
346
+
347
+
348
+ def _handle_print_env_flag(print_env: bool, json_output: bool) -> None:
349
+ if print_env and json_output:
350
+ emit_error_and_exit(
351
+ SubagentError(
352
+ code="INVALID_ARGUMENT",
353
+ message="--print-env cannot be combined with --json",
354
+ ),
355
+ json_output=True,
356
+ )
357
+
358
+
359
+ @controller_app.command("init")
360
+ def controller_init(
361
+ cwd: Path = typer.Option(Path("."), "--cwd", help="Workspace root"),
362
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Controller ID override"),
363
+ label: str = typer.Option("default-manager", "--label", help="Controller label"),
364
+ print_env: bool = typer.Option(
365
+ False,
366
+ "--print-env",
367
+ help="Print shell exports for SUBAGENT_CTL_* variables.",
368
+ ),
369
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
370
+ ) -> None:
371
+ _handle_print_env_flag(print_env, json_output)
372
+ store = _store()
373
+ try:
374
+ initialized = init_controller(
375
+ store,
376
+ workspace=resolve_workspace_path(cwd),
377
+ controller_id=controller_id,
378
+ label=label,
379
+ )
380
+ except SubagentError as error:
381
+ emit_error_and_exit(error, json_output=json_output)
382
+
383
+ if print_env:
384
+ for line in shell_env_exports(initialized.owner):
385
+ typer.echo(line)
386
+ return
387
+
388
+ payload = initialized.to_dict()
389
+ if json_output:
390
+ emit_json(ok_envelope("controller.initialized", payload))
391
+ else:
392
+ typer.echo(f"controllerId: {payload['controllerId']}")
393
+ typer.echo(f"workspaceKey: {payload['workspaceKey']}")
394
+ typer.echo(f"epoch: {payload['owner']['epoch']}")
395
+ typer.echo(f"hintPath: {payload['hintPath']}")
396
+
397
+
398
+ @controller_app.command("attach")
399
+ def controller_attach(
400
+ cwd: Path = typer.Option(Path("."), "--cwd", help="Workspace root"),
401
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Controller ID override"),
402
+ takeover: bool = typer.Option(False, "--takeover", help="Take ownership even if active owner exists"),
403
+ print_env: bool = typer.Option(
404
+ False,
405
+ "--print-env",
406
+ help="Print shell exports for SUBAGENT_CTL_* variables.",
407
+ ),
408
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
409
+ ) -> None:
410
+ _handle_print_env_flag(print_env, json_output)
411
+ store = _store()
412
+ try:
413
+ attached = attach_controller(
414
+ store,
415
+ workspace=resolve_workspace_path(cwd),
416
+ controller_id=controller_id,
417
+ takeover=takeover,
418
+ )
419
+ except SubagentError as error:
420
+ emit_error_and_exit(error, json_output=json_output)
421
+
422
+ if print_env:
423
+ for line in shell_env_exports(attached.owner):
424
+ typer.echo(line)
425
+ return
426
+
427
+ payload = attached.to_dict()
428
+ if json_output:
429
+ emit_json(ok_envelope("controller.attached", payload))
430
+ else:
431
+ typer.echo(f"controllerId: {payload['controllerId']}")
432
+ typer.echo(f"workspaceKey: {payload['workspaceKey']}")
433
+ typer.echo(f"epoch: {payload['owner']['epoch']}")
434
+ typer.echo(f"hintPath: {payload['hintPath']}")
435
+
436
+
437
+ @controller_app.command("status")
438
+ def controller_status(
439
+ cwd: Path = typer.Option(Path("."), "--cwd", help="Workspace root"),
440
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Controller ID override"),
441
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
442
+ ) -> None:
443
+ store = _store()
444
+ workspace = resolve_workspace_path(cwd)
445
+ resolved_controller_id = resolve_controller_id(
446
+ store,
447
+ workspace,
448
+ explicit_controller_id=controller_id,
449
+ )
450
+ if resolved_controller_id is None:
451
+ payload = {
452
+ "workspaceKey": str(workspace),
453
+ "state": "dormant",
454
+ "controllerId": None,
455
+ "activeOwner": None,
456
+ "envHandle": read_env_handle(),
457
+ }
458
+ if json_output:
459
+ emit_json(ok_envelope("controller.status", payload))
460
+ else:
461
+ typer.echo("state: dormant")
462
+ typer.echo("controllerId: (none)")
463
+ return
464
+
465
+ try:
466
+ payload = store.get_controller_status(resolved_controller_id)
467
+ except SubagentError as error:
468
+ emit_error_and_exit(error, json_output=json_output)
469
+
470
+ env_handle = read_env_handle()
471
+ if env_handle is None:
472
+ payload["envHandle"] = {"present": False, "valid": False}
473
+ elif env_handle.get("valid") is False:
474
+ payload["envHandle"] = {
475
+ "present": True,
476
+ "valid": False,
477
+ "reason": env_handle.get("reason"),
478
+ }
479
+ else:
480
+ env_controller_id = str(env_handle["controllerId"])
481
+ env_epoch = int(env_handle["epoch"])
482
+ env_token = str(env_handle["token"])
483
+ valid = (
484
+ env_controller_id == resolved_controller_id
485
+ and store.validate_handle(resolved_controller_id, env_epoch, env_token)
486
+ )
487
+ payload["envHandle"] = {
488
+ "present": True,
489
+ "valid": valid,
490
+ "controllerId": env_controller_id,
491
+ "epoch": env_epoch,
492
+ }
493
+
494
+ if json_output:
495
+ emit_json(ok_envelope("controller.status", payload))
496
+ else:
497
+ typer.echo(f"state: {payload['state']}")
498
+ typer.echo(f"controllerId: {payload['controllerId']}")
499
+ owner = payload.get("activeOwner")
500
+ if owner is None:
501
+ typer.echo("owner: (none)")
502
+ else:
503
+ typer.echo(f"owner.epoch: {owner['epoch']}")
504
+ typer.echo(f"owner.pid: {owner['pid']}")
505
+ env_payload = payload["envHandle"]
506
+ typer.echo(f"env.valid: {env_payload.get('valid', False)}")
507
+
508
+
509
+ @controller_app.command("recover")
510
+ def controller_recover(
511
+ cwd: Path | None = typer.Option(None, "--cwd", help="Optional workspace filter"),
512
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
513
+ ) -> None:
514
+ store = _store()
515
+ try:
516
+ payload = recover_controllers(store, workspace=cwd)
517
+ except SubagentError as error:
518
+ emit_error_and_exit(error, json_output=json_output)
519
+ if json_output:
520
+ emit_json(ok_envelope("controller.recovered", payload))
521
+ else:
522
+ if payload["count"] == 0:
523
+ typer.echo("(no controllers)")
524
+ return
525
+ for item in payload["items"]:
526
+ typer.echo(f"{item['controllerId']}\t{item['state']}\t{item['workspaceKey']}")
527
+
528
+
529
+ @controller_app.command("release")
530
+ def controller_release(
531
+ cwd: Path = typer.Option(Path("."), "--cwd", help="Workspace root"),
532
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Controller ID override"),
533
+ force: bool = typer.Option(False, "--force", help="Release without validating env handle"),
534
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
535
+ ) -> None:
536
+ store = _store()
537
+ try:
538
+ payload = release_controller(
539
+ store,
540
+ workspace=resolve_workspace_path(cwd),
541
+ controller_id=controller_id,
542
+ force=force,
543
+ )
544
+ except SubagentError as error:
545
+ emit_error_and_exit(error, json_output=json_output)
546
+ if json_output:
547
+ emit_json(ok_envelope("controller.released", payload))
548
+ else:
549
+ typer.echo(f"controllerId: {payload['controllerId']}")
550
+ typer.echo(f"released: {payload['released']}")
551
+
552
+
553
+ @app.command("send")
554
+ def send(
555
+ ctx: typer.Context,
556
+ worker_id: str | None = typer.Option(None, "--worker", help="Worker ID"),
557
+ text: str | None = typer.Option(None, "--text", help="Instruction text"),
558
+ blocks_json: str | None = typer.Option(
559
+ None,
560
+ "--blocks",
561
+ help="Optional blocks payload as JSON list",
562
+ ),
563
+ request_approval: bool = typer.Option(
564
+ False,
565
+ "--request-approval",
566
+ help="Simulate approval-required turn in local runtime",
567
+ ),
568
+ input_path: str | None = typer.Option(None, "--input", help="Read command JSON from file path or '-'"),
569
+ debug_mode: bool = typer.Option(
570
+ False,
571
+ "--debug-mode/--no-debug-mode",
572
+ help="Enable local simulation mode for debug/testing.",
573
+ ),
574
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
575
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
576
+ ) -> None:
577
+ try:
578
+ input_payload = load_input_payload(input_path)
579
+ reject_duplicates(
580
+ input_payload,
581
+ flag_values={
582
+ "worker_id": worker_id,
583
+ "text": text,
584
+ "blocks_json": blocks_json,
585
+ "debug_mode": debug_mode,
586
+ },
587
+ value_is_default={
588
+ "worker_id": _is_param_default(ctx, "worker_id"),
589
+ "text": _is_param_default(ctx, "text"),
590
+ "blocks_json": _is_param_default(ctx, "blocks_json"),
591
+ "debug_mode": _is_param_default(ctx, "debug_mode"),
592
+ },
593
+ mapping={
594
+ "workerId": "worker_id",
595
+ "text": "text",
596
+ "blocks": "blocks_json",
597
+ "debugMode": "debug_mode",
598
+ },
599
+ )
600
+ if input_payload is not None:
601
+ worker_id = read_string(input_payload, "workerId") or worker_id
602
+ text = read_string(input_payload, "text") or text
603
+ blocks = read_blocks(input_payload, "blocks")
604
+ payload_debug_mode = read_bool(input_payload, "debugMode")
605
+ if payload_debug_mode is not None:
606
+ debug_mode = payload_debug_mode
607
+ else:
608
+ blocks = None
609
+
610
+ worker_id = _require_value(worker_id, name="worker", json_output=json_output)
611
+ text = _require_value(text, name="text", json_output=json_output)
612
+ except SubagentError as error:
613
+ emit_error_and_exit(error, json_output=json_output)
614
+
615
+ store = _store()
616
+ config = _load_config_or_exit(config_path, json_output=json_output)
617
+ if blocks is None:
618
+ blocks = _parse_blocks_json_or_exit(blocks_json, json_output=json_output)
619
+ execution_mode = "simulate" if debug_mode else "strict"
620
+ try:
621
+ payload = send_message(
622
+ store,
623
+ worker_id=worker_id,
624
+ text=text,
625
+ blocks=blocks,
626
+ request_approval=request_approval,
627
+ config=config,
628
+ execution_mode=execution_mode,
629
+ )
630
+ except SubagentError as error:
631
+ emit_error_and_exit(error, json_output=json_output)
632
+ if json_output:
633
+ emit_json(ok_envelope("turn.accepted", payload))
634
+ else:
635
+ typer.echo(f"workerId: {payload['workerId']}")
636
+ typer.echo(f"turnId: {payload['turnId']}")
637
+ typer.echo(f"state: {payload['state']}")
638
+
639
+
640
+ @app.command("watch")
641
+ def watch(
642
+ worker_id: str = typer.Option(..., "--worker", help="Worker ID"),
643
+ from_event_id: str | None = typer.Option(None, "--from-event-id", help="Cursor event ID"),
644
+ follow: bool = typer.Option(False, "--follow", help="Follow events for a short window"),
645
+ timeout_seconds: float = typer.Option(
646
+ 1.0,
647
+ "--timeout-seconds",
648
+ min=0.1,
649
+ help="Polling window when --follow is enabled",
650
+ ),
651
+ ndjson: bool = typer.Option(False, "--ndjson", help="Emit one normalized event per line"),
652
+ raw: bool = typer.Option(False, "--raw", help="Include raw payload when available"),
653
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
654
+ ) -> None:
655
+ if ndjson and json_output:
656
+ emit_error_and_exit(
657
+ SubagentError(
658
+ code="INVALID_ARGUMENT",
659
+ message="--ndjson cannot be combined with --json",
660
+ ),
661
+ json_output=True,
662
+ )
663
+ store = _store()
664
+ try:
665
+ events = watch_events(
666
+ store,
667
+ worker_id=worker_id,
668
+ from_event_id=from_event_id,
669
+ follow=follow,
670
+ timeout_seconds=timeout_seconds,
671
+ include_raw=raw,
672
+ )
673
+ except SubagentError as error:
674
+ emit_error_and_exit(error, json_output=json_output)
675
+
676
+ ndjson_mode = ndjson or not json_output
677
+ if ndjson_mode:
678
+ for event in events:
679
+ emit_json(event)
680
+ return
681
+
682
+ if json_output:
683
+ emit_json(
684
+ ok_envelope(
685
+ "events.watched",
686
+ {
687
+ "workerId": worker_id,
688
+ "count": len(events),
689
+ "items": events,
690
+ },
691
+ )
692
+ )
693
+ return
694
+
695
+
696
+ @app.command("wait")
697
+ def wait(
698
+ ctx: typer.Context,
699
+ worker_id: str | None = typer.Option(None, "--worker", help="Worker ID"),
700
+ until: str | None = typer.Option(None, "--until", help="Event type to wait for"),
701
+ from_event_id: str | None = typer.Option(None, "--from-event-id", help="Cursor event ID"),
702
+ timeout_seconds: float = typer.Option(10.0, "--timeout-seconds", min=0.1, help="Timeout in seconds"),
703
+ input_path: str | None = typer.Option(None, "--input", help="Read command JSON from file path or '-'"),
704
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
705
+ ) -> None:
706
+ try:
707
+ input_payload = load_input_payload(input_path)
708
+ reject_duplicates(
709
+ input_payload,
710
+ flag_values={
711
+ "worker_id": worker_id,
712
+ "until": until,
713
+ "from_event_id": from_event_id,
714
+ "timeout_seconds": timeout_seconds,
715
+ },
716
+ value_is_default={
717
+ "worker_id": _is_param_default(ctx, "worker_id"),
718
+ "until": _is_param_default(ctx, "until"),
719
+ "from_event_id": _is_param_default(ctx, "from_event_id"),
720
+ "timeout_seconds": _is_param_default(ctx, "timeout_seconds"),
721
+ },
722
+ mapping={
723
+ "workerId": "worker_id",
724
+ "until": "until",
725
+ "fromEventId": "from_event_id",
726
+ "timeoutSeconds": "timeout_seconds",
727
+ },
728
+ )
729
+ if input_payload is not None:
730
+ worker_id = read_string(input_payload, "workerId") or worker_id
731
+ until = read_string(input_payload, "until") or until
732
+ from_event_id = read_string(input_payload, "fromEventId") or from_event_id
733
+ timeout_value = input_payload.get("timeoutSeconds")
734
+ if timeout_value is not None:
735
+ if not isinstance(timeout_value, (int, float)):
736
+ emit_error_and_exit(
737
+ SubagentError(code="INVALID_INPUT", message="`timeoutSeconds` must be a number"),
738
+ json_output=json_output,
739
+ )
740
+ timeout_seconds = float(timeout_value)
741
+
742
+ worker_id = _require_value(worker_id, name="worker", json_output=json_output)
743
+ until = _require_value(until, name="until", json_output=json_output)
744
+ except SubagentError as error:
745
+ emit_error_and_exit(error, json_output=json_output)
746
+
747
+ store = _store()
748
+ try:
749
+ event = wait_for_event(
750
+ store,
751
+ worker_id=worker_id,
752
+ until=until,
753
+ from_event_id=from_event_id,
754
+ timeout_seconds=timeout_seconds,
755
+ )
756
+ except SubagentError as error:
757
+ emit_error_and_exit(error, json_output=json_output)
758
+ if json_output:
759
+ emit_json(ok_envelope("event.matched", event))
760
+ else:
761
+ typer.echo(f"{event['eventId']}\t{event['type']}")
762
+
763
+
764
+ @app.command("approve")
765
+ def approve(
766
+ ctx: typer.Context,
767
+ worker_id: str | None = typer.Option(None, "--worker", help="Worker ID"),
768
+ request_id: str | None = typer.Option(None, "--request", help="Approval request ID"),
769
+ decision: str | None = typer.Option(None, "--decision", help="Decision alias"),
770
+ option_id: str | None = typer.Option(None, "--option-id", help="Approval option id"),
771
+ alias: str | None = typer.Option(None, "--alias", help="Approval option alias"),
772
+ note: str | None = typer.Option(None, "--note", help="Decision note"),
773
+ input_path: str | None = typer.Option(None, "--input", help="Read command JSON from file path or '-'"),
774
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
775
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
776
+ ) -> None:
777
+ try:
778
+ input_payload = load_input_payload(input_path)
779
+ reject_duplicates(
780
+ input_payload,
781
+ flag_values={
782
+ "worker_id": worker_id,
783
+ "request_id": request_id,
784
+ "decision": decision,
785
+ "option_id": option_id,
786
+ "alias": alias,
787
+ "note": note,
788
+ },
789
+ value_is_default={
790
+ "worker_id": _is_param_default(ctx, "worker_id"),
791
+ "request_id": _is_param_default(ctx, "request_id"),
792
+ "decision": _is_param_default(ctx, "decision"),
793
+ "option_id": _is_param_default(ctx, "option_id"),
794
+ "alias": _is_param_default(ctx, "alias"),
795
+ "note": _is_param_default(ctx, "note"),
796
+ },
797
+ mapping={
798
+ "workerId": "worker_id",
799
+ "requestId": "request_id",
800
+ "decision": "decision",
801
+ "optionId": "option_id",
802
+ "alias": "alias",
803
+ "note": "note",
804
+ },
805
+ )
806
+ if input_payload is not None:
807
+ worker_id = read_string(input_payload, "workerId") or worker_id
808
+ request_id = read_string(input_payload, "requestId") or request_id
809
+ decision = read_string(input_payload, "decision") or decision
810
+ option_id = read_string(input_payload, "optionId") or option_id
811
+ alias = read_string(input_payload, "alias") or alias
812
+ note = read_string(input_payload, "note") or note
813
+
814
+ worker_id = _require_value(worker_id, name="worker", json_output=json_output)
815
+ request_id = _require_value(request_id, name="request", json_output=json_output)
816
+ except SubagentError as error:
817
+ emit_error_and_exit(error, json_output=json_output)
818
+
819
+ store = _store()
820
+ config = _load_config_or_exit(config_path, json_output=json_output)
821
+ try:
822
+ payload = approve_request(
823
+ store,
824
+ worker_id=worker_id,
825
+ request_id=request_id,
826
+ decision=decision,
827
+ option_id=option_id,
828
+ alias=alias,
829
+ note=note,
830
+ config=config,
831
+ )
832
+ except SubagentError as error:
833
+ emit_error_and_exit(error, json_output=json_output)
834
+ if json_output:
835
+ emit_json(ok_envelope("approval.decided", payload))
836
+ else:
837
+ typer.echo(f"requestId: {payload['requestId']}")
838
+ typer.echo(f"decision: {payload['decision']}")
839
+ typer.echo(f"state: {payload['state']}")
840
+
841
+
842
+ @app.command("cancel")
843
+ def cancel(
844
+ worker_id: str = typer.Option(..., "--worker", help="Worker ID"),
845
+ reason: str | None = typer.Option(None, "--reason", help="Cancel reason"),
846
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
847
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
848
+ ) -> None:
849
+ store = _store()
850
+ config = _load_config_or_exit(config_path, json_output=json_output)
851
+ try:
852
+ payload = cancel_turn(
853
+ store,
854
+ worker_id=worker_id,
855
+ reason=reason,
856
+ config=config,
857
+ )
858
+ except SubagentError as error:
859
+ emit_error_and_exit(error, json_output=json_output)
860
+ if json_output:
861
+ emit_json(ok_envelope("turn.canceled", payload))
862
+ else:
863
+ typer.echo(f"workerId: {payload['workerId']}")
864
+ typer.echo(f"state: {payload['state']}")
865
+
866
+
867
+ @worker_app.command("start")
868
+ def worker_start(
869
+ ctx: typer.Context,
870
+ launcher: str | None = typer.Option(None, "--launcher", help="Launcher name"),
871
+ profile: str | None = typer.Option(None, "--profile", help="Profile name"),
872
+ packs: list[str] = typer.Option([], "--pack", help="Pack names (repeatable)"),
873
+ cwd: Path = typer.Option(Path("."), "--cwd", help="Worker working directory"),
874
+ label: str | None = typer.Option(None, "--label", help="Worker label"),
875
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Controller ID override"),
876
+ debug_mode: bool = typer.Option(
877
+ False,
878
+ "--debug-mode/--no-debug-mode",
879
+ help="Start worker without backend runtime (for debug/testing).",
880
+ ),
881
+ input_path: str | None = typer.Option(None, "--input", help="Read command JSON from file path or '-'"),
882
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
883
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
884
+ ) -> None:
885
+ try:
886
+ input_payload = load_input_payload(input_path)
887
+ reject_duplicates(
888
+ input_payload,
889
+ flag_values={
890
+ "launcher": launcher,
891
+ "profile": profile,
892
+ "packs": packs,
893
+ "cwd": str(cwd),
894
+ "label": label,
895
+ "controller_id": controller_id,
896
+ "debug_mode": debug_mode,
897
+ },
898
+ value_is_default={
899
+ "launcher": _is_param_default(ctx, "launcher"),
900
+ "profile": _is_param_default(ctx, "profile"),
901
+ "packs": _is_param_default(ctx, "packs"),
902
+ "cwd": _is_param_default(ctx, "cwd"),
903
+ "label": _is_param_default(ctx, "label"),
904
+ "controller_id": _is_param_default(ctx, "controller_id"),
905
+ "debug_mode": _is_param_default(ctx, "debug_mode"),
906
+ },
907
+ mapping={
908
+ "launcher": "launcher",
909
+ "profile": "profile",
910
+ "packs": "packs",
911
+ "cwd": "cwd",
912
+ "label": "label",
913
+ "controllerId": "controller_id",
914
+ "debugMode": "debug_mode",
915
+ },
916
+ )
917
+ if input_payload is not None:
918
+ launcher = read_string(input_payload, "launcher") or launcher
919
+ profile = read_string(input_payload, "profile") or profile
920
+ payload_packs = read_string_list(input_payload, "packs")
921
+ if payload_packs is not None:
922
+ packs = payload_packs
923
+ payload_cwd = read_string(input_payload, "cwd")
924
+ if payload_cwd is not None:
925
+ cwd = Path(payload_cwd)
926
+ label = read_string(input_payload, "label") or label
927
+ controller_id = read_string(input_payload, "controllerId") or controller_id
928
+ payload_debug_mode = read_bool(input_payload, "debugMode")
929
+ if payload_debug_mode is not None:
930
+ debug_mode = payload_debug_mode
931
+ except SubagentError as error:
932
+ emit_error_and_exit(error, json_output=json_output)
933
+
934
+ store = _store()
935
+ config = _load_config_or_exit(config_path, json_output=json_output)
936
+ try:
937
+ payload = start_worker(
938
+ store,
939
+ config,
940
+ workspace=resolve_workspace_path(cwd),
941
+ worker_cwd=resolve_workspace_path(cwd),
942
+ controller_id=controller_id,
943
+ launcher=launcher,
944
+ profile=profile,
945
+ packs=packs,
946
+ label=label,
947
+ debug_mode=debug_mode,
948
+ )
949
+ except SubagentError as error:
950
+ emit_error_and_exit(error, json_output=json_output)
951
+ if json_output:
952
+ emit_json(ok_envelope("worker.started", payload))
953
+ else:
954
+ typer.echo(f"workerId: {payload['workerId']}")
955
+ typer.echo(f"controllerId: {payload['controllerId']}")
956
+ typer.echo(f"state: {payload['state']}")
957
+
958
+
959
+ @worker_app.command("list")
960
+ def worker_list(
961
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Filter by controller ID"),
962
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
963
+ ) -> None:
964
+ store = _store()
965
+ try:
966
+ items = list_workers(store, controller_id=controller_id)
967
+ except SubagentError as error:
968
+ emit_error_and_exit(error, json_output=json_output)
969
+ payload = {
970
+ "items": items,
971
+ "count": len(items),
972
+ "controllerId": controller_id,
973
+ }
974
+ if json_output:
975
+ emit_json(ok_envelope("worker.listed", payload))
976
+ else:
977
+ if not items:
978
+ typer.echo("(no workers)")
979
+ return
980
+ for item in items:
981
+ typer.echo(f"{item['workerId']}\t{item['state']}\t{item['label']}")
982
+
983
+
984
+ @worker_app.command("show")
985
+ def worker_show(
986
+ worker_id: str = typer.Argument(..., help="Worker ID"),
987
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
988
+ ) -> None:
989
+ store = _store()
990
+ try:
991
+ payload = show_worker(store, worker_id)
992
+ except SubagentError as error:
993
+ emit_error_and_exit(error, json_output=json_output)
994
+ if json_output:
995
+ emit_json(ok_envelope("worker.shown", payload))
996
+ else:
997
+ for key, value in payload.items():
998
+ typer.echo(f"{key}: {value}")
999
+
1000
+
1001
+ @worker_app.command("inspect")
1002
+ def worker_inspect(
1003
+ worker_id: str = typer.Argument(..., help="Worker ID"),
1004
+ events_limit: int = typer.Option(20, "--events-limit", min=1, max=200, help="Number of recent events"),
1005
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
1006
+ ) -> None:
1007
+ store = _store()
1008
+ try:
1009
+ payload = inspect_worker(store, worker_id, events_limit=events_limit)
1010
+ except SubagentError as error:
1011
+ emit_error_and_exit(error, json_output=json_output)
1012
+ if json_output:
1013
+ emit_json(ok_envelope("worker.inspected", payload))
1014
+ else:
1015
+ worker = payload["worker"]
1016
+ typer.echo(f"workerId: {worker['workerId']}")
1017
+ typer.echo(f"state: {worker['state']}")
1018
+ typer.echo(f"recoveryState: {worker['recoveryState']}")
1019
+ typer.echo(f"pendingApprovals: {len(payload['pendingApprovals'])}")
1020
+ typer.echo(f"events: {len(payload['events'])}")
1021
+
1022
+
1023
+ @worker_app.command("stop")
1024
+ def worker_stop(
1025
+ worker_id: str = typer.Argument(..., help="Worker ID"),
1026
+ force: bool = typer.Option(False, "--force", help="Force transition to stopped"),
1027
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
1028
+ ) -> None:
1029
+ store = _store()
1030
+ try:
1031
+ payload = stop_worker(store, worker_id, force=force)
1032
+ except SubagentError as error:
1033
+ emit_error_and_exit(error, json_output=json_output)
1034
+ if json_output:
1035
+ emit_json(ok_envelope("worker.stopped", payload))
1036
+ else:
1037
+ typer.echo(f"workerId: {payload['workerId']}")
1038
+ typer.echo(f"state: {payload['state']}")
1039
+
1040
+
1041
+ @worker_app.command("handoff")
1042
+ def worker_handoff(
1043
+ worker_id: str = typer.Option(..., "--worker", help="Worker ID"),
1044
+ handoffs_dir: Path | None = typer.Option(
1045
+ None,
1046
+ "--handoffs-dir",
1047
+ help="Override handoff storage directory",
1048
+ ),
1049
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
1050
+ ) -> None:
1051
+ store = _store()
1052
+ try:
1053
+ payload = create_handoff(
1054
+ store,
1055
+ worker_id=worker_id,
1056
+ handoffs_dir=handoffs_dir,
1057
+ )
1058
+ except SubagentError as error:
1059
+ emit_error_and_exit(error, json_output=json_output)
1060
+ if json_output:
1061
+ emit_json(ok_envelope("worker.handoff.ready", payload))
1062
+ else:
1063
+ typer.echo(f"workerId: {payload['workerId']}")
1064
+ typer.echo(f"handoffPath: {payload['handoffPath']}")
1065
+ typer.echo(f"checkpointPath: {payload['checkpointPath']}")
1066
+
1067
+
1068
+ @worker_app.command("continue")
1069
+ def worker_continue(
1070
+ from_worker: str | None = typer.Option(None, "--from-worker", help="Source worker ID"),
1071
+ from_handoff: Path | None = typer.Option(None, "--from-handoff", help="Path to handoff.md"),
1072
+ launcher: str | None = typer.Option(None, "--launcher", help="Launcher override"),
1073
+ profile: str | None = typer.Option(None, "--profile", help="Profile override"),
1074
+ packs: list[str] = typer.Option([], "--pack", help="Pack override (repeatable)"),
1075
+ cwd: Path | None = typer.Option(None, "--cwd", help="Target working directory"),
1076
+ label: str | None = typer.Option(None, "--label", help="Target worker label"),
1077
+ controller_id: str | None = typer.Option(None, "--controller-id", help="Controller override"),
1078
+ handoffs_dir: Path | None = typer.Option(
1079
+ None,
1080
+ "--handoffs-dir",
1081
+ help="Override handoff storage directory",
1082
+ ),
1083
+ debug_mode: bool = typer.Option(
1084
+ False,
1085
+ "--debug-mode/--no-debug-mode",
1086
+ help="Enable debug mode for worker startup and bootstrap turn.",
1087
+ ),
1088
+ config_path: Path | None = typer.Option(None, "--config", help="Override config path."),
1089
+ json_output: bool = typer.Option(False, "--json", help="Emit JSON envelope."),
1090
+ ) -> None:
1091
+ store = _store()
1092
+ config = _load_config_or_exit(config_path, json_output=json_output)
1093
+ execution_mode = "simulate" if debug_mode else "strict"
1094
+ try:
1095
+ payload = continue_worker(
1096
+ store,
1097
+ config,
1098
+ from_worker=from_worker,
1099
+ from_handoff=from_handoff,
1100
+ launcher=launcher,
1101
+ profile=profile,
1102
+ packs=packs,
1103
+ cwd=cwd,
1104
+ label=label,
1105
+ controller_id=controller_id,
1106
+ handoffs_dir=handoffs_dir,
1107
+ debug_mode=debug_mode,
1108
+ execution_mode=execution_mode,
1109
+ )
1110
+ except SubagentError as error:
1111
+ emit_error_and_exit(error, json_output=json_output)
1112
+ if json_output:
1113
+ emit_json(ok_envelope("worker.continued", payload))
1114
+ else:
1115
+ typer.echo(f"sourceHandoffPath: {payload['sourceHandoffPath']}")
1116
+ typer.echo(f"workerId: {payload['worker']['workerId']}")
1117
+ typer.echo(f"state: {payload['worker']['state']}")
1118
+
1119
+
1120
+ def main() -> None:
1121
+ app()
1122
+
1123
+
1124
+ if __name__ == "__main__":
1125
+ main()