loki-mode 10.6.7 → 10.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.md +35 -1
  2. package/SKILL.md +2 -2
  3. package/VERSION +1 -1
  4. package/autonomy/lib/workspace.py +290 -0
  5. package/autonomy/lib/worktree_prep.py +143 -0
  6. package/autonomy/loki +25 -0
  7. package/bin/loki +1 -1
  8. package/completions/_loki +1 -0
  9. package/completions/loki.bash +1 -2
  10. package/dashboard/__init__.py +1 -1
  11. package/docs/INSTALLATION.md +1 -1
  12. package/docs/WANG-PRINCIPLES-PLAN.md +1 -1
  13. package/docs/WORKSPACES.md +29 -0
  14. package/docs/alternative-installations.md +3 -1
  15. package/docs/control-plane-container.md +45 -0
  16. package/docs/dev/architecture-reference.md +4 -0
  17. package/docs/dev/project-structure.md +1 -1
  18. package/docs/slack.md +26 -0
  19. package/docs/v10/BOARD.md +26 -1
  20. package/docs/v10/CONTROL-PLANE.md +4 -0
  21. package/docs/v10/D61-SPEED.md +7 -2
  22. package/docs/v10/DECISIONS.md +24 -0
  23. package/docs/v10/DEPS.md +6 -2
  24. package/docs/v10/E2E-D65.md +87 -0
  25. package/docs/v10/HANDOFF-NEXT-WEEK.md +10 -4
  26. package/docs/v10/PROGRESS.md +13 -0
  27. package/docs/v10/ROADMAP-D63.md +35 -0
  28. package/loki-ts/dist/loki.js +528 -491
  29. package/mcp/__init__.py +1 -1
  30. package/mcp/server.py +76 -0
  31. package/mcp/tests/test_v10_tools.py +137 -0
  32. package/mcp/v10_tools.py +172 -0
  33. package/package.json +1 -1
  34. package/packages/control-plane/dist/server.js +56 -4
  35. package/packages/control-plane/src/server/answer.ts +33 -0
  36. package/packages/control-plane/src/server/app.ts +17 -1
  37. package/packages/control-plane/src/server/runs.ts +2 -0
  38. package/packages/control-plane/src/server/serve.ts +2 -2
  39. package/packages/control-plane/ui/dist/assets/index-53E32yrO.js +114 -0
  40. package/packages/control-plane/ui/dist/assets/{index-BH3LtKcG.css → index-CFfrqCx2.css} +1 -1
  41. package/packages/control-plane/ui/dist/index.html +2 -2
  42. package/plugins/loki-mode/.claude-plugin/plugin.json +1 -1
  43. package/web-app/dist/assets/{AdminPage-DyjnUcaP.js → AdminPage-DN_pe8vo.js} +1 -1
  44. package/web-app/dist/assets/{Avatar-CY0sQNao.js → Avatar-BnQsr8z-.js} +1 -1
  45. package/web-app/dist/assets/{Badge-BmwTRu72.js → Badge-DzERiZn2.js} +1 -1
  46. package/web-app/dist/assets/{Button-BRf5aTlb.js → Button-CaaoxQAt.js} +1 -1
  47. package/web-app/dist/assets/{CockpitPage-RR94HuxS.js → CockpitPage-SXhnysJA.js} +1 -1
  48. package/web-app/dist/assets/{ComparePage-CFWAuPRO.js → ComparePage-krxTCFJY.js} +1 -1
  49. package/web-app/dist/assets/{ErrorBoundary-4cDADrG-.js → ErrorBoundary-BL96ESMc.js} +1 -1
  50. package/web-app/dist/assets/{EvidenceReceiptPanel-ChPFx2eM.js → EvidenceReceiptPanel-PUiGSpkB.js} +1 -1
  51. package/web-app/dist/assets/{GitHubIssuesPanel-B0TAjyeW.js → GitHubIssuesPanel-C0hPs-P2.js} +1 -1
  52. package/web-app/dist/assets/{GitHubPRsPanel-BbszEwW9.js → GitHubPRsPanel-CLvqNUlu.js} +1 -1
  53. package/web-app/dist/assets/{HomePage-DrBgzs1d.js → HomePage-X-sq_Rc9.js} +2 -2
  54. package/web-app/dist/assets/{LoginPage-Bu7b2DGz.js → LoginPage-DhQuwdua.js} +1 -1
  55. package/web-app/dist/assets/{MagicPage-BFEYp631.js → MagicPage-CP2ODjfn.js} +1 -1
  56. package/web-app/dist/assets/{MetricsPage-eIe2naVR.js → MetricsPage-C6SYiQES.js} +1 -1
  57. package/web-app/dist/assets/{NotFoundPage-BVWYkNt9.js → NotFoundPage-CCDxxYc-.js} +1 -1
  58. package/web-app/dist/assets/{ProjectPage-zmxyu732.js → ProjectPage-yDF--8FQ.js} +3 -3
  59. package/web-app/dist/assets/{ProjectsPage-Dq5NdCak.js → ProjectsPage-BBLspdLn.js} +1 -1
  60. package/web-app/dist/assets/{SettingsPage-CCrJOMe0.js → SettingsPage-C_sWbQRw.js} +1 -1
  61. package/web-app/dist/assets/{ShowcasePage-n-Ipzp01.js → ShowcasePage-xCMuL0N8.js} +1 -1
  62. package/web-app/dist/assets/{SystemSettingsPage-CP_rjYXK.js → SystemSettingsPage-DnJKGCx7.js} +1 -1
  63. package/web-app/dist/assets/{TeamsPage-B_P9DepW.js → TeamsPage-BBXdhrSs.js} +1 -1
  64. package/web-app/dist/assets/{TemplatesPage-h-3A6PQT.js → TemplatesPage-AZ1Lr1rn.js} +1 -1
  65. package/web-app/dist/assets/{TerminalOutput-pD0nJ1BU.js → TerminalOutput-CtIm9tXc.js} +1 -1
  66. package/web-app/dist/assets/{activity-ChFwzT-d.js → activity-Dk1FK25Q.js} +1 -1
  67. package/web-app/dist/assets/{bell-BX-eK8Gu.js → bell-dhqXyzP4.js} +1 -1
  68. package/web-app/dist/assets/{bot-fj5PkPtR.js → bot-CExHd4Kf.js} +1 -1
  69. package/web-app/dist/assets/{check-BGAbHlvD.js → check-D51zaA1a.js} +1 -1
  70. package/web-app/dist/assets/{chevron-left-BzFUxHgb.js → chevron-left-DEwnQzH1.js} +1 -1
  71. package/web-app/dist/assets/{circle-alert-BQrg5DWl.js → circle-alert-C6U90Msk.js} +1 -1
  72. package/web-app/dist/assets/{clock-CTu9lkVo.js → clock-6mFbMzzH.js} +1 -1
  73. package/web-app/dist/assets/{cloud-DrIENRiE.js → cloud-BrZZtE7F.js} +1 -1
  74. package/web-app/dist/assets/{code-xml-PaoVpfur.js → code-xml-DTFzeepP.js} +1 -1
  75. package/web-app/dist/assets/{database-DMmkAotn.js → database-B5rEiTre.js} +1 -1
  76. package/web-app/dist/assets/{dollar-sign-B2LQdBoX.js → dollar-sign-bQs4rlS_.js} +1 -1
  77. package/web-app/dist/assets/{file-code-corner-Dfv1XXeR.js → file-code-corner-Mo6e6qbf.js} +1 -1
  78. package/web-app/dist/assets/{file-plus-D2Wzf2wd.js → file-plus-DzHZ0jpP.js} +1 -1
  79. package/web-app/dist/assets/{globe-DxotVWt8.js → globe-DYN9UhTa.js} +1 -1
  80. package/web-app/dist/assets/{hammer-Brfy5ZrJ.js → hammer-bB0hSRcJ.js} +1 -1
  81. package/web-app/dist/assets/{index-BfhHMwi8.js → index-CIH5CqZB.js} +3 -3
  82. package/web-app/dist/assets/{layers-DfxSWDvp.js → layers-qhFkY8Nu.js} +1 -1
  83. package/web-app/dist/assets/{loader-circle-CxIrzNwx.js → loader-circle-BGBkaYH7.js} +1 -1
  84. package/web-app/dist/assets/{lock-m6nzhtpU.js → lock-DIcsNFum.js} +1 -1
  85. package/web-app/dist/assets/{package-ri9oE9Jc.js → package-BdIIbZhS.js} +1 -1
  86. package/web-app/dist/assets/{plus-DWeTAMVV.js → plus-Co1Bv8zT.js} +1 -1
  87. package/web-app/dist/assets/{refresh-cw-BGMSpaMh.js → refresh-cw-CGF4ABWH.js} +1 -1
  88. package/web-app/dist/assets/{rotate-ccw-DD1xBD_b.js → rotate-ccw-BX10Q79h.js} +1 -1
  89. package/web-app/dist/assets/{scroll-text-BCCR_AN9.js → scroll-text-CbXDlpGp.js} +1 -1
  90. package/web-app/dist/assets/{server-CmMWPIjS.js → server-CIhi6yUO.js} +1 -1
  91. package/web-app/dist/assets/{shield-alert-BZZmBVEJ.js → shield-alert-DJ5fB6Dg.js} +1 -1
  92. package/web-app/dist/assets/{thumbs-up-OaxbNRXu.js → thumbs-up-BEft2T94.js} +1 -1
  93. package/web-app/dist/assets/{trash-2-DmcSzTfr.js → trash-2-CWGUPn7Y.js} +1 -1
  94. package/web-app/dist/assets/{trending-down-BmfXLykz.js → trending-down-CjqstvxQ.js} +1 -1
  95. package/web-app/dist/assets/{trending-up-Djec3jIq.js → trending-up-CTyLjMzE.js} +1 -1
  96. package/web-app/dist/assets/{upload-BN8dXIlF.js → upload-BmRx-uv4.js} +1 -1
  97. package/web-app/dist/assets/{usePolling-Cu4yi04I.js → usePolling-ChHkSJye.js} +1 -1
  98. package/web-app/dist/assets/{user-IBI-k10o.js → user-ZXh_f35z.js} +1 -1
  99. package/web-app/dist/index.html +1 -1
  100. package/packages/control-plane/ui/dist/assets/index-B6EiyukW.js +0 -114
package/mcp/__init__.py CHANGED
@@ -75,4 +75,4 @@ try:
75
75
  except ImportError:
76
76
  __all__ = ['mcp']
77
77
 
78
- __version__ = '10.6.7'
78
+ __version__ = '10.6.8'
package/mcp/server.py CHANGED
@@ -1368,6 +1368,82 @@ async def get_pending_tasks() -> str:
1368
1368
  # ENTERPRISE TOOLS (P0-1)
1369
1369
  # ============================================================
1370
1370
 
1371
+ @mcp.tool()
1372
+ async def loki_v10_run(ref: str, repo_path: str) -> str:
1373
+ """
1374
+ Start a Loki 10 run in the background (same engine as `loki <ref>`).
1375
+
1376
+ Args:
1377
+ ref: Task text or issue reference (owner/repo#N or issue URL)
1378
+ repo_path: Repository directory (must be inside the project root)
1379
+
1380
+ Returns:
1381
+ JSON with run_id (null if not yet created), pid and log_path. Never blocks.
1382
+ """
1383
+ _emit_tool_event_async('loki_v10_run', 'start', parameters={'repo_path': repo_path})
1384
+ try:
1385
+ from mcp import v10_tools
1386
+ result = await asyncio.to_thread(
1387
+ v10_tools.v10_run, ref, repo_path, lambda p: validate_path(p, allowed_dirs=['.']))
1388
+ _emit_tool_event_async('loki_v10_run', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
1389
+ return json.dumps(result)
1390
+ except Exception as e:
1391
+ logger.error(f"loki_v10_run failed: {e}")
1392
+ _emit_tool_event_async('loki_v10_run', 'complete', result_status='error', error=str(e))
1393
+ return json.dumps({"error": str(e)})
1394
+
1395
+
1396
+ @mcp.tool()
1397
+ async def loki_v10_status(repo_path: str, run_id: str = "") -> str:
1398
+ """
1399
+ Read the state of a Loki 10 run from its events log.
1400
+
1401
+ Args:
1402
+ repo_path: Repository directory containing .loki/runs
1403
+ run_id: Run id (e10-...); defaults to the newest run
1404
+
1405
+ Returns:
1406
+ JSON with phase, done, verdict (once done) and cost_usd (null if unmeasured)
1407
+ """
1408
+ _emit_tool_event_async('loki_v10_status', 'start', parameters={'repo_path': repo_path, 'run_id': run_id})
1409
+ try:
1410
+ from mcp import v10_tools
1411
+ result = v10_tools.v10_status(
1412
+ run_id, repo_path, lambda p: validate_path(p, allowed_dirs=['.']))
1413
+ _emit_tool_event_async('loki_v10_status', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
1414
+ return json.dumps(result)
1415
+ except Exception as e:
1416
+ logger.error(f"loki_v10_status failed: {e}")
1417
+ _emit_tool_event_async('loki_v10_status', 'complete', result_status='error', error=str(e))
1418
+ return json.dumps({"error": str(e)})
1419
+
1420
+
1421
+ @mcp.tool()
1422
+ async def loki_v10_verify(receipt_path: str = "", repo_path: str = "") -> str:
1423
+ """
1424
+ Run `loki verify` on a receipt (or the newest run of a repo).
1425
+
1426
+ Args:
1427
+ receipt_path: Path to a receipt.json (takes priority)
1428
+ repo_path: Repository directory; verifies its newest run
1429
+
1430
+ Returns:
1431
+ JSON with exit_code, verified and an output summary
1432
+ """
1433
+ _emit_tool_event_async('loki_v10_verify', 'start', parameters={'receipt_path': receipt_path, 'repo_path': repo_path})
1434
+ try:
1435
+ from mcp import v10_tools
1436
+ result = await asyncio.to_thread(
1437
+ v10_tools.v10_verify, receipt_path, repo_path,
1438
+ lambda p: validate_path(p, allowed_dirs=['.']))
1439
+ _emit_tool_event_async('loki_v10_verify', 'complete', result_status='error' if isinstance(result, dict) and result.get('error') else 'success')
1440
+ return json.dumps(result)
1441
+ except Exception as e:
1442
+ logger.error(f"loki_v10_verify failed: {e}")
1443
+ _emit_tool_event_async('loki_v10_verify', 'complete', result_status='error', error=str(e))
1444
+ return json.dumps({"error": str(e)})
1445
+
1446
+
1371
1447
  @mcp.tool()
1372
1448
  async def loki_start_project(prd_content: str = "", prd_path: str = "") -> str:
1373
1449
  """
@@ -0,0 +1,137 @@
1
+ """Unit tests for mcp/v10_tools.py with subprocess mocked."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import unittest
9
+ from unittest import mock
10
+
11
+ _REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), '..', '..'))
12
+ if _REPO_ROOT not in sys.path:
13
+ sys.path.insert(0, _REPO_ROOT)
14
+
15
+ from mcp import v10_tools # noqa: E402
16
+
17
+
18
+ def _ok(p):
19
+ return os.path.realpath(p)
20
+
21
+
22
+ def _deny(p):
23
+ raise PermissionError("Access denied: " + p)
24
+
25
+
26
+ def _write_events(repo, run_id, events):
27
+ d = os.path.join(repo, ".loki", "runs", run_id)
28
+ os.makedirs(d)
29
+ with open(os.path.join(d, "events.jsonl"), "w") as f:
30
+ for e in events:
31
+ f.write(json.dumps(e) + "\n")
32
+
33
+
34
+ class RunTests(unittest.TestCase):
35
+ def test_rejects_flag_like_ref(self):
36
+ with mock.patch("subprocess.Popen") as popen:
37
+ r = v10_tools.v10_run("--help", "/x", _ok)
38
+ self.assertIn("error", r)
39
+ popen.assert_not_called()
40
+
41
+ def test_path_validation_failure(self):
42
+ with mock.patch("subprocess.Popen") as popen:
43
+ r = v10_tools.v10_run("fix the bug", "/etc", _deny)
44
+ self.assertIn("Access denied", r["error"])
45
+ popen.assert_not_called()
46
+
47
+ def test_starts_in_background_with_arg_list(self):
48
+ with tempfile.TemporaryDirectory() as repo, \
49
+ mock.patch.object(v10_tools, "V10_RUN_ID_WAIT_S", 0), \
50
+ mock.patch("subprocess.Popen") as popen:
51
+ popen.return_value.pid = 4242
52
+ r = v10_tools.v10_run("fix the bug", repo, _ok)
53
+ args, kwargs = popen.call_args
54
+ self.assertEqual(args[0][1], "fix the bug")
55
+ self.assertIsInstance(args[0], list)
56
+ self.assertNotIn("shell", kwargs)
57
+ self.assertTrue(kwargs["start_new_session"])
58
+ self.assertEqual(kwargs["env"]["LOKI_ENGINE"], "v10")
59
+ self.assertEqual(r["pid"], 4242)
60
+ self.assertIsNone(r["run_id"])
61
+ self.assertTrue(r["log_path"].startswith(os.path.realpath(repo)))
62
+
63
+
64
+ class StatusTests(unittest.TestCase):
65
+ def test_done_run(self):
66
+ ev = [
67
+ {"seq": 0, "type": "run.started", "stage": None, "data": {}},
68
+ {"seq": 1, "type": "stage.started", "stage": "plan", "data": {}},
69
+ {"seq": 2, "type": "cost", "stage": "plan", "data": {"usd": 0.5}},
70
+ {"seq": 3, "type": "stage.completed", "stage": "plan", "data": {}},
71
+ {"seq": 4, "type": "run.completed", "stage": None, "data": {"verdict": "VERIFIED"}},
72
+ ]
73
+ with tempfile.TemporaryDirectory() as repo:
74
+ _write_events(repo, "e10-a", ev)
75
+ r = v10_tools.v10_status("", repo, _ok)
76
+ self.assertEqual(r["run_id"], "e10-a")
77
+ self.assertTrue(r["done"])
78
+ self.assertEqual(r["verdict"], "VERIFIED")
79
+ self.assertEqual(r["phase"], "done")
80
+ self.assertAlmostEqual(r["cost_usd"], 0.5)
81
+
82
+ def test_running_and_unmeasured_cost(self):
83
+ ev = [
84
+ {"seq": 0, "type": "stage.started", "stage": "build", "data": {}},
85
+ {"seq": 1, "type": "cost", "stage": "build", "data": {}},
86
+ ]
87
+ with tempfile.TemporaryDirectory() as repo:
88
+ _write_events(repo, "e10-b", ev)
89
+ r = v10_tools.v10_status("e10-b", repo, _ok)
90
+ self.assertFalse(r["done"])
91
+ self.assertEqual(r["phase"], "build")
92
+ self.assertIsNone(r["cost_usd"])
93
+ self.assertIsNone(r["verdict"])
94
+
95
+ def test_bad_run_id_and_missing(self):
96
+ with tempfile.TemporaryDirectory() as repo:
97
+ self.assertIn("error", v10_tools.v10_status("../x", repo, _ok))
98
+ self.assertIn("error", v10_tools.v10_status("", repo, _ok))
99
+ self.assertIn("error", v10_tools.v10_status("", "", _ok))
100
+
101
+
102
+ class VerifyTests(unittest.TestCase):
103
+ def test_receipt_path_runs_verify(self):
104
+ with tempfile.TemporaryDirectory() as repo:
105
+ receipt = os.path.join(repo, "receipt.json")
106
+ open(receipt, "w").write("{}")
107
+ cp = subprocess.CompletedProcess([], 0, stdout="VERIFIED\n", stderr="")
108
+ with mock.patch("subprocess.run", return_value=cp) as run:
109
+ r = v10_tools.v10_verify(receipt, "", _ok)
110
+ args, kwargs = run.call_args
111
+ self.assertEqual(args[0][1:], ["verify", os.path.realpath(receipt)])
112
+ self.assertEqual(kwargs["timeout"], v10_tools.V10_VERIFY_TIMEOUT_S)
113
+ self.assertNotIn("shell", kwargs)
114
+ self.assertEqual(r["exit_code"], 0)
115
+ self.assertTrue(r["verified"])
116
+ self.assertIn("VERIFIED", r["output"])
117
+
118
+ def test_repo_path_nonzero_exit(self):
119
+ with tempfile.TemporaryDirectory() as repo:
120
+ cp = subprocess.CompletedProcess([], 1, stdout="", stderr="hash mismatch")
121
+ with mock.patch("subprocess.run", return_value=cp):
122
+ r = v10_tools.v10_verify("", repo, _ok)
123
+ self.assertEqual(r["exit_code"], 1)
124
+ self.assertFalse(r["verified"])
125
+
126
+ def test_timeout_and_validation(self):
127
+ with tempfile.TemporaryDirectory() as repo:
128
+ with mock.patch("subprocess.run", side_effect=subprocess.TimeoutExpired("loki", 1)):
129
+ self.assertIn("timed out", v10_tools.v10_verify("", repo, _ok)["error"])
130
+ with mock.patch("subprocess.run") as run:
131
+ self.assertIn("error", v10_tools.v10_verify("/etc/passwd", "", _deny))
132
+ self.assertIn("error", v10_tools.v10_verify("", "", _ok))
133
+ run.assert_not_called()
134
+
135
+
136
+ if __name__ == "__main__":
137
+ unittest.main()
@@ -0,0 +1,172 @@
1
+ """Loki 10 MCP tool helpers: start a v10 run, read its status, verify a receipt.
2
+
3
+ Plain functions returning dicts so they are testable without the MCP SDK; the
4
+ thin @mcp.tool wrappers live in mcp/server.py. Subprocesses use argument lists
5
+ only (never a shell), and every path is checked by the caller-supplied
6
+ validator before use.
7
+ """
8
+
9
+ import json
10
+ import os
11
+ import subprocess
12
+ import time
13
+ from pathlib import Path
14
+ from typing import Callable, Dict, List, Optional
15
+
16
+ LOKI_BIN = str(Path(__file__).resolve().parent.parent / "bin" / "loki")
17
+ RUNS_REL = os.path.join(".loki", "runs")
18
+ V10_RUN_ID_WAIT_S = 2.0
19
+ V10_VERIFY_TIMEOUT_S = 120
20
+ _TERMINAL = {"stage.completed", "stage.failed", "stage.skipped"}
21
+
22
+
23
+ def _env() -> Dict[str, str]:
24
+ env = dict(os.environ)
25
+ env["LOKI_ENGINE"] = "v10"
26
+ env["LOKI_NO_BROWSER"] = env.get("LOKI_NO_BROWSER", "1")
27
+ return env
28
+
29
+
30
+ def _run_ids(repo: str) -> List[str]:
31
+ d = os.path.join(repo, RUNS_REL)
32
+ if not os.path.isdir(d):
33
+ return []
34
+ return sorted(n for n in os.listdir(d) if os.path.isdir(os.path.join(d, n)))
35
+
36
+
37
+ def v10_run(ref: str, repo_path: str, validate: Callable[[str], str]) -> dict:
38
+ if not isinstance(ref, str) or not ref.strip() or ref.startswith("-") or "\x00" in ref:
39
+ return {"error": "invalid ref: must be a non-empty task or issue reference that does not start with '-'"}
40
+ try:
41
+ repo = validate(repo_path)
42
+ except Exception as e: # PathTraversalError from the server validator
43
+ return {"error": str(e)}
44
+ if not os.path.isdir(repo):
45
+ return {"error": f"repo_path is not a directory: {repo_path}"}
46
+ before = set(_run_ids(repo))
47
+ log_dir = os.path.join(repo, ".loki")
48
+ os.makedirs(log_dir, exist_ok=True)
49
+ log_path = os.path.join(log_dir, f"v10-mcp-{int(time.time() * 1000)}.log")
50
+ try:
51
+ with open(log_path, "ab") as log:
52
+ proc = subprocess.Popen(
53
+ [LOKI_BIN, ref],
54
+ cwd=repo,
55
+ env=_env(),
56
+ stdin=subprocess.DEVNULL,
57
+ stdout=log,
58
+ stderr=log,
59
+ start_new_session=True,
60
+ )
61
+ except OSError as e:
62
+ return {"error": f"could not start loki: {e}"}
63
+ run_id: Optional[str] = None
64
+ deadline = time.time() + V10_RUN_ID_WAIT_S
65
+ while True:
66
+ new = [r for r in _run_ids(repo) if r not in before]
67
+ if new:
68
+ run_id = new[-1]
69
+ break
70
+ if time.time() >= deadline:
71
+ break
72
+ time.sleep(0.1)
73
+ return {"run_id": run_id, "pid": proc.pid, "log_path": log_path}
74
+
75
+
76
+ def _read_events(path: str) -> List[dict]:
77
+ events = []
78
+ with open(path, "r", encoding="utf-8") as f:
79
+ for line in f:
80
+ line = line.strip()
81
+ if not line:
82
+ continue
83
+ try:
84
+ e = json.loads(line)
85
+ except ValueError:
86
+ continue
87
+ if isinstance(e, dict):
88
+ events.append(e)
89
+ return events
90
+
91
+
92
+ def v10_status(run_id: str, repo_path: str, validate: Callable[[str], str]) -> dict:
93
+ if not repo_path:
94
+ return {"error": "repo_path is required"}
95
+ try:
96
+ repo = validate(repo_path)
97
+ except Exception as e:
98
+ return {"error": str(e)}
99
+ if run_id:
100
+ if "/" in run_id or "\\" in run_id or run_id.startswith(".") or "\x00" in run_id:
101
+ return {"error": "invalid run_id"}
102
+ else:
103
+ ids = _run_ids(repo)
104
+ if not ids:
105
+ return {"error": "no v10 runs found under .loki/runs"}
106
+ run_id = ids[-1]
107
+ events_path = os.path.join(repo, RUNS_REL, run_id, "events.jsonl")
108
+ if not os.path.isfile(events_path):
109
+ return {"error": f"no events.jsonl for run {run_id}"}
110
+ events = _read_events(events_path)
111
+ open_stages: Dict[str, bool] = {}
112
+ verdict = None
113
+ done = False
114
+ usd = 0.0
115
+ saw_cost = False
116
+ unmeasured = False
117
+ for e in events:
118
+ t, stage = e.get("type"), e.get("stage")
119
+ data = e.get("data") if isinstance(e.get("data"), dict) else {}
120
+ if stage:
121
+ if t == "stage.started":
122
+ open_stages[stage] = True
123
+ elif t in _TERMINAL:
124
+ open_stages.pop(stage, None)
125
+ if t == "run.completed":
126
+ done = True
127
+ verdict = data.get("verdict") if isinstance(data.get("verdict"), str) else None
128
+ elif t == "cost":
129
+ saw_cost = True
130
+ if isinstance(data.get("usd"), (int, float)):
131
+ usd += data["usd"]
132
+ else:
133
+ unmeasured = True
134
+ phase = "done" if done else ("+".join(sorted(open_stages)) or "starting")
135
+ return {
136
+ "run_id": run_id,
137
+ "phase": phase,
138
+ "done": done,
139
+ "verdict": verdict,
140
+ "cost_usd": usd if saw_cost and not unmeasured else None,
141
+ "events": len(events),
142
+ }
143
+
144
+
145
+ def v10_verify(receipt_path: str, repo_path: str, validate: Callable[[str], str]) -> dict:
146
+ args = [LOKI_BIN, "verify"]
147
+ try:
148
+ if receipt_path:
149
+ receipt = validate(receipt_path)
150
+ if not os.path.isfile(receipt):
151
+ return {"error": f"receipt not found: {receipt_path}"}
152
+ args.append(receipt)
153
+ cwd = os.path.dirname(receipt)
154
+ elif repo_path:
155
+ cwd = validate(repo_path)
156
+ if not os.path.isdir(cwd):
157
+ return {"error": f"repo_path is not a directory: {repo_path}"}
158
+ else:
159
+ return {"error": "receipt_path or repo_path is required"}
160
+ except Exception as e:
161
+ return {"error": str(e)}
162
+ try:
163
+ p = subprocess.run(
164
+ args, cwd=cwd, env=_env(), stdin=subprocess.DEVNULL,
165
+ capture_output=True, text=True, timeout=V10_VERIFY_TIMEOUT_S,
166
+ )
167
+ except subprocess.TimeoutExpired:
168
+ return {"error": f"loki verify timed out after {V10_VERIFY_TIMEOUT_S}s"}
169
+ except OSError as e:
170
+ return {"error": f"could not run loki verify: {e}"}
171
+ out = ((p.stdout or "") + (p.stderr or "")).strip()
172
+ return {"exit_code": p.returncode, "verified": p.returncode == 0, "output": out[-2000:]}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "loki-mode",
3
3
  "mcpName": "io.github.asklokesh/loki-mode",
4
- "version": "10.6.7",
4
+ "version": "10.6.8",
5
5
  "description": "An autonomous software factory that knows what it is supposed to deliver, and proves it did.",
6
6
  "keywords": [
7
7
  "agent",
@@ -16,7 +16,7 @@ var __export = (target, all) => {
16
16
 
17
17
  // src/server/app.ts
18
18
  import { existsSync as existsSync2, statSync } from "fs";
19
- import { join as join2, resolve, sep } from "path";
19
+ import { join as join3, resolve, sep } from "path";
20
20
 
21
21
  // node_modules/hono/dist/request/constants.js
22
22
  var GET_MATCH_RESULT = Symbol();
@@ -5658,6 +5658,35 @@ var RULES = [
5658
5658
  ];
5659
5659
  var redactSecrets = (s) => RULES.reduce((a, [re, to]) => a.replace(re, to), s);
5660
5660
 
5661
+ // src/server/answer.ts
5662
+ import { chmodSync, mkdirSync, writeFileSync } from "fs";
5663
+ import { homedir } from "os";
5664
+ import { join as join2 } from "path";
5665
+ var MAX_ANSWER = 4000;
5666
+ var ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
5667
+ var defaultAnswerDir = (env = process.env) => env.LOKI_CONTROL_ANSWER_DIR || join2(env.HOME || homedir(), ".loki", "control", "answers");
5668
+ function blockedQuestion(evs, verdict) {
5669
+ if (verdict !== "SPEC_CONFLICT")
5670
+ return null;
5671
+ const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
5672
+ return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
5673
+ }
5674
+ function writeAnswer(dir, source, run, answer) {
5675
+ if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes(".."))
5676
+ return { status: 400, body: { error: "invalid run id" } };
5677
+ if (typeof answer !== "string" || answer.trim() === "")
5678
+ return { status: 400, body: { error: "answer must be a non-empty string" } };
5679
+ if (answer.length > MAX_ANSWER)
5680
+ return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
5681
+ const d = join2(dir, source);
5682
+ mkdirSync(d, { recursive: true, mode: 448 });
5683
+ const path = join2(d, `${run}.answer.txt`);
5684
+ writeFileSync(path, `${answer.trim()}
5685
+ `, { mode: 384 });
5686
+ chmodSync(path, 384);
5687
+ return { status: 200, body: { ok: true, path, resume: `loki "$(cat '${path}')" # re-run the task with the answer, or paste it into the issue` } };
5688
+ }
5689
+
5661
5690
  // src/server/runs.ts
5662
5691
  var str = (x) => typeof x === "string" ? x : null;
5663
5692
  function loadEvents(db, sourceId, runId) {
@@ -5792,6 +5821,7 @@ function runDetail(db, sourceId, runId) {
5792
5821
  const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
5793
5822
  return {
5794
5823
  ...withLive(db, r),
5824
+ blocked_question: blockedQuestion(evs, r.verdict),
5795
5825
  stages,
5796
5826
  stages_completed: f.completed,
5797
5827
  receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
@@ -5848,11 +5878,12 @@ function ingest(db, body) {
5848
5878
 
5849
5879
  // src/server/app.ts
5850
5880
  var MAX_BODY = 1e6;
5851
- var defaultUiDir = () => [join2(import.meta.dir, "../../ui/dist"), join2(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join2(d, "index.html")));
5881
+ var defaultUiDir = () => [join3(import.meta.dir, "../../ui/dist"), join3(import.meta.dir, "../ui/dist")].find((d) => existsSync2(join3(d, "index.html")));
5852
5882
  function createApp(opts) {
5853
5883
  const uiDir = opts.uiDir ?? defaultUiDir();
5854
5884
  const { db, sqlite } = openDb(opts.dbPath);
5855
5885
  let ready = true;
5886
+ const answerDir = opts.answerDir ?? defaultAnswerDir();
5856
5887
  const app = new Hono3;
5857
5888
  app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
5858
5889
  app.get("/ready", (c) => {
@@ -5885,6 +5916,27 @@ function createApp(opts) {
5885
5916
  return d ? c.json(d) : c.json({ error: "run not found" }, 404);
5886
5917
  };
5887
5918
  app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
5919
+ app.post("/v1/runs/:source/:run/answer", async (c) => {
5920
+ const source = c.req.param("source"), run = c.req.param("run");
5921
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json"))
5922
+ return c.json({ error: "content-type must be application/json" }, 400);
5923
+ const text2 = await c.req.text();
5924
+ if (text2.length > 20000)
5925
+ return c.json({ error: "body too large" }, 413);
5926
+ let body;
5927
+ try {
5928
+ body = JSON.parse(text2);
5929
+ } catch {
5930
+ return c.json({ error: "invalid JSON" }, 400);
5931
+ }
5932
+ const d = runDetail(db, source, run);
5933
+ if (!d)
5934
+ return c.json({ error: "run not found" }, 404);
5935
+ if (!d.blocked_question)
5936
+ return c.json({ error: "run is not blocked on a question" }, 409);
5937
+ const r = writeAnswer(answerDir, source, run, body?.answer);
5938
+ return c.json(r.body, r.status);
5939
+ });
5888
5940
  app.get("/v1/runs/:id", (c) => {
5889
5941
  const id = c.req.param("id");
5890
5942
  const i = id.indexOf(":");
@@ -5900,14 +5952,14 @@ function createApp(opts) {
5900
5952
  return new Response(Bun.file(f));
5901
5953
  if (/\.[a-z0-9]+$/i.test(p))
5902
5954
  return c.json({ error: "not found" }, 404);
5903
- return new Response(Bun.file(join2(root, "index.html")));
5955
+ return new Response(Bun.file(join3(root, "index.html")));
5904
5956
  });
5905
5957
  return { app, db, close: () => sqlite.close() };
5906
5958
  }
5907
5959
 
5908
5960
  // src/server/serve.ts
5909
5961
  var { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
5910
- var serve = (port) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
5962
+ var serve = (port) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
5911
5963
  var s;
5912
5964
  try {
5913
5965
  s = serve(Number(process.env.PORT ?? 0));
@@ -0,0 +1,33 @@
1
+ // BLOCKED answer: a human reply to a run's one question, written to a file the operator resumes from.
2
+ // File: <answerDir>/<source>/<run>.answer.txt (default ~/.loki/control/answers, override LOKI_CONTROL_ANSWER_DIR).
3
+ import { chmodSync, mkdirSync, writeFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
7
+
8
+ export const MAX_ANSWER = 4000;
9
+ const ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
10
+
11
+ export const defaultAnswerDir = (env: NodeJS.ProcessEnv = process.env): string =>
12
+ env.LOKI_CONTROL_ANSWER_DIR || join(env.HOME || homedir(), ".loki", "control", "answers");
13
+
14
+ /** The question a BLOCKED (spec conflict) run is waiting on, or null when it is not blocked. */
15
+ export function blockedQuestion(evs: EventEnvelope[], verdict: string | null): string | null {
16
+ if (verdict !== "SPEC_CONFLICT") return null;
17
+ const why = evs.find((e) => e.type === "stage.completed" && e.stage === "implement")?.data.spec_conflict_reason;
18
+ return (typeof why === "string" && why ? why : "the run is blocked on a spec conflict").replace(/[\x00-\x1f\x7f]+/g, " ").slice(0, 500);
19
+ }
20
+
21
+ export type AnswerResult = { status: 200; body: { ok: true; path: string; resume: string } } | { status: 400 | 413; body: { error: string } };
22
+
23
+ export function writeAnswer(dir: string, source: string, run: string, answer: unknown): AnswerResult {
24
+ if (!ID.test(source) || !ID.test(run) || source.includes("..") || run.includes("..")) return { status: 400, body: { error: "invalid run id" } };
25
+ if (typeof answer !== "string" || answer.trim() === "") return { status: 400, body: { error: "answer must be a non-empty string" } };
26
+ if (answer.length > MAX_ANSWER) return { status: 413, body: { error: `answer over ${MAX_ANSWER} characters` } };
27
+ const d = join(dir, source);
28
+ mkdirSync(d, { recursive: true, mode: 0o700 });
29
+ const path = join(d, `${run}.answer.txt`);
30
+ writeFileSync(path, `${answer.trim()}\n`, { mode: 0o600 });
31
+ chmodSync(path, 0o600);
32
+ return { status: 200, body: { ok: true, path, resume: `loki "$(cat '${path}')" # re-run the task with the answer, or paste it into the issue` } };
33
+ }
@@ -3,6 +3,7 @@ import { join, resolve, sep } from "node:path";
3
3
  import { Hono } from "hono";
4
4
  import { openDb } from "../db/migrate.ts";
5
5
  import { ingest } from "./ingest.ts";
6
+ import { defaultAnswerDir, writeAnswer } from "./answer.ts";
6
7
  import { listRuns, runDetail } from "./runs.ts";
7
8
 
8
9
  const MAX_BODY = 1_000_000;
@@ -11,10 +12,11 @@ const MAX_BODY = 1_000_000;
11
12
  const defaultUiDir = () => [join(import.meta.dir, "../../ui/dist"), join(import.meta.dir, "../ui/dist")].find((d) => existsSync(join(d, "index.html")));
12
13
 
13
14
  /** dbPath ":memory:" for tests. Migrations run here, so /ready is true as soon as this returns. uiDir overrides the built-UI location. */
14
- export function createApp(opts: { dbPath: string; uiDir?: string }) {
15
+ export function createApp(opts: { dbPath: string; uiDir?: string; answerDir?: string }) {
15
16
  const uiDir = opts.uiDir ?? defaultUiDir();
16
17
  const { db, sqlite } = openDb(opts.dbPath);
17
18
  let ready = true;
19
+ const answerDir = opts.answerDir ?? defaultAnswerDir();
18
20
  const app = new Hono();
19
21
 
20
22
  app.get("/health", (c) => c.json({ service: "loki-control", pid: process.pid, install_path: import.meta.dir }));
@@ -41,6 +43,20 @@ export function createApp(opts: { dbPath: string; uiDir?: string }) {
41
43
  return d ? c.json(d) : c.json({ error: "run not found" }, 404);
42
44
  };
43
45
  app.get("/v1/runs/:source/:run", (c) => detail(c, c.req.param("source"), c.req.param("run")));
46
+ // BLOCKED answer: only for a run blocked on a question; JSON content type required (blocks cross-site form posts).
47
+ app.post("/v1/runs/:source/:run/answer", async (c) => {
48
+ const source = c.req.param("source"), run = c.req.param("run");
49
+ if (!(c.req.header("content-type") ?? "").toLowerCase().startsWith("application/json")) return c.json({ error: "content-type must be application/json" }, 400);
50
+ const text = await c.req.text();
51
+ if (text.length > 20_000) return c.json({ error: "body too large" }, 413);
52
+ let body: { answer?: unknown } | null;
53
+ try { body = JSON.parse(text) as { answer?: unknown } | null; } catch { return c.json({ error: "invalid JSON" }, 400); }
54
+ const d = runDetail(db, source, run);
55
+ if (!d) return c.json({ error: "run not found" }, 404);
56
+ if (!d.blocked_question) return c.json({ error: "run is not blocked on a question" }, 409);
57
+ const r = writeAnswer(answerDir, source, run, body?.answer);
58
+ return c.json(r.body, r.status);
59
+ });
44
60
  // :id is `source:run` (run ids never contain a colon)
45
61
  app.get("/v1/runs/:id", (c) => {
46
62
  const id = c.req.param("id");
@@ -3,6 +3,7 @@ import { fold, partialCost } from "../../../../loki-ts/src/engine10/events.ts";
3
3
  import type { EventEnvelope } from "../../../../loki-ts/src/engine10/types.ts";
4
4
  import type { Db } from "../db/migrate.ts";
5
5
  import { events, runs } from "../db/schema.ts";
6
+ import { blockedQuestion } from "./answer.ts";
6
7
 
7
8
  const str = (x: unknown): string | null => (typeof x === "string" ? x : null);
8
9
 
@@ -108,6 +109,7 @@ export function runDetail(db: Db, sourceId: string, runId: string) {
108
109
  const sealed = evs.find((e) => e.type === "receipt.sealed")?.data;
109
110
  return {
110
111
  ...withLive(db, r),
112
+ blocked_question: blockedQuestion(evs, r.verdict),
111
113
  stages,
112
114
  stages_completed: f.completed,
113
115
  receipt: sealed ? { sha256: str(sealed.receipt_sha256), signed: sealed.signed === true, verdict: str(sealed.verdict), path: str(sealed.path) } : null,
@@ -1,10 +1,10 @@
1
1
  // Entry for `loki control serve` (bundled to dist/server.js). Config comes from env: PORT, LOKI_CONTROL_DB.
2
- // Binds loopback only. LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
2
+ // Binds loopback unless LOKI_CONTROL_HOST is set (the container image sets 0.0.0.0). LOKI_CONTROL_PORT_FALLBACK=1 (set for the default port, never for an explicit --port)
3
3
  // retries on any free port when PORT is taken; the URL printed below is always the real one.
4
4
  import { createApp } from "./app.ts";
5
5
 
6
6
  const { app } = createApp({ dbPath: process.env.LOKI_CONTROL_DB ?? "control.db" });
7
- const serve = (port: number) => Bun.serve({ port, hostname: "127.0.0.1", fetch: app.fetch });
7
+ const serve = (port: number) => Bun.serve({ port, hostname: process.env.LOKI_CONTROL_HOST || "127.0.0.1", fetch: app.fetch });
8
8
  let s: ReturnType<typeof serve>;
9
9
  try {
10
10
  s = serve(Number(process.env.PORT ?? 0));