@litfamily/lithermes 1.0.0 → 1.0.1

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.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.0/readme-assets/cover.webp" width="100%" alt="LitHermes v5 armory cover" /></p>
1
+ <p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.1/readme-assets/cover.webp" width="100%" alt="LitHermes v5 armory cover" /></p>
2
2
 
3
3
  ```
4
4
  ▄▄▄▄
@@ -41,7 +41,7 @@ LitHermes connects planning, execution, review, and handoff inside **Hermes Agen
41
41
 
42
42
  [한국어](./README_Ko-KR.md) · [npm](https://www.npmjs.com/package/@litfamily/lithermes) · [GitHub](https://github.com/wjgoarxiv/lithermes)
43
43
 
44
- Source version: `@litfamily/lithermes@1.0.0`. Install the scoped package from npm with the command in Quick start. A reviewed local archive remains available as an optional isolated trial.
44
+ Source version: `@litfamily/lithermes@1.0.1`. Install the scoped package from npm with the command in Quick start. A reviewed local archive remains available as an optional isolated trial.
45
45
 
46
46
  Landing images load from the published package so they render on npmjs. Guide links still point at the source repository.
47
47
 
package/README_Ko-KR.md CHANGED
@@ -1,4 +1,4 @@
1
- <p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.0/readme-assets/cover.webp" width="100%" alt="LitHermes v5 armory cover" /></p>
1
+ <p align="center"><img src="https://cdn.jsdelivr.net/npm/@litfamily/lithermes@1.0.1/readme-assets/cover.webp" width="100%" alt="LitHermes v5 armory cover" /></p>
2
2
 
3
3
  ```
4
4
  ▄▄▄▄
@@ -44,7 +44,7 @@ LitHermes는 **Hermes Agent**에서 계획, 실행, 검토, 인계를 이어갑
44
44
 
45
45
  [English](./README.md) · [npm](https://www.npmjs.com/package/@litfamily/lithermes) · [GitHub](https://github.com/wjgoarxiv/lithermes)
46
46
 
47
- 소스 기준 버전은 `@litfamily/lithermes@1.0.0`입니다. 빠른 시작의 명령으로 scoped package를 설치합니다. 별도 체험이 필요하면 검토된 로컬 압축 파일을 선택할 수 있습니다.
47
+ 소스 기준 버전은 `@litfamily/lithermes@1.0.1`입니다. 빠른 시작의 명령으로 scoped package를 설치합니다. 별도 체험이 필요하면 검토된 로컬 압축 파일을 선택할 수 있습니다.
48
48
 
49
49
  랜딩 이미지는 게시된 패키지에서 불러와 npmjs에서도 보입니다. 안내 문서 링크는 소스 저장소를 가리킵니다.
50
50
 
@@ -1,6 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import argparse
4
+ from contextvars import ContextVar
4
5
  import functools
5
6
  import re
6
7
  from pathlib import Path
@@ -21,6 +22,35 @@ from .litgoal import hook as litgoal_hook
21
22
  from .litgoal import tools as litgoal_tools
22
23
 
23
24
 
25
+ _NATIVE_COMMAND_CONTEXT: ContextVar[dict[str, str] | None] = ContextVar(
26
+ "lithermes_native_command_context", default=None
27
+ )
28
+ _NATIVE_COMMANDS = frozenset(
29
+ {
30
+ "lit-plan",
31
+ "litwork-plan",
32
+ "lit",
33
+ "lit-loop",
34
+ "litwork-loop",
35
+ "litgoal",
36
+ "review-work",
37
+ "start-work",
38
+ "deep-interview",
39
+ "lit-recap",
40
+ "lit-handoff",
41
+ "lit-scientific-visualization",
42
+ "korean-ai-slop-remover",
43
+ *core.KOREAN_PROSE_COMMANDS,
44
+ }
45
+ )
46
+ _NATIVE_GATEWAY_NOTICE = (
47
+ "LitHermes could not start an agent turn on the Hermes gateway: native gateway "
48
+ "injection requires an existing session key and "
49
+ "`plugins.entries.lithermes.allow_gateway_injection: true`; LitHermes does not "
50
+ "enable that setting."
51
+ )
52
+
53
+
24
54
  PORTED_SKILLS = [
25
55
  (
26
56
  "lit-burnoff-file",
@@ -358,7 +388,7 @@ def _setup_lithermes_cli(parser) -> None:
358
388
  f"skills: {len(PORTED_SKILLS)} lithermes:* skills — `hermes lithermes status` lists them\n"
359
389
  "contract schema: lithermes_llm_contract/v1 headings include #contract.activation, "
360
390
  "#contract.inputs, #contract.outputs, #contract.evidence, and #contract.hard_stops\n"
361
- "hooks: on_session_start, pre_llm_call, pre_tool_call, post_tool_call, "
391
+ "hooks: on_session_start, pre_llm_call, pre_tool_call, pre_command, post_tool_call, "
362
392
  "post_api_request, subagent_stop, transform_llm_output, on_session_finalize, on_session_reset\n"
363
393
  "bounded work: schema 3 via /lit-loop init|status|resume|cancel|complete and lithermes_work_progress\n"
364
394
  "wikify knowledge: hermes lithermes knowledge status|query|capture|save|review\n"
@@ -403,6 +433,91 @@ def _ignited(handler, route):
403
433
  return wrapped
404
434
 
405
435
 
436
+ def _capture_pre_command(**kwargs: Any) -> None:
437
+ """Remember the host route immediately before Hermes invokes a command.
438
+
439
+ Hermes 0.21 fires ``pre_command`` for both the CLI and gateway surfaces. The
440
+ command handler itself only receives raw arguments, so this small
441
+ context-local bridge carries the surface/session pair into the native
442
+ ``PluginContext.inject_message`` adapter below. Older Hermes releases do not
443
+ expose ``inject_message`` and never register this hook.
444
+ """
445
+ surface = str(kwargs.get("surface") or "").strip().lower()
446
+ if surface not in {"cli", "gateway"}:
447
+ return None
448
+ command = str(
449
+ kwargs.get("command")
450
+ or kwargs.get("alias_used")
451
+ or ""
452
+ ).strip().lstrip("/").lower()
453
+ if command not in _NATIVE_COMMANDS:
454
+ return None
455
+ session_key = str(kwargs.get("session_key") or "").strip()
456
+ _NATIVE_COMMAND_CONTEXT.set(
457
+ {"surface": surface, "session_key": session_key}
458
+ )
459
+ return None
460
+
461
+
462
+ def _native_command_dispatch(ctx, handler):
463
+ """Adapt structured LitHermes results to Hermes 0.21's native API.
464
+
465
+ Hermes 0.17/0.19 have no ``inject_message`` method, so registration keeps
466
+ returning the legacy structured payload for the installer source patch. On
467
+ 0.21 the plugin API owns the queueing/turn lifecycle; returning the dict to
468
+ the host would otherwise print its Python representation to users.
469
+ """
470
+ inject_message = getattr(ctx, "inject_message", None)
471
+ if not callable(inject_message):
472
+ return handler
473
+
474
+ @functools.wraps(handler)
475
+ def wrapped(user_args):
476
+ context = _NATIVE_COMMAND_CONTEXT.get() or {}
477
+ try:
478
+ result = handler(user_args)
479
+ if not isinstance(result, dict):
480
+ return result
481
+
482
+ display = result.get("display") or result.get("message")
483
+ agent_message = result.get("agent_message")
484
+ injected = False
485
+ if agent_message:
486
+ try:
487
+ injected = bool(
488
+ inject_message(
489
+ str(agent_message),
490
+ role="user",
491
+ session_key=context.get("session_key") or None,
492
+ )
493
+ )
494
+ except Exception:
495
+ # A host-side refusal must remain a visible, actionable
496
+ # result rather than leaking the structured dict repr.
497
+ injected = False
498
+
499
+ if context.get("surface") == "gateway" and not injected:
500
+ display_text = str(display) if display else ""
501
+ return (
502
+ f"{display_text}\n\n{_NATIVE_GATEWAY_NOTICE}"
503
+ if display_text
504
+ else _NATIVE_GATEWAY_NOTICE
505
+ )
506
+ if display is not None:
507
+ return str(display)
508
+ return ""
509
+ finally:
510
+ _NATIVE_COMMAND_CONTEXT.set(None)
511
+
512
+ return wrapped
513
+
514
+
515
+ def _command_handler(ctx, handler, route=None):
516
+ """Build a command handler that works on both legacy and native Hermes."""
517
+ ignited = _ignited(handler, route) if route else handler
518
+ return _native_command_dispatch(ctx, ignited)
519
+
520
+
406
521
  def _transform_llm_output(**kwargs: Any) -> str | None:
407
522
  named = handoff.transform_llm_output(**kwargs)
408
523
  if named is not None:
@@ -458,6 +573,11 @@ def register(ctx) -> None:
458
573
  # The host-supplied session id authorizes progress before the model-facing
459
574
  # tool handler runs. The tool itself has no resume/grant lifecycle bypass.
460
575
  ctx.register_hook("pre_tool_call", _pre_tool_call)
576
+ # Hermes 0.21 gives native plugin commands a host-owned turn injection API.
577
+ # Register the route bridge only when that API exists; older Hermes releases
578
+ # continue through the installer-managed source patch below.
579
+ if callable(getattr(ctx, "inject_message", None)):
580
+ ctx.register_hook("pre_command", _capture_pre_command)
461
581
  # Observer over completed mutations. Hermes ignores this hook's return value, so
462
582
  # it only buffers the mutated paths; pre_llm_call renders them into the
463
583
  # event-shaped skill route on the next turn.
@@ -489,80 +609,80 @@ def register(ctx) -> None:
489
609
 
490
610
  ctx.register_command(
491
611
  "lit-plan",
492
- _ignited(core.command_lit_plan, "lit-plan"),
612
+ _command_handler(ctx, core.command_lit_plan, "lit-plan"),
493
613
  description="Create a durable Litwork implementation plan",
494
614
  args_hint='"what to build"',
495
615
  )
496
616
  ctx.register_command(
497
617
  "litwork-plan",
498
- _ignited(core.command_lit_plan, "lit-plan"),
618
+ _command_handler(ctx, core.command_lit_plan, "lit-plan"),
499
619
  description="Alias for /lit-plan",
500
620
  args_hint='"what to build"',
501
621
  )
502
622
  ctx.register_command(
503
623
  "lit",
504
- _ignited(core.command_lit, "litwork"),
624
+ _command_handler(ctx, core.command_lit, "litwork"),
505
625
  description="Start a Litwork run and execute the task immediately",
506
626
  args_hint='"task"',
507
627
  )
508
628
  ctx.register_command(
509
629
  "lit-loop",
510
- _ignited(core.command_lit_loop, "lit-loop"),
630
+ _command_handler(ctx, core.command_lit_loop, "lit-loop"),
511
631
  description="Start a Litwork run or operate its bounded lifecycle",
512
632
  args_hint='"task" [--completion-promise TEXT] [--strategy reset|continue] | init <plan> --grant ACTION@ROOT[,ACTION@ROOT] [--worktree PATH] | status <work-id> [--worktree PATH] | resume <work-id> --revision N --boundary ID --grant ACTION@ROOT [--worktree PATH] | cancel|complete <work-id> --revision N [--worktree PATH]',
513
633
  )
514
634
  ctx.register_command(
515
635
  "litwork-loop",
516
- _ignited(core.command_lit_loop, "lit-loop"),
636
+ _command_handler(ctx, core.command_lit_loop, "lit-loop"),
517
637
  description="Alias for /lit-loop",
518
638
  args_hint='"task"',
519
639
  )
520
640
  ctx.register_command(
521
641
  "litgoal",
522
- _ignited(core.command_litgoal, "litgoal"),
642
+ _command_handler(ctx, core.command_litgoal, "litgoal"),
523
643
  description="Open or inspect the LitHermes litgoal durable runtime",
524
644
  args_hint='["objective"] [--worktree PATH]',
525
645
  )
526
646
  ctx.register_command(
527
647
  "review-work",
528
- _ignited(core.command_review_work, "review-work"),
648
+ _command_handler(ctx, core.command_review_work, "review-work"),
529
649
  description="Run the LitHermes 5-lane review orchestrator on the current diff",
530
650
  args_hint="[--base REF]",
531
651
  )
532
652
  ctx.register_command(
533
653
  "start-work",
534
- _ignited(core.command_start_work, "start-work"),
654
+ _command_handler(ctx, core.command_start_work, "start-work"),
535
655
  description="Open or dry-run a LitHermes plan against a workspace",
536
656
  args_hint="[plan-name] [--worktree PATH] [--dry-run]",
537
657
  )
538
658
  ctx.register_command(
539
659
  "deep-interview",
540
- _ignited(core.command_deep_interview, "deep-interview"),
660
+ _command_handler(ctx, core.command_deep_interview, "deep-interview"),
541
661
  description="Run the LitHermes Socratic clarity gate before planning/execution",
542
662
  args_hint="[--quick|--standard|--deep] <idea>",
543
663
  )
544
664
  ctx.register_command(
545
665
  "lit-recap",
546
- _ignited(core.command_lit_recap, "lit-recap"),
666
+ _command_handler(ctx, core.command_lit_recap, "lit-recap"),
547
667
  description="Read-only Korean recap of finalized work (side-effect-free)",
548
668
  args_hint="[--brief] [--en] [--worktree PATH]",
549
669
  )
550
670
  ctx.register_command(
551
671
  "lit-handoff",
552
- handoff.command_lit_handoff,
672
+ _command_handler(ctx, handoff.command_lit_handoff),
553
673
  description="Create or update a verified LitHermes continuation handoff",
554
674
  args_hint="[focus]",
555
675
  )
556
676
  ctx.register_command(
557
677
  "lit-scientific-visualization",
558
- scientific_visualization.command_lit_scientific_visualization,
678
+ _command_handler(ctx, scientific_visualization.command_lit_scientific_visualization),
559
679
  description="Create a publication-quality scientific figure with the bundled source",
560
680
  args_hint="[figure request or dataset paths]",
561
681
  )
562
682
  for name in core.KOREAN_PROSE_COMMANDS:
563
683
  ctx.register_command(
564
684
  name,
565
- _ignited(core.command_korean_prose_cleanup, "lit-korean"),
685
+ _command_handler(ctx, core.command_korean_prose_cleanup, "lit-korean"),
566
686
  description="Side-effect-free Korean prose cleanup and naturalization",
567
687
  args_hint="[Korean text]",
568
688
  )
@@ -576,7 +696,7 @@ def register(ctx) -> None:
576
696
 
577
697
  ctx.register_command(
578
698
  "korean-ai-slop-remover",
579
- _ignited(korean_rename_redirect, "lit-korean"),
699
+ _command_handler(ctx, korean_rename_redirect, "lit-korean"),
580
700
  description="Redirect to /lit-korean for this release; removed in the next minor",
581
701
  args_hint="[Korean text]",
582
702
  )
@@ -26,6 +26,7 @@ HOOKS = (
26
26
  "on_session_start",
27
27
  "pre_llm_call",
28
28
  "pre_tool_call",
29
+ "pre_command",
29
30
  "post_tool_call",
30
31
  "post_api_request",
31
32
  "subagent_stop",
@@ -1,7 +1,7 @@
1
1
  {
2
- "syncedAt": "2026-09-13T22:21:44.664Z",
2
+ "syncedAt": "2026-09-17T12:28:42.389Z",
3
3
  "source": "bundled-payload",
4
- "sourceHash": "98a99fa2936eba6e66e1a4c8eb1b8b02fe4b1668bc084179c756f10a8a253431",
4
+ "sourceHash": "20ca6086bec7f31f2f1bda43b4f99ec34d95fbc81fcdec9d98815897a69099bc",
5
5
  "files": [
6
6
  {
7
7
  "path": "NOTICE.md",
@@ -13,7 +13,7 @@
13
13
  },
14
14
  {
15
15
  "path": "__init__.py",
16
- "sha256": "8bf962b44c8ccc19f5edc203eb9c91ad68565edeef2911e27c25c68d810e78fb"
16
+ "sha256": "1cd11adf3eb20516e4891840db188324c3065e634b06007f36a9137485403c7a"
17
17
  },
18
18
  {
19
19
  "path": "auto_update.py",
@@ -85,7 +85,7 @@
85
85
  },
86
86
  {
87
87
  "path": "diagnostics.py",
88
- "sha256": "ff9ca32366be865f8f50724776016d5de855e31826b838fbddd0adf3d59bd13a"
88
+ "sha256": "5ee1242f357dc164a6ab663baf3d658a13d53f437240e33b8bb1c5cffa4d9ed2"
89
89
  },
90
90
  {
91
91
  "path": "handoff.py",
@@ -157,7 +157,7 @@
157
157
  },
158
158
  {
159
159
  "path": "plugin.yaml",
160
- "sha256": "5183a4cf5c0e41727dbb369b9ae522e7603d4076e907a319bdfa5b17d679b083"
160
+ "sha256": "4af3d8b0f4ca8c613bba087fbf5d70400097f7bae73b0bc7936f077c88bb66ec"
161
161
  },
162
162
  {
163
163
  "path": "provider_metrics.py",
@@ -1253,7 +1253,7 @@
1253
1253
  },
1254
1254
  {
1255
1255
  "path": "skills/frontend-ui-ux/references/complete-contract.md",
1256
- "sha256": "4284c4a1b45ea8e38fe63570f83312816fb0c105d15128db214413add32eab4b"
1256
+ "sha256": "2728e0114c749ef101018ee217adf338e7a01030b2865fdec33946b0155b9e2d"
1257
1257
  },
1258
1258
  {
1259
1259
  "path": "skills/frontend-ui-ux/references/composition.md",
@@ -1893,7 +1893,7 @@
1893
1893
  },
1894
1894
  {
1895
1895
  "path": "skills/visual-qa/references/complete-contract.md",
1896
- "sha256": "9ae56774c7e1d291fe4df46277b87b8ac0a2cba610284856da050dd807fd7f17"
1896
+ "sha256": "7858afb13f0e877e82e94039d290d8afd3bf938337cb3260364a12a6e1028924"
1897
1897
  },
1898
1898
  {
1899
1899
  "path": "skills/visual-qa/schemas/evidence-manifest-v1alpha1.schema.json",
@@ -1,5 +1,5 @@
1
1
  name: lithermes
2
- version: 1.0.0
2
+ version: 1.0.1
3
3
  description: "Hermes-native workflow toolkit: litgoal durable runtime, 5-lane review orchestrator, Litwork commands, skills, and prompt steering."
4
4
  author: "Hermes Agent"
5
5
  kind: standalone
@@ -8,6 +8,7 @@ hooks:
8
8
  - on_session_start
9
9
  - pre_llm_call
10
10
  - pre_tool_call
11
+ - pre_command
11
12
  - post_tool_call
12
13
  - post_api_request
13
14
  - subagent_stop
@@ -420,7 +420,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
420
420
 
421
421
  ```text
422
422
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
423
- Installed LitHermes 1.0.0
423
+ Installed LitHermes 1.0.1
424
424
  plugin: $ISOLATED/plugins/lithermes
425
425
  model config: updated
426
426
 
@@ -437,7 +437,7 @@ installed payload: PASS
437
437
  enabled config: PASS
438
438
 
439
439
  $ hermes lithermes doctor
440
- [OK] plugin.yaml readable (version 1.0.0)
440
+ [OK] plugin.yaml readable (version 1.0.1)
441
441
  [OK] skills bundled: 32
442
442
  [OK] litgoal durable runtime importable
443
443
  ```
@@ -485,7 +485,7 @@ isolated Hermes home. Use `--hermes-home`; never `--home`.
485
485
 
486
486
  ```text
487
487
  $ node packages/lithermes-installer/bin/lithermes.js install --yes --offline --no-hud --hermes-home "$ISOLATED"
488
- Installed LitHermes 1.0.0
488
+ Installed LitHermes 1.0.1
489
489
  plugin: $ISOLATED/plugins/lithermes
490
490
  model config: updated
491
491
 
@@ -502,7 +502,7 @@ installed payload: PASS
502
502
  enabled config: PASS
503
503
 
504
504
  $ hermes lithermes doctor
505
- [OK] plugin.yaml readable (version 1.0.0)
505
+ [OK] plugin.yaml readable (version 1.0.1)
506
506
  [OK] skills bundled: 32
507
507
  [OK] litgoal durable runtime importable
508
508
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@litfamily/lithermes",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "npx/bunx installer for the LitHermes Hermes plugin",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -1 +1 @@
1
- <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.0"><title>release: 1.0.0</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.0</text></g></svg>
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="146" height="20" role="img" aria-label="release: 1.0.1"><title>release: 1.0.1</title><g shape-rendering="crispEdges"><rect width="49" height="20" fill="#080d14"/><rect x="49" width="97" height="20" fill="#ff6337"/></g><g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,DejaVu Sans,sans-serif" text-rendering="geometricPrecision" font-size="110"><text x="255" y="140" textLength="390" transform="scale(.1)">release</text><text x="965" y="140" textLength="870" transform="scale(.1)" fill="#080D14">1.0.1</text></g></svg>
package/src/lib/check.js CHANGED
@@ -27,8 +27,25 @@ function scanPluginCommands(pluginPath) {
27
27
  return requiredCommands.filter((cmd) => source.includes(`"${cmd}"`) || source.includes(`'${cmd}'`));
28
28
  }
29
29
 
30
+ function nativePluginInjectionAvailable(repo) {
31
+ if (!repo) return false;
32
+ const file = path.join(repo, "hermes_cli", "plugins.py");
33
+ if (!fs.existsSync(file)) return false;
34
+ const source = fs.readFileSync(file, "utf8");
35
+ return source.includes("def inject_message(")
36
+ && source.includes("session_key")
37
+ && source.includes("allow_gateway_injection");
38
+ }
39
+
30
40
  function checkGatewaySource(repo) {
31
41
  if (!repo) return { ok: true, skipped: true };
42
+ if (nativePluginInjectionAvailable(repo)) {
43
+ return {
44
+ ok: true,
45
+ native: true,
46
+ reason: "Hermes PluginContext.inject_message handles native gateway dispatch",
47
+ };
48
+ }
32
49
  const file = path.join(repo, "gateway", "run.py");
33
50
  if (!fs.existsSync(file)) return { ok: false, reason: "gateway/run.py missing" };
34
51
  const source = fs.readFileSync(file, "utf8");
@@ -40,6 +57,13 @@ function checkGatewaySource(repo) {
40
57
 
41
58
  function checkCliSource(repo) {
42
59
  if (!repo) return { ok: true, skipped: true };
60
+ if (nativePluginInjectionAvailable(repo)) {
61
+ return {
62
+ ok: true,
63
+ native: true,
64
+ reason: "Hermes PluginContext.inject_message handles native CLI turns",
65
+ };
66
+ }
43
67
  const file = path.join(repo, "cli.py");
44
68
  if (!fs.existsSync(file)) return { ok: false, reason: "cli.py missing" };
45
69
  const source = fs.readFileSync(file, "utf8");
@@ -60,6 +84,12 @@ function checkTuiSource(repo) {
60
84
  };
61
85
  }
62
86
 
87
+ function formatDispatchStatus(result) {
88
+ if (result.skipped) return "SKIPPED";
89
+ if (result.native) return "PASS (native PluginContext.inject_message)";
90
+ return "PASS";
91
+ }
92
+
63
93
  function enabledUserJsonIncludesPlugin(output, pluginName) {
64
94
  try {
65
95
  const entries = JSON.parse(String(output));
@@ -95,7 +125,9 @@ function checkLitHermes(flags = {}) {
95
125
  if (flags["gateway-offline"]) {
96
126
  const gateway = checkGatewaySource(hermesRepo);
97
127
  if (!gateway.ok && !gateway.skipped) throw new LitHermesError(`gateway check FAIL: ${gateway.reason}`, 7);
98
- const status = gateway.skipped ? "SKIPPED (no Hermes repo provided)" : "PASS";
128
+ const status = gateway.skipped
129
+ ? "SKIPPED (no Hermes repo provided)"
130
+ : formatDispatchStatus(gateway);
99
131
  lines.push(`gateway /lit_loop ${status}`);
100
132
  lines.push(`gateway /lit_plan ${status}`);
101
133
  }
@@ -172,9 +204,9 @@ function doctorLitHermes(flags = {}) {
172
204
  `model route safety: ${modelSafety.status}${modelSafety.code ? ` (${modelSafety.code}; ${modelSafety.reason})` : ` (${modelSafety.reason})`}`,
173
205
  `runtime: ${runtime.status}${runtime.status === "hard" ? ` (${runtime.provider} ${runtime.apiMode})` : ` (${runtime.reason})`}`,
174
206
  `global child route: ${formatManagedRoute(delegationRoute)}`,
175
- `cli payload dispatch: ${cli.ok ? (cli.skipped ? "SKIPPED" : "PASS") : "PATCH_AVAILABLE"}`,
176
- `tui payload dispatch: ${tui.ok ? (tui.skipped ? "SKIPPED" : "PASS") : "PATCH_AVAILABLE"}`,
177
- `gateway underscore dispatch: ${gateway.ok ? (gateway.skipped ? "SKIPPED" : "PASS") : "PATCH_AVAILABLE"}`,
207
+ `cli payload dispatch: ${cli.ok ? formatDispatchStatus(cli) : "PATCH_AVAILABLE"}`,
208
+ `tui payload dispatch: ${tui.ok ? formatDispatchStatus(tui) : "PATCH_AVAILABLE"}`,
209
+ `gateway underscore dispatch: ${gateway.ok ? formatDispatchStatus(gateway) : "PATCH_AVAILABLE"}`,
178
210
  ];
179
211
  return {
180
212
  message: lines.join("\n"),
@@ -195,6 +227,7 @@ module.exports = {
195
227
  loadedStatusMatchesOrigin,
196
228
  requiredCommands,
197
229
  requiredSkills,
230
+ nativePluginInjectionAvailable,
198
231
  scanPluginCommands,
199
232
  scanPluginSkills,
200
233
  };
@@ -65,22 +65,43 @@ function detectHermesRuntimeRepo() {
65
65
  }
66
66
  }
67
67
 
68
+ function readIfExists(filePath) {
69
+ try {
70
+ return fs.readFileSync(filePath, "utf8");
71
+ } catch {
72
+ return "";
73
+ }
74
+ }
75
+
76
+ // Hermes 0.21.0 split tools/delegate_tool.py: the concurrency knob and delegation-route
77
+ // helpers moved into tools/delegate_tool_config.py, and hermes_cli/runtime_provider.py
78
+ // moved from an if/elif chain to a _POOL_ENTRY_SIMPLE_MODES data table. Both layouts are
79
+ // checked so a 0.21.0 host is not misreported as lacking capabilities it genuinely has.
68
80
  function inspectHermesHostCapabilities(hermesRepo) {
69
81
  const unavailable = { concurrencyHard: false, delegationRouteHard: false, runtimeHard: false };
70
82
  if (!hermesRepo) return unavailable;
71
83
  try {
72
- const delegation = fs.readFileSync(path.join(hermesRepo, "tools", "delegate_tool.py"), "utf8");
84
+ const legacyDelegation = readIfExists(path.join(hermesRepo, "tools", "delegate_tool.py"));
85
+ const configDelegation = readIfExists(path.join(hermesRepo, "tools", "delegate_tool_config.py"));
86
+ if (!legacyDelegation && !configDelegation) return unavailable;
87
+ const delegation = `${legacyDelegation}\n${configDelegation}`;
73
88
  const runtime = fs.readFileSync(path.join(hermesRepo, "hermes_cli", "runtime_provider.py"), "utf8");
74
89
  const runtimeStem = ["co", "dex"].join("");
75
90
  return {
76
91
  concurrencyHard: delegation.includes("_get_max_concurrent_children")
77
- && delegation.includes('cfg.get("max_concurrent_children")')
78
- && delegation.includes("max(1, int(val))"),
79
- delegationRouteHard: delegation.includes('configured_model = str(cfg.get("model")')
80
- && delegation.includes('delegation_cfg.get("reasoning_effort")')
81
- && delegation.includes("effective_model = model or parent_agent.model"),
82
- runtimeHard: runtime.includes(`provider == "openai-${runtimeStem}"`)
83
- && runtime.includes(`api_mode = "${runtimeStem}_responses"`),
92
+ && ((delegation.includes('cfg.get("max_concurrent_children")') && delegation.includes("max(1, int(val))"))
93
+ || (delegation.includes('"max_concurrent_children", "DELEGATION_MAX_CONCURRENT_CHILDREN"')
94
+ && delegation.includes("max(1, int(v))"))),
95
+ // The config-level model override moved from a single `configured_model` line
96
+ // (0.17/0.19) into a per-key dict comprehension in _resolve_delegation_credentials
97
+ // (0.21.0); both read the same cfg.get("model") value.
98
+ delegationRouteHard: delegation.includes('delegation_cfg.get("reasoning_effort")')
99
+ && delegation.includes("effective_model = model or parent_agent.model")
100
+ && (delegation.includes('configured_model = str(cfg.get("model")')
101
+ || delegation.includes('str(cfg.get(k) or "").strip() or None for k in ("model"')),
102
+ runtimeHard: (runtime.includes(`provider == "openai-${runtimeStem}"`)
103
+ && runtime.includes(`api_mode = "${runtimeStem}_responses"`))
104
+ || runtime.includes(`"openai-${runtimeStem}": ("${runtimeStem}_responses"`),
84
105
  };
85
106
  } catch {
86
107
  return unavailable;
@@ -14,6 +14,7 @@ const {
14
14
  } = require("./config");
15
15
  const { copyTree, removeEmptyDirs, sha256, writeFileAtomic } = require("./files");
16
16
  const { assertStateParent, assertPluginParent, defaultHermesHome, defaultHermesRepo, detectHermesRuntimeRepo, detectHermesVersion, ensureHermesHome, inspectHermesHostCapabilities, LitHermesError } = require("./hermesDiscovery");
17
+ const { NOT_PROBED_HOST_VERSION } = require("./modelConfigBoundary");
17
18
  const { patchInstalledHermes, rollbackPatches } = require("./patch");
18
19
  const {
19
20
  formatManagedRoute,
@@ -333,7 +334,7 @@ function installLitHermes(flags = {}) {
333
334
  const modelPlan = planModelConfig(beforeConfig, {
334
335
  ...routeOptions(flags),
335
336
  hostCapabilities: inspectHermesHostCapabilities(capabilityRepo),
336
- hostVersion: dryRun ? "" : detectHermesVersion(),
337
+ hostVersion: dryRun ? NOT_PROBED_HOST_VERSION : detectHermesVersion(),
337
338
  reconfigure: Boolean(flags["reconfigure-model"]),
338
339
  });
339
340
  if (modelPlan.action === "stop") {
@@ -499,7 +500,13 @@ function installLitHermes(flags = {}) {
499
500
  ] : []),
500
501
  ...capabilityLines({ capabilities: effectiveCapabilities }),
501
502
  ];
502
- if (patchResult) lines.push(`patches: ${patchResult.changed.length ? patchResult.changed.join(", ") : "none needed"}`);
503
+ if (patchResult) {
504
+ lines.push(
505
+ patchResult.native
506
+ ? "patches: native PluginContext.inject_message dispatch (no source patch)"
507
+ : `patches: ${patchResult.changed.length ? patchResult.changed.join(", ") : "none needed"}`,
508
+ );
509
+ }
503
510
  if (patchWarning) lines.push(`patches: skipped (${patchWarning})`);
504
511
  const credentialWarning = modelPlan.request && effectiveModelAction === "write"
505
512
  ? missingCredentialWarning(modelPlan.request, process.env)
@@ -12,6 +12,10 @@ const VERIFIED_HOST_VERSION_TEXT = [...VERIFIED_HOST_VERSIONS].join(", ");
12
12
  // Below this the managed schema did not exist, so forward compatibility must not become
13
13
  // backward acceptance.
14
14
  const MINIMUM_HOST_VERSION = "0.17.0";
15
+ // Sentinel `hostVersion` for a dry-run, which deliberately never executes the Hermes
16
+ // binary. Distinguishes "never probed" from a real host whose banner came back empty or
17
+ // malformed, which must still be reported as unsupported.
18
+ const NOT_PROBED_HOST_VERSION = "__lithermes_dry_run_not_probed__";
15
19
  const CONFIG_VERSION = 30;
16
20
  const CREDENTIAL_KEY = /(^|[_-])(api[_-]?key|client[_-]?secret|auth[_-]?token|(?:aws[_-]?)?access[_-]?key(?:[_-]?id)?|(?:ssh[_-]?)?private[_-]?key|token|password|passphrase|secret|credential|authorization)([_-]|$)/i;
17
21
  const MAP_PATHS = ["model", "agent", "delegation", "compression"];
@@ -111,6 +115,7 @@ function compareVersions(left, right) {
111
115
  * this matrix has seen it.
112
116
  */
113
117
  function classifyHostVersion(raw) {
118
+ if (raw === NOT_PROBED_HOST_VERSION) return { version: "", status: "not-probed" };
114
119
  const version = exactHostVersion(raw);
115
120
  // A release is exactly major.minor.patch. Four-part builds such as 0.19.0.1 are not a
116
121
  // published release line and were rejected before this gate accepted unseen versions.
@@ -121,6 +126,9 @@ function classifyHostVersion(raw) {
121
126
  }
122
127
 
123
128
  function hostVersionError(classified) {
129
+ if (classified.status === "not-probed") {
130
+ return "Hermes host version not probed (dry-run); re-run without --dry-run to detect the host and its capabilities";
131
+ }
124
132
  if (classified.status === "malformed") {
125
133
  return `unsupported Hermes host version; expected a release banner at or above ${MINIMUM_HOST_VERSION} (verified: ${VERIFIED_HOST_VERSION_TEXT})`;
126
134
  }
@@ -197,6 +205,7 @@ function parseBoundary(text, options = {}) {
197
205
 
198
206
  module.exports = {
199
207
  MINIMUM_HOST_VERSION,
208
+ NOT_PROBED_HOST_VERSION,
200
209
  OPENAI_PROVIDER,
201
210
  RESPONSES_MODE,
202
211
  classifyHostVersion,
package/src/lib/patch.js CHANGED
@@ -7,6 +7,15 @@ function patchManifestPath(hermesHome) {
7
7
  return path.join(hermesHome, "lithermes", "patch-manifest.json");
8
8
  }
9
9
 
10
+ function nativePluginInjectionAvailable(repo) {
11
+ const file = path.join(repo, "hermes_cli", "plugins.py");
12
+ if (!fs.existsSync(file)) return false;
13
+ const source = fs.readFileSync(file, "utf8");
14
+ return source.includes("def inject_message(")
15
+ && source.includes("session_key")
16
+ && source.includes("allow_gateway_injection");
17
+ }
18
+
10
19
  function patchTarget(repo, relative, marker, additions) {
11
20
  const file = path.join(repo, relative);
12
21
  if (!fs.existsSync(file)) {
@@ -95,6 +104,28 @@ function patchInstalledHermes({ hermesHome, hermesRepo }) {
95
104
  if (!hermesRepo || !fs.existsSync(hermesRepo)) {
96
105
  throw new LitHermesError("Cannot patch Hermes because --hermes-repo was not found. Pass --hermes-repo PATH or run doctor first.", 8);
97
106
  }
107
+ if (nativePluginInjectionAvailable(hermesRepo)) {
108
+ fs.mkdirSync(path.dirname(patchManifestPath(hermesHome)), { recursive: true });
109
+ writeFileAtomic(
110
+ patchManifestPath(hermesHome),
111
+ JSON.stringify({
112
+ patchedAt: new Date().toISOString(),
113
+ nativeDispatch: {
114
+ cli: true,
115
+ gateway: true,
116
+ reason: "Hermes PluginContext.inject_message is available",
117
+ },
118
+ records: [],
119
+ }, null, 2),
120
+ "utf8",
121
+ );
122
+ return {
123
+ changed: [],
124
+ records: [],
125
+ native: true,
126
+ reason: "Hermes PluginContext.inject_message is available",
127
+ };
128
+ }
98
129
  const changed = [];
99
130
  const records = [];
100
131
  const cli = patchCliPayloadDispatch(hermesRepo);
@@ -146,6 +177,7 @@ function rollbackPatches({ hermesHome }) {
146
177
  }
147
178
 
148
179
  module.exports = {
180
+ nativePluginInjectionAvailable,
149
181
  patchCliPayloadDispatch,
150
182
  patchInstalledHermes,
151
183
  patchManifestPath,